Skip to content

GSR: fix the evaluator's prediction path and land the TrackLab staging tooling - #25

Open
AtomScott wants to merge 5 commits into
mainfrom
fix/gsr-prediction-path
Open

GSR: fix the evaluator's prediction path and land the TrackLab staging tooling#25
AtomScott wants to merge 5 commits into
mainfrom
fix/gsr-prediction-path

Conversation

@AtomScott

Copy link
Copy Markdown
Owner

Two things, both discovered while getting the GSR baseline to actually run end to end.

1. The GS-HOTA prediction path could not score a real prediction file

Three errors in sequence, all in soccertrack_records_to_gs (the flat-records → GameState
converter used for predictions):

# defect failure
1 bbox_pitch carried only x/y_bottom_middle scorer reads six keys with EVAL_SPACE='pitch'KeyError
2 image_id was an int scorer does len(image_id) == 10TypeError
3 predictions numbered frames 0-based; GT uses strings ("3000001" = 1-based frame 1) no prediction ever matched a GT frame

Why the existing test missed all three: the released GSR ground truth is already
SoccerNet GameState, so tests/test_gs_hota_identity.py symlinks one file in as both ground
truth and prediction and never calls the converter. It verified the scorer's config, not this
code path.

tests/test_gs_hota_prediction_path.py closes that gap — it re-encodes real ground truth into
the flat prediction layout, pushes it back through the converter, and scores it:

test_gs_hota_prediction_path:  HOTA=1.000000  LocA=1.000000   6600 pred dets = 6600 GT dets
test_gs_hota_identity:         HOTA=1.000000  (unchanged)

Also reconciles _SN_CATEGORIES with the released categories block (it said 4=other, 5=ball;
every real file says 4=ball). No score changes — pitch-space GS-HOTA keys off attributes.role.

2. scripts/gsr/ — the staging needed to run TrackLab on a 45-minute half

TrackLab reads frames from <seq>/img1/000001.jpg, never from video. Staging also repairs
three defects in the released labels: width/height say 3840x1504 when the frames are
4096x1080; info.id is "1" in all 20 files and info.name collides between halves;
and the GT annotates ~25 more frames than the video has.

<match>_calibrated_keypoints.json ships for 117093 only, so no other match could run at
all. The generator is validated by regenerating 117093's set and comparing the full
image→pitch chain against the shipped one: 0.0000 m over 725 on-pitch grid points.

Context that is not in this PR

A quadratic-time defect in TrackLab's merge_dataframes had to be fixed to make a full half
feasible — per-batch cost grew linearly (1.02 s/batch at batch 0 → 3.74 s by batch 2353),
making total cost quadratic in sequence length. That fix lives in the external tracklab tree.
It was validated by running a full 750-frame pipeline before and after: GS-HOTA 29.135% both
times, metric summary files byte-identical
, only float noise at 3.9e-11 m differing.

First real numbers (30 s clip, match 117093)

configuration GS-HOTA DetA AssA LocA DetRe
roles+teams+jersey (official) 29.13 9.70 87.60 92.15 17.57
roles+teams, no jersey 49.99 34.99 71.67 86.61 50.65
roles only 62.53 55.87 70.34 87.14 69.55
no attributes (geometry only) 64.41 59.61 70.09 86.50 72.14

Detection and pitch projection are good — 72% recall. Jersey numbers cost 20.9 points,
team classification 12.5. AssA 87.60 is a selection effect, not an association result.

🤖 Generated with Claude Code

AtomScott and others added 5 commits August 11, 2026 20:08
…ion file

Scoring the first real GSR predictions raised three errors in sequence. All three lived in
`soccertrack_records_to_gs`, the flat-records -> GameState converter used for predictions:

  1. bbox_pitch carried only x/y_bottom_middle. With EVAL_SPACE='pitch' the upstream scorer
     reads six keys unconditionally (x/y_bottom_left, _middle, _right) -> KeyError.
  2. image_id was an int. The scorer's unmatched-id branch does `len(image_id) == 10`
     -> TypeError: object of type 'int' has no len().
  3. predictions numbered frames 0-based while the ground truth uses string ids
     ("3000001" is 1-based frame 1), so no prediction ever matched a ground-truth frame.

tests/test_gs_hota_identity.py could not catch any of this: because the released ground truth
is already GameState, that test symlinks one file in as both ground truth and prediction and
never calls the converter. It verified the scorer's configuration, not this code path.

Changes:
  * emit all six bbox_pitch keys. The three points are degenerate in the released GT (left ==
    middle == right), so replicating the middle across all three matches how the GT is built.
  * `image_id_for_frame` maps a 0-based frame index to the GT's string id, defaulting to the
    released convention, with `image_id_mapper_from_gt` to derive it from a GT file instead of
    trusting the default. Measured 100% coverage: 67,625 of 67,625 prediction frames for
    128057's first half.
  * reconcile _SN_CATEGORIES with the released categories block (1 player, 2 goalkeeper,
    3 referee, 4 ball). It previously said 4=other, 5=ball, which disagreed with every real
    file. Pitch-space GS-HOTA keys off attributes.role, so no score changed.

Adds tests/test_gs_hota_prediction_path.py, which re-encodes real ground truth into the flat
prediction layout, pushes it back through the converter and scores it. A perfect prediction can
only reach 1.0 if the pitch keys, category_id and id mapping are all correct.

  test_gs_hota_prediction_path: HOTA=1.000000 LocA=1.000000, 6600 pred dets = 6600 GT dets
  test_gs_hota_identity:        HOTA=1.000000 (unchanged)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eproducible

Getting TrackLab to run on a 45-minute half needed staging work that existed only in a
scratchpad. This lands it, with the reasoning, so it does not have to be rediscovered.

scripts/gsr/
  stage_soccernetgs.py         extract img1/*.jpg and repair the label metadata
  make_calibrated_keypoints.py generate the calibrated keypoint sets the release omits
  stage_prefix_sequences.py    nested-prefix sequences for the length study, zero extra disk
  score_length_sweep.py        score each run on its own length AND on a shared window
  plot_length_sweep.py         accuracy / runtime / memory vs length
  README.md                    the reproduction sequence and the measured costs

Why each is needed:

* TrackLab reads frames from <seq>/img1/000001.jpg, never from video, so a half must be
  extracted (~110 GB at -q:v 2, matching the 1.67 MB/frame of the pre-existing staged clip).
* Staging also repairs three defects in the released labels: width/height say 3840x1504 when
  the frames are 4096x1080; info.id is "1" in all 20 files and info.name collides between the
  two halves of a match; and the GT annotates ~25 more frames than the video contains, so
  seq_length must be clamped or TrackLab requests JPEGs that cannot exist.
* <match>_calibrated_keypoints.json ships for 117093 ONLY, and manual_calib_distorted needs a
  distorted and a calibrated set, so no other match could run. The calibrated set is just an
  intermediate space (TPS maps distorted->calibrated, H_pc maps pitch->calibrated, both fit
  from the same points, so the canvas cancels); make_calibrated_keypoints.py proves this by
  regenerating 117093's and comparing the full image->pitch chain to the shipped one, getting
  0.0000 m over 725 on-pitch grid points. It still normalises the canvas, because a raw
  balance=0 undistortion puts 128057's control points at x in [-7973, 11234] for a 4096-wide
  image and the TPS then extrapolates wildly. 132831 picks up data_corrections/ automatically.
* The prefix sequences symlink img1, so a 30-minute prefix costs a label file and no frames.

The README records the measured per-module costs, the two non-obvious flags the run cannot
work without (use_rich=False or the log stays empty; num_cores=12 for a 2.05x speedup over the
shipped num_cores=1), and the attribute ablation showing jersey numbers cost 20.9 GS-HOTA
points and team classification 12.5, while detection recall is a healthy 72%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One file, no server, no sibling assets: plots and videos are embedded as data URIs, with videos
transcoded down first (a 4096-wide 30 s clip is ~7 MB and base64 inflates by a third). Full
resolution originals stay on disk and are listed by path.

Re-runnable at any point. Every section degrades gracefully when its inputs are absent, so a
report generated mid-experiment is still valid, just shorter -- which matters when the thing
being reported on is a sequence of overnight GPU runs.

    python scripts/gsr/make_report.py --work <scratchpad> --out report.html

Sections: the length decomposition (what sequence length does and does not break), the length
sweep, the attribute ablation, the minute-0 vs minute-1 diagnosis, GTA's tracklet bookkeeping,
runtime and memory, the code defects found and fixed, and the open questions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ging for all 20 halves

The label-to-imagery offset is NOT uniform. It falls into two clusters, roughly 0 and roughly 25
frames, and varies by HALF rather than by match:

    offset 0  : 117092 1st, 117093 2nd, 118575 1st+2nd, 118577 1st, 118578 2nd
    offset 25 : the other fourteen

Assuming a single value would have misaligned six halves by a second in one direction or
fourteen in the other. Two independent methods now agree on every half:

  * arithmetic: (End - Start) * fps / 1000 from <match>_padding_info.csv, minus the video's
    frame count. This is what stage_soccernetgs.py applies.
  * measurement: scripts/gsr/measure_frame_offset.py, which asks for each candidate lag whether
    the ground-truth boxes land on players or on empty grass. A pitch is overwhelmingly green
    and players are not, so box occupancy peaks at the correct lag. No detector, no prior
    staging, and it never consults the evaluation metric.

Agreement on 18 of 18 halves where the measurement had signal. For 132831 and 132877 the
occupancy curve is flat (gain 0.001-0.004, z below 2.6) so the estimator is uninformative and
the arithmetic is applied there WITHOUT independent confirmation -- noted rather than hidden.

An earlier worry that padding_info's declared frame count disagrees with the ground truth's own
seq_length by up to 251 frames turned out not to affect the offset: that surplus is annotation
past the end of the video, which staging trims. The measurement is what settled it.

measure_frame_offset.py carries a --validate mode that checks it against the two halves whose
offset was established independently, because three earlier versions of the estimator failed:
a background-subtraction centroid returned 67 against a known 25 (at kickoff players stand
still, so a median background contains them and subtraction erases them), and seeking with
cap.set(CAP_PROP_POS_FRAMES) injected an unknown offset of its own, not being frame-accurate on
these files. Do not remove --validate.

Labels re-staged for all 20 halves. Frames are untouched: the defect is purely which
annotations attach to which frame, so no re-extraction was needed.

Also adds a 2-minute prefix to stage_prefix_sequences.py, and an experiments + offsets section
to the HTML report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nk by confidence

Two defects of the same shape: both make an evaluator or loader unusable on real data, and
neither could be caught by the identity tests already in the tree.

1. _parse_gsr could not read a single released file
   It did `records = json.loads(...)` then iterated the result as a list. Every released GSR
   file is a JSON OBJECT (SoccerNet GameState), so iteration yielded the top-level key strings
   and `r["image_id"]` raised "TypeError: string indices must be integers" on all 20 files.
   Both layouts are now accepted, dispatching on the parsed type rather than on a filename:
   GameState objects (released ground truth; string image_id with a 1-based numeric suffix,
   dict bboxes, pitch position in bbox_pitch's bottom-middle keys) and flat record lists (what
   predictions use; 0-based integer image_id, sequence bboxes).

   Frame.image_id keeps whichever convention the file uses -- 1-based for GameState, 0-based
   for flat records -- rather than silently reconciling them, because the offset between
   annotations and video is a per-half property that has to be MEASURED. Assuming it cost this
   project 18 GS-HOTA points once already; see scripts/gsr/measure_frame_offset.py.

2. The BAS evaluator threw away confidence ranking
   `_ap_tolerant` states its contract in a comment -- "callers: sort by confidence desc" -- and
   its own caller `_map_per_class` then sorted by `(half, t_ms)`, by TIME, discarding it. Event
   had no score field at all. So the metric was not average precision but a time-ordered
   precision-recall traversal: a detector gained nothing from ranking its output well and was
   not penalised for emitting a flood of low-confidence spots. Every BAS number the project
   ever produced would have been wrong in that way.

   Event gains `score`, _parse_bas reads it from "score" or "confidence", and _map_per_class
   ranks by descending score with a deterministic time fallback for ties.

Neither identity test could have caught either defect: scoring ground truth against itself
makes every prediction a true positive, so AP is 1.0 in any order, and no test pointed the GSR
loader at real data. The two new tests are built to discriminate:

  tests/test_gsr_loader.py       fails with TypeError against the old loader; asserts on real
                                 data (22 entities in every frame, roles player/goalkeeper only,
                                 1-based first frame, pitch coords in range)
  tests/test_bas_map_ranking.py  AP 1.0000 with false positives ranked last vs 0.5000 ranked
                                 first. Equal scores mean ranking is being ignored.

All five tests pass:
  test_bas_map_identity, test_bas_map_ranking, test_gsr_loader,
  test_gs_hota_prediction_path, test_gs_hota_identity

Also adds docs/experiment-design-bas.md: the BAS plan, the resolved time-alignment question
(position is absolute from match start, not per-half as format-bas.md claims), why a
ball-centred crop from ground truth is an ORACLE and must be labelled as one, and five open
questions for Atom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant