Skip to main content

๐Ÿ“ Analysis JSON Schema

:::warning Audit scope This schema is owned by the excluded GPU-backend contract and was not re-verified during the 2026-07-23 non-GPU repository audit. Its older last_verified date is intentional. Validate it against a current production artifact and the GPU repository before implementing a consumer from this page. :::

The canonical shape of every analysis output from the GPU backend.

:::tip TL;DR

  • Schema version: 1.5.0
  • Written by: src/export/analysis_export.py in acesense-gpu-backend
  • Path in Storage: results/{uid}/{sessionId}/{sessionId}_combined.json
  • Read by: Flutter frontend, Admin panel, mergeChunkResults Cloud Function
  • Wire format: minified JSON (single line, no whitespace) :::

๐Ÿงพ Top-level shapeโ€‹

{
"schema_version": "1.5.0",
"generated_at": "2026-04-11T12:34:56.789Z",

"video": { /* video metadata */ },
"court": { /* court geometry + coordinate system */ },
"session": { /* session-level info */ },
"physics": { /* physics metadata */ },
"stats": { /* aggregate metrics */ },

"ball_trajectory": { /* per-frame ball positions */ },
"bounces": [ /* bounce events */ ],
"point_ends": [ /* rally end frames */ ],
"players": [ /* per-frame player positions in court coords */ ],
"shots": [ /* per-shot data with pose */ ],
"speed_heatmap": [ /* landing position + speed */ ],
"rallies": [ /* consecutive shot sequences */ ],
"insights": { /* ranked Phase 6 insight candidates (also written standalone to insights.json) */ },
"processing": { /* per-stage timing */ }
}

The insights object is produced by the Phase 6 insight engine (src/insights/, emitted via src/pipeline/phase6_outputs.py) and is also written as a standalone insights.json artifact alongside the analysis file.

Bump schema_version and update this page whenever the shape changes. The current flow produces one *_combined.json per single-file upload (chunking is deferred โ€” see job document).


๐Ÿ“บ videoโ€‹

"video": {
"fps": 30,
"total_frames": 8100,
"duration_ms": 270000,
"width": 1920,
"height": 1080
}
FieldTypeNotes
fpsintVideo's native frame rate
total_framesintTotal frame count โ€” falls back to ball_track length if video info is missing
duration_msinttotal_frames * 1000 / fps (rounded)
width, heightintPixel dimensions of the source video

๐ŸŽพ courtโ€‹

"court": {
"width_m": 10.97,
"length_m": 23.77,
"net_y_m": 11.885,
"coordinate_system": "meters",
"origin": "far_baseline_left_sideline",
"notes": "x: 0=left sideline, 10.97=right. y: 0=far baseline, 23.77=near baseline. Net at y=11.885"
}

Fixed for all tennis courts โ€” these are ITF regulation dimensions for a doubles court.

:::warning Coordinate system gotcha (updated in v46) As of the v46 accuracy overhaul (April 2026), all court coordinates are in real meters using ITF regulation dimensions. Prior versions used normalized 0โ€“1 values โ€” if you're reading old cached analysis JSON, check the schema_version.

All court_x / court_y / player x / y / landing x,y fields are in meters, not pixels, and are in this coordinate system:

  • x = 0 โ†’ left sideline
  • x = 10.97 โ†’ right sideline
  • y = 0 โ†’ far baseline
  • y = 11.885 โ†’ net
  • y = 23.77 โ†’ near baseline

Translating to pixels for the frontend requires the image dimensions, which are not in the schema โ€” use video.width / video.height + a projection. :::


๐Ÿ“Š statsโ€‹

Aggregate metrics summarizing the whole session. Written from metrics["summary"]:

"stats": {
"total_shots": 42,
"shot_counts": {
"forehand": 18,
"backhand": 15,
"serve": 8,
"volley": 1
},
"depth_distribution": {
"baseline": 20,
"midcourt": 15,
"net": 7
},
"max_speed_kmh": 142.8,
"avg_speed_kmh": 98.4,
"consistency_score": 72.3
}

consistency_score is a weighted combo (70% speed coefficient-of-variation + 30% depth distribution uniformity), clamped to [0, 100]. 100 = perfectly consistent session, 0 = wild inconsistency.


๐Ÿ€ ball_trajectoryโ€‹

Per-frame ball position in both pixel and court coordinates.

"ball_trajectory": {
"frames": [0, 1, 2, 3, ...],
"timestamp_ms": [0, 33, 66, 100, ...],
"video_x": [960.5, 962.1, 963.7, ...],
"video_y": [540.2, 541.0, 541.9, ...],
"court_x": [5.49, 5.51, 5.53, ...],
"court_y": [11.88, 11.90, 11.92, ...]
}

All arrays have the same length and index by position. Missing frames are omitted entirely (the array is compact, not sparse). To look up a specific frame, use a frame โ†’ index map:

const frameToIdx = new Map(trajectory.frames.map((f, i) => [f, i]));
const pos = { x: trajectory.court_x[frameToIdx.get(1234)!], y: trajectory.court_y[...]};

๐Ÿ bouncesโ€‹

Ball-floor contact events. Each is a point where the ball touches down.

"bounces": [
{ "frame": 142, "timestamp_ms": 4733, "x": 5.48, "y": 19.22 },
{ "frame": 287, "timestamp_ms": 9566, "x": 2.10, "y": 4.81 },
...
]
FieldTypeNotes
frameintFrame where the bounce occurred
timestamp_msintframe * 1000 / fps
xfloat|nullCourt x in meters (may be null if bounce frame isn't in ball trajectory)
yfloat|nullCourt y in meters

Detected by the CatBoost bounce classifier.


๐Ÿ point_endsโ€‹

Frames where a rally concluded (winner, error, net). Currently a bare list of frame indices with timestamps.

"point_ends": [
{ "frame": 450, "timestamp_ms": 15000 },
{ "frame": 892, "timestamp_ms": 29733 }
]

๐Ÿƒ playersโ€‹

Per-frame player positions in court coordinates, one list per player. The exact shape is produced by transform_players_to_court_coordinates() and includes homography-unprojected positions.

Typical shape:

"players": [
{
"id": "top",
"position": "top",
"court_side": "far_court",
"positions": [
{ "frame": 0, "timestamp_ms": 0, "x": 5.48, "y": 1.2 },
{ "frame": 1, "timestamp_ms": 33, "x": 5.51, "y": 1.21 },
...
]
},
{
"id": "bottom",
"position": "bottom",
"court_side": "near_court",
"positions": [ ... ]
}
]

๐ŸŽฏ shots (the big one)โ€‹

The main output. One entry per detected shot.

{
"shot_number": 1,
"frame_idx": 142,
"timestamp_ms": 4733,
"start_frame": 130,
"end_frame": 158,
"duration_ms": 933,

"is_serve": false,
"serve_frame_idx": null,

"player": {
"id": "bottom",
"position": "bottom",
"court_side": "near_court",
"hit_position": {
"x": 5.12,
"y": 22.40
}
},

"landing": {
"position": { "x": 2.30, "y": 5.80 },
"zone": "deuce_backcourt"
},

"classification": {
"type": "forehand",
"confidence": 0.8734,
"probabilities": {
"forehand": 0.87,
"backhand": 0.09,
"serve": 0.02,
"volley": 0.02
}
},

"camera_view": "baseline",

"physics": {
"speed_kmh": 112.3,
"depth": "baseline",
"spin_factor": 0.5
},

"pose_sequence": [
{
"frame_idx": 130,
"timestamp_ms": 4333,
"landmarks": [
{ "x": 0.512, "y": 0.643, "z": -0.024, "visibility": 0.97 },
/* 32 more MediaPipe landmarks */
]
},
/* ... one entry per N frames (N = pose_frame_skip, default 2) */
]
}

Field detailโ€‹

FieldTypeNotes
shot_numberint1-indexed within the session
frame_idxintCenter frame of the shot
start_frame, end_frameintFrame range of the shot swing
duration_msint(end - start) * 1000 / fps
is_serveboolWhether this shot was classified as a serve
serve_frame_idxint|nullFrame where the serve motion was detected
player.id"top" | "bottom"Which player hit the shot
player.hit_position{x, y}Player's court coords when contact was made
landing.position{x, y}Where the ball landed (court coords, meters)
landing.zonestringDerived zone label (e.g. "deuce_backcourt", "net")
classification.typestring"forehand" | "backhand" | "serve" | "volley" | "unknown"
classification.confidencefloat0-1 softmax probability of the top class
classification.probabilitiesmapFull distribution over classes
camera_viewstring"baseline" | "side" | "unknown"
physics.speed_kmhfloatEstimated ball speed through this shot
physics.depthstring"baseline" | "midcourt" | "net"
physics.speed_kmhfloatBall speed clamped to 263 km/h physical max (v46 fix)
physics.depthstring"baseline" | "midcourt" | "net" โ€” based on court-y zone
physics.spin_factorfloatPlaceholder 0.5 โ€” real spin analysis pending
pose_sequencearrayMediaPipe pose landmarks subsampled every pose_frame_skip frames

Pose landmarksโ€‹

Each landmark is a MediaPipe output โ€” 33 per frame, indexed per the MediaPipe BlazePose topology.

{ "x": 0.512, "y": 0.643, "z": -0.024, "visibility": 0.97 }
  • x, y, z: normalized image coordinates (0-1), 3 decimals
  • visibility: occlusion score (0-1), 2 decimals

:::note Why pose_frame_skip? Pose data is huge โ€” 33 landmarks ร— 4 floats ร— 30 fps quickly dwarfs everything else. We subsample every 2nd frame by default. Rendering is still smooth because the UI interpolates. :::


๐Ÿ”ฅ speed_heatmapโ€‹

Convenience list for frontend heatmap rendering. Each entry is a landing point with speed + shot type:

"speed_heatmap": [
{
"x": 2.30,
"y": 5.80,
"speed_kmh": 112.3,
"is_serve": false,
"shot_type": "forehand"
},
...
]

This is redundant with shots[] โ€” it's pre-filtered to just landings with valid coordinates, to save the frontend from doing that walk itself.


๐Ÿ” ralliesโ€‹

Grouped shot sequences representing exchanges. Built by _build_rally_sequences().

"rallies": [
{
"rally_number": 1,
"start_frame": 130,
"end_frame": 487,
"duration_ms": 11900,
"shot_count": 6,
"winner": "bottom",
"shot_indices": [0, 1, 2, 3, 4, 5]
},
...
]

(Exact shape depends on the _build_rally_sequences implementation โ€” check source if building against this.)


โฑ๏ธ processingโ€‹

Per-stage timing for observability. All values in seconds, rounded to 2 decimals.

"processing": {
"ball_tracking": 30.12,
"court_detection": 10.45,
"player_detection": 40.87,
"bounce_detection": 2.18,
"shot_classification": 4.56,
"export": 1.22,
"total": 89.40
}

Use this to spot regressions: if ball_tracking suddenly doubles after a model update, something's off.


๐Ÿงช Schema evolution policyโ€‹

Change typeRequired
Add a new fieldBump minor (1.5.0 โ†’ 1.6.0)
Rename a fieldBump major (1.x.x โ†’ 2.0.0) + migration code on reader side
Add a new top-level keyBump minor
Remove a fieldBump major + keep field with null for one minor version first
Change a field's semanticsBump major
warning

Readers must check schema_version and handle the version they understand. If they see a newer major, refuse to parse and prompt for a client update.


๐ŸŽฏ Next Stepsโ€‹