๐ 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.pyinacesense-gpu-backend - Path in Storage:
results/{uid}/{sessionId}/{sessionId}_combined.json - Read by: Flutter frontend, Admin panel,
mergeChunkResultsCloud 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
}
| Field | Type | Notes |
|---|---|---|
fps | int | Video's native frame rate |
total_frames | int | Total frame count โ falls back to ball_track length if video info is missing |
duration_ms | int | total_frames * 1000 / fps (rounded) |
width, height | int | Pixel 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 sidelinex = 10.97โ right sideliney = 0โ far baseliney = 11.885โ nety = 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 },
...
]
| Field | Type | Notes |
|---|---|---|
frame | int | Frame where the bounce occurred |
timestamp_ms | int | frame * 1000 / fps |
x | float|null | Court x in meters (may be null if bounce frame isn't in ball trajectory) |
y | float|null | Court 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โ
| Field | Type | Notes |
|---|---|---|
shot_number | int | 1-indexed within the session |
frame_idx | int | Center frame of the shot |
start_frame, end_frame | int | Frame range of the shot swing |
duration_ms | int | (end - start) * 1000 / fps |
is_serve | bool | Whether this shot was classified as a serve |
serve_frame_idx | int|null | Frame 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.zone | string | Derived zone label (e.g. "deuce_backcourt", "net") |
classification.type | string | "forehand" | "backhand" | "serve" | "volley" | "unknown" |
classification.confidence | float | 0-1 softmax probability of the top class |
classification.probabilities | map | Full distribution over classes |
camera_view | string | "baseline" | "side" | "unknown" |
physics.speed_kmh | float | Estimated ball speed through this shot |
physics.depth | string | "baseline" | "midcourt" | "net" |
physics.speed_kmh | float | Ball speed clamped to 263 km/h physical max (v46 fix) |
physics.depth | string | "baseline" | "midcourt" | "net" โ based on court-y zone |
physics.spin_factor | float | Placeholder 0.5 โ real spin analysis pending |
pose_sequence | array | MediaPipe 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 decimalsvisibility: 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 type | Required |
|---|---|
| Add a new field | Bump minor (1.5.0 โ 1.6.0) |
| Rename a field | Bump major (1.x.x โ 2.0.0) + migration code on reader side |
| Add a new top-level key | Bump minor |
| Remove a field | Bump major + keep field with null for one minor version first |
| Change a field's semantics | Bump major |
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โ
- ๐พ Firestore Data Model โ how jobs + sessions reference this file
- ๐ค GPU Backend Overview โ who writes it
- ๐ฑ Frontend Overview โ who reads it
- โก mergeChunkResults โ how chunks are stitched