Shoraky commited on
Commit
9089e3e
·
0 Parent(s):
Files changed (6) hide show
  1. .dockerignore +10 -0
  2. .gitignore +12 -0
  3. Dockerfile +29 -0
  4. README.md +34 -0
  5. api.py +2365 -0
  6. requirements.txt +13 -0
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .git/
5
+ .sporalize_runtime/
6
+ Storage/
7
+ Weights/
8
+ ViTPose/
9
+ pipeline.py
10
+ DEPLOYMENT.md
.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .sporalize_runtime/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ .venv/
9
+ venv/
10
+ Storage/
11
+ Weights/
12
+ ViTPose/
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1 \
6
+ PORT=7860 \
7
+ HF_HOME=/home/appuser/.cache/huggingface
8
+
9
+ RUN apt-get update && apt-get install -y --no-install-recommends \
10
+ ffmpeg \
11
+ libglib2.0-0 \
12
+ libgl1 \
13
+ libsm6 \
14
+ libxext6 \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ RUN useradd --create-home --uid 1000 appuser
18
+ WORKDIR /app
19
+
20
+ COPY --chown=appuser:appuser requirements.txt /app/requirements.txt
21
+ RUN pip install --upgrade pip && pip install -r /app/requirements.txt
22
+
23
+ COPY --chown=appuser:appuser api.py /app/api.py
24
+ COPY --chown=appuser:appuser README.md /app/README.md
25
+
26
+ USER appuser
27
+ EXPOSE 7860
28
+
29
+ CMD ["sh", "-c", "uvicorn api:app --host 0.0.0.0 --port ${PORT:-7860}"]
README.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Sporalize API
3
+ emoji: ⚽
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Sporalize API
12
+
13
+ Public Docker Space for the Sporalize backend API.
14
+
15
+ Runtime behavior:
16
+
17
+ - Does not bundle private runtime code or model assets in the public image.
18
+ - Loads `pipeline.py`, `ViTPose`, optional `Weights`, and seeded `Storage` from the private runtime repo at startup.
19
+ - Defaults `SPORALIZE_ASSETS_REPO_ID` to `SPORALIZE_STORAGE_REPO_ID`, which defaults to `Shoraky/SporalizeLabs-runtime-private`.
20
+ - Downloads model weights at startup if they are not already present in the private repo or cache.
21
+ - Serves the FastAPI API on port `7860`.
22
+ - Exposes `/healthz` with `pipelineSource`, `pipelineRoot`, and private asset repo metadata so the frontend deployment can verify which pipeline source is active.
23
+
24
+ Required deployment secret:
25
+
26
+ - `HF_TOKEN`: token with read access to the private runtime repo and write access if archive storage sync is enabled.
27
+
28
+ Optional deployment variables:
29
+
30
+ - `SPORALIZE_ASSETS_REPO_ID`: override the private repo that provides `pipeline.py` and `ViTPose`.
31
+ - `SPORALIZE_ASSETS_REVISION`: pin the private pipeline to a branch, tag, or commit.
32
+ - `SPORALIZE_STORAGE_REPO_ID`: override the private repo used for persistent `Storage` sessions.
33
+ - `SPORALIZE_PUBLIC_BASE_URL`: public API URL used when returning `/storage/...` links.
34
+ - `CORS_ALLOW_ORIGINS`: comma-separated frontend origins; omit or set `*` for open CORS.
api.py ADDED
@@ -0,0 +1,2365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import time
4
+ import uuid
5
+ import shutil
6
+ import traceback
7
+ import re
8
+ import sys
9
+ import importlib.util
10
+ import threading
11
+ import subprocess
12
+ import concurrent.futures
13
+ from urllib.parse import urlsplit, urlunsplit
14
+ import cv2
15
+ import numpy as np
16
+ from fastapi import FastAPI, File, UploadFile, Form, Request, HTTPException
17
+ from fastapi.middleware.cors import CORSMiddleware
18
+ from fastapi.concurrency import run_in_threadpool
19
+ from fastapi.responses import JSONResponse
20
+ from fastapi.staticfiles import StaticFiles
21
+ from typing import List
22
+ from huggingface_hub import hf_hub_download, snapshot_download, HfApi
23
+
24
+ app = FastAPI(title="Sporalize Labs 3D Analysis Engine")
25
+
26
+ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
27
+
28
+
29
+ def default_runtime_root():
30
+ if os.path.isdir("/data"):
31
+ return os.path.join("/data", "sporalize_runtime")
32
+ return os.path.join(os.path.expanduser("~"), ".sporalize_runtime")
33
+
34
+
35
+ RUNTIME_ROOT = os.environ.get("SPORALIZE_RUNTIME_DIR", default_runtime_root())
36
+ ASSETS_RUNTIME_ROOT = os.environ.get("SPORALIZE_ASSETS_DIR", os.path.join(RUNTIME_ROOT, "assets"))
37
+ WEIGHTS_RUNTIME_ROOT = os.environ.get("SPORALIZE_WEIGHTS_DIR", os.path.join(RUNTIME_ROOT, "weights"))
38
+ DEFAULT_LOCAL_STORAGE_ROOT = os.path.join(CURRENT_DIR, "Storage")
39
+ if not os.path.isdir("/data") and not os.access(CURRENT_DIR, os.W_OK):
40
+ DEFAULT_LOCAL_STORAGE_ROOT = os.path.join(RUNTIME_ROOT, "storage")
41
+ STORAGE_ROOT = os.environ.get(
42
+ "SPORALIZE_STORAGE_DIR",
43
+ os.path.join("/data", "sporalize_storage") if os.path.isdir("/data") else DEFAULT_LOCAL_STORAGE_ROOT,
44
+ )
45
+ STORAGE_DATASET_REPO_ID = os.environ.get("SPORALIZE_STORAGE_REPO_ID", "Shoraky/SporalizeLabs-runtime-private").strip()
46
+ STORAGE_DATASET_REPO_TYPE = os.environ.get("SPORALIZE_STORAGE_REPO_TYPE", "dataset").strip()
47
+ STORAGE_DATASET_PATH = os.environ.get("SPORALIZE_STORAGE_DATASET_PATH", "Storage").strip("/").strip()
48
+ STORAGE_SYNC_INTERVAL_SECONDS = float(os.environ.get("SPORALIZE_STORAGE_SYNC_INTERVAL_SECONDS", "20"))
49
+ CONFIGURED_ASSETS_REPO_ID = os.environ.get("SPORALIZE_ASSETS_REPO_ID", "").strip()
50
+ ASSETS_REPO_ID = CONFIGURED_ASSETS_REPO_ID or STORAGE_DATASET_REPO_ID
51
+ ASSETS_REPO_TYPE = os.environ.get("SPORALIZE_ASSETS_REPO_TYPE", STORAGE_DATASET_REPO_TYPE or "dataset").strip() or "dataset"
52
+ ASSETS_REVISION = os.environ.get("SPORALIZE_ASSETS_REVISION")
53
+ ASSETS_REVISION = ASSETS_REVISION.strip() if ASSETS_REVISION else None
54
+ USE_LOCAL_ASSETS = os.environ.get("SPORALIZE_USE_LOCAL_ASSETS", "").strip().lower() in {"1", "true", "yes", "on"}
55
+
56
+ _storage_sync_lock = threading.Lock()
57
+ _storage_last_sync_ts = 0.0
58
+
59
+
60
+ DEFAULT_WEIGHT_SPECS = {
61
+ "POSE_PATH": {
62
+ "filename": "vitpose-s-coco_25.onnx",
63
+ "repo_id": os.environ.get("SPORALIZE_POSE_MODEL_REPO_ID", "JunkyByte/easy_ViTPose"),
64
+ "repo_type": os.environ.get("SPORALIZE_POSE_MODEL_REPO_TYPE", "model"),
65
+ "repo_file": os.environ.get("SPORALIZE_POSE_MODEL_FILE", "onnx/coco_25/vitpose-25-s.onnx"),
66
+ "override_env": "SPORALIZE_POSE_MODEL_PATH",
67
+ "local_fallback": os.path.join(CURRENT_DIR, "Weights", "vitpose-s-coco_25.onnx"),
68
+ },
69
+ "YOLO_PATH": {
70
+ "filename": "yolov8m.pt",
71
+ "repo_id": os.environ.get("SPORALIZE_YOLO_MODEL_REPO_ID", "Ultralytics/YOLOv8"),
72
+ "repo_type": os.environ.get("SPORALIZE_YOLO_MODEL_REPO_TYPE", "model"),
73
+ "repo_file": os.environ.get("SPORALIZE_YOLO_MODEL_FILE", "yolov8m.pt"),
74
+ "override_env": "SPORALIZE_YOLO_MODEL_PATH",
75
+ "local_fallback": os.path.join(CURRENT_DIR, "Weights", "yolov8m.pt"),
76
+ },
77
+ }
78
+
79
+ runtime_state = {
80
+ "ready": False,
81
+ "pipeline_root": None,
82
+ "pipeline_source": None,
83
+ "assets_repo_id": None,
84
+ "assets_repo_type": None,
85
+ "assets_revision": None,
86
+ "run_pipeline": None,
87
+ "weights": {},
88
+ }
89
+
90
+
91
+ def get_hf_token():
92
+ return os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
93
+
94
+
95
+ def hf_storage_enabled():
96
+ return bool(STORAGE_DATASET_REPO_ID and STORAGE_DATASET_PATH)
97
+
98
+
99
+ def hf_storage_path(*parts: str) -> str:
100
+ normalized = [STORAGE_DATASET_PATH]
101
+ normalized.extend(part.strip("/").replace("\\", "/") for part in parts if part is not None and str(part).strip("/"))
102
+ return "/".join(segment for segment in normalized if segment)
103
+
104
+
105
+ def sync_storage_from_hf(force: bool = False):
106
+ global _storage_last_sync_ts
107
+ if not hf_storage_enabled():
108
+ return
109
+ now = time.time()
110
+ if not force and (now - _storage_last_sync_ts) < STORAGE_SYNC_INTERVAL_SECONDS:
111
+ return
112
+
113
+ with _storage_sync_lock:
114
+ now = time.time()
115
+ if not force and (now - _storage_last_sync_ts) < STORAGE_SYNC_INTERVAL_SECONDS:
116
+ return
117
+
118
+ sync_cache_root = os.path.join(RUNTIME_ROOT, "storage-sync-cache")
119
+ os.makedirs(sync_cache_root, exist_ok=True)
120
+ local_repo_dir = os.path.join(sync_cache_root, safe_name(STORAGE_DATASET_REPO_ID))
121
+
122
+ snapshot_download(
123
+ repo_id=STORAGE_DATASET_REPO_ID,
124
+ repo_type=STORAGE_DATASET_REPO_TYPE,
125
+ token=get_hf_token(),
126
+ local_dir=local_repo_dir,
127
+ allow_patterns=[f"{STORAGE_DATASET_PATH}/**"],
128
+ )
129
+
130
+ source_storage = os.path.join(local_repo_dir, STORAGE_DATASET_PATH)
131
+ if os.path.isdir(source_storage):
132
+ if os.path.isdir(STORAGE_ROOT):
133
+ shutil.rmtree(STORAGE_ROOT, ignore_errors=True)
134
+ shutil.copytree(source_storage, STORAGE_ROOT, dirs_exist_ok=True)
135
+ _storage_last_sync_ts = time.time()
136
+
137
+
138
+ def push_session_to_hf(player_id: str, session_id: str, session_dir: str):
139
+ if not hf_storage_enabled():
140
+ return
141
+ api = HfApi(token=get_hf_token())
142
+ api.upload_folder(
143
+ repo_id=STORAGE_DATASET_REPO_ID,
144
+ repo_type=STORAGE_DATASET_REPO_TYPE,
145
+ folder_path=session_dir,
146
+ path_in_repo=hf_storage_path(safe_name(player_id), safe_name(session_id)),
147
+ commit_message=f"Add session {safe_name(session_id)} for player {safe_name(player_id)}",
148
+ )
149
+
150
+
151
+ def delete_session_from_hf(player_id: str, session_id: str):
152
+ if not hf_storage_enabled():
153
+ return
154
+ api = HfApi(token=get_hf_token())
155
+ api.delete_folder(
156
+ repo_id=STORAGE_DATASET_REPO_ID,
157
+ repo_type=STORAGE_DATASET_REPO_TYPE,
158
+ path_in_repo=hf_storage_path(safe_name(player_id), safe_name(session_id)),
159
+ commit_message=f"Delete session {safe_name(session_id)} for player {safe_name(player_id)}",
160
+ )
161
+
162
+
163
+ def delete_player_from_hf(player_id: str):
164
+ if not hf_storage_enabled():
165
+ return
166
+ api = HfApi(token=get_hf_token())
167
+ api.delete_folder(
168
+ repo_id=STORAGE_DATASET_REPO_ID,
169
+ repo_type=STORAGE_DATASET_REPO_TYPE,
170
+ path_in_repo=hf_storage_path(safe_name(player_id)),
171
+ commit_message=f"Delete player {safe_name(player_id)} storage",
172
+ )
173
+
174
+
175
+ def path_has_session_data(directory: str):
176
+ if not os.path.isdir(directory):
177
+ return False
178
+ for _root, _dirs, files in os.walk(directory):
179
+ if "session.json" in files:
180
+ return True
181
+ return False
182
+
183
+
184
+ def seed_storage_if_needed(seed_dir: str, target_dir: str):
185
+ if not os.path.isdir(seed_dir):
186
+ return
187
+ os.makedirs(target_dir, exist_ok=True)
188
+ if path_has_session_data(target_dir):
189
+ return
190
+ shutil.copytree(seed_dir, target_dir, dirs_exist_ok=True)
191
+
192
+
193
+ def local_pipeline_bundle_available():
194
+ return (
195
+ os.path.isfile(os.path.join(CURRENT_DIR, "pipeline.py"))
196
+ and os.path.isdir(os.path.join(CURRENT_DIR, "ViTPose"))
197
+ )
198
+
199
+
200
+ def use_local_pipeline_bundle():
201
+ seed_storage_if_needed(os.path.join(CURRENT_DIR, "Storage"), STORAGE_ROOT)
202
+ runtime_state.update({
203
+ "pipeline_source": "local_bundle",
204
+ "assets_repo_id": None,
205
+ "assets_repo_type": None,
206
+ "assets_revision": None,
207
+ })
208
+ return CURRENT_DIR
209
+
210
+
211
+ def use_private_pipeline_repo():
212
+ if not ASSETS_REPO_ID:
213
+ raise RuntimeError(
214
+ "A private runtime pipeline is required. Set SPORALIZE_ASSETS_REPO_ID "
215
+ "or run from a directory that contains both pipeline.py and ViTPose."
216
+ )
217
+
218
+ assets_dir = os.path.join(ASSETS_RUNTIME_ROOT, safe_name(ASSETS_REPO_ID))
219
+ snapshot_download(
220
+ repo_id=ASSETS_REPO_ID,
221
+ repo_type=ASSETS_REPO_TYPE,
222
+ revision=ASSETS_REVISION,
223
+ token=get_hf_token(),
224
+ local_dir=assets_dir,
225
+ allow_patterns=["pipeline.py", "ViTPose/**", "Storage/**", "Weights/**"],
226
+ )
227
+
228
+ if not os.path.isfile(os.path.join(assets_dir, "pipeline.py")):
229
+ raise RuntimeError(f"pipeline.py was not found in private assets repo {ASSETS_REPO_ID}")
230
+ if not os.path.isdir(os.path.join(assets_dir, "ViTPose")):
231
+ raise RuntimeError(f"ViTPose was not found in private assets repo {ASSETS_REPO_ID}")
232
+
233
+ seed_storage_if_needed(os.path.join(assets_dir, "Storage"), STORAGE_ROOT)
234
+ runtime_state.update({
235
+ "pipeline_source": "private_repo",
236
+ "assets_repo_id": ASSETS_REPO_ID,
237
+ "assets_repo_type": ASSETS_REPO_TYPE,
238
+ "assets_revision": ASSETS_REVISION,
239
+ })
240
+ return assets_dir
241
+
242
+
243
+ def resolve_pipeline_root():
244
+ if local_pipeline_bundle_available() and (USE_LOCAL_ASSETS or not CONFIGURED_ASSETS_REPO_ID):
245
+ return use_local_pipeline_bundle()
246
+
247
+ if ASSETS_REPO_ID:
248
+ return use_private_pipeline_repo()
249
+
250
+ if local_pipeline_bundle_available():
251
+ return use_local_pipeline_bundle()
252
+
253
+ raise RuntimeError(
254
+ "No runtime pipeline is available. Provide a private Hugging Face repo via "
255
+ "SPORALIZE_ASSETS_REPO_ID or bundle pipeline.py with ViTPose for local development."
256
+ )
257
+
258
+
259
+ def load_pipeline_callable(pipeline_root: str):
260
+ pipeline_path = os.path.join(pipeline_root, "pipeline.py")
261
+ if not os.path.isfile(pipeline_path):
262
+ raise RuntimeError(f"pipeline.py was not found at {pipeline_path}")
263
+
264
+ if pipeline_root not in sys.path:
265
+ sys.path.insert(0, pipeline_root)
266
+
267
+ module_name = "sporalize_runtime_pipeline"
268
+ if module_name in sys.modules:
269
+ del sys.modules[module_name]
270
+
271
+ spec = importlib.util.spec_from_file_location(module_name, pipeline_path)
272
+ if spec is None or spec.loader is None:
273
+ raise RuntimeError(f"Unable to create import spec for {pipeline_path}")
274
+ module = importlib.util.module_from_spec(spec)
275
+ sys.modules[module_name] = module
276
+ spec.loader.exec_module(module)
277
+
278
+ run_pipeline = getattr(module, "run_pipeline", None)
279
+ if run_pipeline is None:
280
+ raise RuntimeError("run_pipeline was not found in the resolved pipeline module")
281
+ return run_pipeline
282
+
283
+
284
+ def ensure_weight_file(spec: dict, pipeline_root: str):
285
+ override_path = os.environ.get(spec["override_env"])
286
+ if override_path and os.path.isfile(override_path):
287
+ return override_path
288
+
289
+ pipeline_weight = os.path.join(pipeline_root, "Weights", spec["filename"])
290
+ if os.path.isfile(pipeline_weight):
291
+ return pipeline_weight
292
+
293
+ local_fallback = spec.get("local_fallback")
294
+ if local_fallback and os.path.isfile(local_fallback):
295
+ return local_fallback
296
+
297
+ os.makedirs(WEIGHTS_RUNTIME_ROOT, exist_ok=True)
298
+ cached_path = os.path.join(WEIGHTS_RUNTIME_ROOT, spec["filename"])
299
+ if os.path.isfile(cached_path):
300
+ return cached_path
301
+
302
+ return hf_hub_download(
303
+ repo_id=spec["repo_id"],
304
+ repo_type=spec.get("repo_type", "model"),
305
+ filename=spec["repo_file"],
306
+ token=get_hf_token(),
307
+ local_dir=WEIGHTS_RUNTIME_ROOT,
308
+ )
309
+
310
+
311
+ def ensure_runtime_ready(force: bool = False):
312
+ if runtime_state["ready"] and not force:
313
+ return runtime_state
314
+
315
+ os.makedirs(RUNTIME_ROOT, exist_ok=True)
316
+ os.makedirs(STORAGE_ROOT, exist_ok=True)
317
+
318
+ pipeline_root = resolve_pipeline_root()
319
+ run_pipeline = load_pipeline_callable(pipeline_root)
320
+ weight_paths = {name: ensure_weight_file(spec, pipeline_root) for name, spec in DEFAULT_WEIGHT_SPECS.items()}
321
+
322
+ runtime_state.update({
323
+ "ready": True,
324
+ "pipeline_root": pipeline_root,
325
+ "run_pipeline": run_pipeline,
326
+ "weights": weight_paths,
327
+ })
328
+ return runtime_state
329
+
330
+
331
+ os.makedirs(STORAGE_ROOT, exist_ok=True)
332
+ app.mount("/storage", StaticFiles(directory=STORAGE_ROOT), name="storage")
333
+
334
+ progress_store = {}
335
+
336
+ cancel_store = {}
337
+ TERMINAL_PROGRESS_STATUSES = {"completed", "failed", "cancelled"}
338
+ CANCEL_PROGRESS_STATUSES = {"cancelling", "cancelled"}
339
+ ACTIVE_PROGRESS_STATUSES = {"queued", "running"}
340
+ active_job_futures = {}
341
+ active_job_lock = threading.Lock()
342
+
343
+ def env_int(name: str, default: int) -> int:
344
+ try:
345
+ return int(os.environ.get(name, str(default)))
346
+ except Exception:
347
+ return default
348
+
349
+
350
+ ANALYSIS_WORKERS = max(1, env_int("SPORALIZE_ANALYSIS_WORKERS", 1))
351
+ analysis_executor = concurrent.futures.ThreadPoolExecutor(
352
+ max_workers=ANALYSIS_WORKERS,
353
+ thread_name_prefix="sporalize-analysis",
354
+ )
355
+ JOB_PROGRESS_ROOT = os.environ.get("SPORALIZE_JOB_PROGRESS_DIR", os.path.join(RUNTIME_ROOT, "jobs"))
356
+ _progress_file_last_write = {}
357
+
358
+
359
+ def progress_file_path(client_id: str) -> str:
360
+ return os.path.join(JOB_PROGRESS_ROOT, f"{safe_name(client_id)}.json")
361
+
362
+
363
+ def write_progress_file(client_id: str, payload: dict):
364
+ try:
365
+ os.makedirs(JOB_PROGRESS_ROOT, exist_ok=True)
366
+ with open(progress_file_path(client_id), "w", encoding="utf-8") as f:
367
+ json.dump(payload, f, indent=2)
368
+ except Exception:
369
+ pass
370
+
371
+
372
+ def read_progress_file(client_id: str):
373
+ try:
374
+ path = progress_file_path(client_id)
375
+ if not os.path.isfile(path):
376
+ return None
377
+ with open(path, "r", encoding="utf-8") as f:
378
+ return json.load(f)
379
+ except Exception:
380
+ return None
381
+
382
+
383
+ def current_progress_payload(client_id: str):
384
+ memory_payload = progress_store.get(client_id) or {}
385
+ file_payload = read_progress_file(client_id) or {}
386
+ if file_payload.get("updatedAt", 0) > memory_payload.get("updatedAt", 0):
387
+ return file_payload
388
+ return memory_payload or file_payload
389
+
390
+
391
+ def cleanup_active_job_futures():
392
+ with active_job_lock:
393
+ for stored_client_id, future in list(active_job_futures.items()):
394
+ if future.done() or future.cancelled():
395
+ active_job_futures.pop(stored_client_id, None)
396
+
397
+
398
+ def register_active_job(client_id: str, future):
399
+ cleanup_active_job_futures()
400
+ with active_job_lock:
401
+ active_job_futures[client_id] = future
402
+
403
+
404
+ def unregister_active_job(client_id: str):
405
+ with active_job_lock:
406
+ active_job_futures.pop(client_id, None)
407
+
408
+
409
+ def get_active_job_future(client_id: str):
410
+ cleanup_active_job_futures()
411
+ with active_job_lock:
412
+ return active_job_futures.get(client_id)
413
+
414
+
415
+ def active_job_client_ids():
416
+ cleanup_active_job_futures()
417
+ with active_job_lock:
418
+ return list(active_job_futures.keys())
419
+
420
+
421
+ def build_active_job_payload(client_id: str, payload: dict):
422
+ updated_at = int(payload.get("updatedAt") or time.time() * 1000)
423
+ return {
424
+ "clientId": payload.get("clientId") or client_id,
425
+ "sessionId": payload.get("sessionId"),
426
+ "playerId": payload.get("playerId"),
427
+ "createdAt": int(payload.get("createdAt") or updated_at),
428
+ "updatedAt": updated_at,
429
+ "progress": payload.get("progress", 0.0),
430
+ "phase": payload.get("phase") or "Queued for Processing",
431
+ "status": payload.get("status") or "queued",
432
+ "resultUrl": payload.get("resultUrl"),
433
+ "error": payload.get("error"),
434
+ }
435
+
436
+
437
+ def is_cancel_requested(client_id: str):
438
+ if cancel_store.get(client_id):
439
+ return True
440
+ stored_progress = read_progress_file(client_id)
441
+ return bool(stored_progress and stored_progress.get("status") in CANCEL_PROGRESS_STATUSES)
442
+
443
+
444
+ def clear_cancel_request(client_id: str):
445
+ cancel_store.pop(client_id, None)
446
+
447
+
448
+ def should_write_progress_file(client_id: str, payload: dict):
449
+ status = payload.get("status")
450
+ if status in {"uploading", "queued", "cancelling", "completed", "failed", "cancelled"}:
451
+ _progress_file_last_write[client_id] = time.time()
452
+ return True
453
+
454
+ now = time.time()
455
+ if (now - _progress_file_last_write.get(client_id, 0.0)) >= 1.0:
456
+ _progress_file_last_write[client_id] = now
457
+ return True
458
+ return False
459
+
460
+
461
+ def set_progress(
462
+ client_id: str,
463
+ progress: float,
464
+ phase: str,
465
+ step: int = 0,
466
+ total: int = 0,
467
+ status: str = "running",
468
+ **extra,
469
+ ):
470
+ memory_payload = progress_store.get(client_id, {})
471
+ file_payload = read_progress_file(client_id) or {}
472
+ if file_payload.get("updatedAt", 0) > memory_payload.get("updatedAt", 0):
473
+ payload = dict(file_payload)
474
+ else:
475
+ payload = dict(memory_payload)
476
+
477
+ now_ms = int(time.time() * 1000)
478
+ if not payload.get("createdAt"):
479
+ payload["createdAt"] = now_ms
480
+
481
+ if payload.get("status") == "cancelling" and status not in TERMINAL_PROGRESS_STATUSES:
482
+ status = "cancelling"
483
+ phase = payload.get("phase") or "Cancellation Requested"
484
+
485
+ payload.update({
486
+ "clientId": client_id,
487
+ "progress": max(0.0, min(100.0, float(progress))),
488
+ "step": max(0, int(step or 0)),
489
+ "total": max(0, int(total or 0)),
490
+ "phase": phase,
491
+ "status": status,
492
+ "updatedAt": now_ms,
493
+ })
494
+ payload.update({key: value for key, value in extra.items() if value is not None})
495
+ progress_store[client_id] = payload
496
+ if should_write_progress_file(client_id, payload):
497
+ write_progress_file(client_id, payload)
498
+
499
+
500
+ def safe_name(value: str) -> str:
501
+ allowed = []
502
+ for ch in str(value):
503
+ if ch.isalnum() or ch in ("-", "_", "."):
504
+ allowed.append(ch)
505
+ else:
506
+ allowed.append("_")
507
+ cleaned = "".join(allowed).strip("._")
508
+ return cleaned or "item"
509
+
510
+
511
+ def session_storage_paths(player_id: str, session_id: str):
512
+ player_dir = os.path.join(STORAGE_ROOT, safe_name(player_id))
513
+ session_dir = os.path.join(player_dir, safe_name(session_id))
514
+ videos_dir = os.path.join(session_dir, "videos")
515
+ return player_dir, session_dir, videos_dir
516
+
517
+
518
+ def list_session_files():
519
+ session_files = []
520
+ for root, _, files in os.walk(STORAGE_ROOT):
521
+ if "session.json" in files:
522
+ session_files.append(os.path.join(root, "session.json"))
523
+ return sorted(session_files, key=os.path.getmtime, reverse=True)
524
+
525
+
526
+ def request_public_base_url(request: Request) -> str:
527
+ configured_public_base = os.environ.get("SPORALIZE_PUBLIC_BASE_URL", "").strip()
528
+ if configured_public_base:
529
+ return configured_public_base.rstrip("/")
530
+
531
+ forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower()
532
+ forwarded_host = request.headers.get("x-forwarded-host", "").split(",")[0].strip()
533
+ if forwarded_proto in ("http", "https") and forwarded_host:
534
+ return f"{forwarded_proto}://{forwarded_host}".rstrip("/")
535
+
536
+ base_url = str(request.base_url).rstrip("/")
537
+ if forwarded_proto in ("http", "https"):
538
+ parsed = urlsplit(base_url)
539
+ base_url = urlunsplit((forwarded_proto, parsed.netloc, parsed.path, "", "")).rstrip("/")
540
+
541
+ return base_url
542
+
543
+
544
+ def build_storage_url(request_or_base_url, *parts: str) -> str:
545
+ relative = "/".join(safe_name(part) if idx < len(parts) - 1 else part.replace("\\", "/") for idx, part in enumerate(parts))
546
+ if isinstance(request_or_base_url, str):
547
+ base_url = request_or_base_url.rstrip("/")
548
+ else:
549
+ base_url = request_public_base_url(request_or_base_url)
550
+ return base_url + "/storage/" + relative.lstrip("/")
551
+
552
+
553
+ def parse_video_timecode(value, fps=30.0):
554
+ if value is None:
555
+ return 0.0
556
+ if isinstance(value, (int, float, np.integer, np.floating)):
557
+ return max(0.0, float(value))
558
+
559
+ parts = str(value).split(":")
560
+ if len(parts) == 4:
561
+ h, m, s, f = [int(float(part or 0)) for part in parts]
562
+ return max(0.0, (h * 3600) + (m * 60) + s + (f / max(1.0, float(fps))))
563
+
564
+ try:
565
+ return max(0.0, float(value))
566
+ except Exception:
567
+ return 0.0
568
+
569
+
570
+ def detect_camera_id(file_name: str):
571
+ match = re.search(r"_cam_(\d+)_", file_name)
572
+ if match:
573
+ return int(match.group(1))
574
+ return None
575
+
576
+
577
+ def split_camera_video_variants(videos_dir: str):
578
+ grouped: dict[int, dict[str, str]] = {}
579
+ for file_name in sorted(os.listdir(videos_dir)):
580
+ camera_id = detect_camera_id(file_name)
581
+ if camera_id is None:
582
+ continue
583
+ full_path = os.path.join(videos_dir, file_name)
584
+ if not os.path.isfile(full_path):
585
+ continue
586
+ bucket = grouped.setdefault(camera_id, {})
587
+ if file_name.lower().endswith(".web.mp4"):
588
+ bucket["web"] = full_path
589
+ else:
590
+ bucket["base"] = full_path
591
+
592
+ primary_map = {}
593
+ fallback_map = {}
594
+ for camera_id in sorted(grouped.keys()):
595
+ base_path = grouped[camera_id].get("base")
596
+ web_path = grouped[camera_id].get("web")
597
+ if base_path:
598
+ primary_map[camera_id] = base_path
599
+ if web_path:
600
+ fallback_map[camera_id] = web_path
601
+ elif web_path:
602
+ primary_map[camera_id] = web_path
603
+
604
+ return primary_map, fallback_map
605
+
606
+
607
+ def normalize_video_for_web(input_path: str) -> str:
608
+ """
609
+ Re-encode uploaded video to a browser-friendly MP4 stream.
610
+ Falls back to the original file if normalization fails.
611
+ """
612
+ output_path = os.path.splitext(input_path)[0] + ".web.mp4"
613
+
614
+ # First try ffmpeg -> H.264 + yuv420p for maximum browser compatibility.
615
+ ffmpeg_cmd = [
616
+ "ffmpeg",
617
+ "-y",
618
+ "-i",
619
+ input_path,
620
+ "-an",
621
+ "-c:v",
622
+ "libx264",
623
+ "-preset",
624
+ "veryfast",
625
+ "-pix_fmt",
626
+ "yuv420p",
627
+ "-movflags",
628
+ "+faststart",
629
+ output_path,
630
+ ]
631
+ try:
632
+ ffmpeg_proc = subprocess.run(ffmpeg_cmd, capture_output=True, text=True)
633
+ if ffmpeg_proc.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0:
634
+ return output_path
635
+ except Exception:
636
+ pass
637
+
638
+ # Fallback: OpenCV transcode when ffmpeg is unavailable.
639
+ cap = cv2.VideoCapture(input_path)
640
+ if not cap.isOpened():
641
+ return input_path
642
+
643
+ fps = cap.get(cv2.CAP_PROP_FPS)
644
+ if not fps or not np.isfinite(fps) or fps <= 0:
645
+ fps = 30.0
646
+
647
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
648
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
649
+ if width <= 0 or height <= 0:
650
+ cap.release()
651
+ return input_path
652
+
653
+ writer = cv2.VideoWriter(
654
+ output_path,
655
+ cv2.VideoWriter_fourcc(*"mp4v"),
656
+ float(fps),
657
+ (width, height),
658
+ )
659
+ if not writer.isOpened():
660
+ cap.release()
661
+ return input_path
662
+
663
+ frame_count = 0
664
+ success = True
665
+ while True:
666
+ ok, frame = cap.read()
667
+ if not ok:
668
+ break
669
+ writer.write(frame)
670
+ frame_count += 1
671
+
672
+ cap.release()
673
+ writer.release()
674
+
675
+ if frame_count <= 0:
676
+ success = False
677
+ elif not os.path.exists(output_path) or os.path.getsize(output_path) <= 0:
678
+ success = False
679
+
680
+ if not success:
681
+ if os.path.exists(output_path):
682
+ os.remove(output_path)
683
+ return input_path
684
+
685
+ return output_path
686
+
687
+
688
+ def normalize_camera_videos_for_web(camera_map):
689
+ fallback_map = {}
690
+ items = [(camera_id, video_path) for camera_id, video_path in sorted(camera_map.items()) if video_path]
691
+ if not items:
692
+ return fallback_map
693
+
694
+ def normalize_item(item):
695
+ camera_id, video_path = item
696
+ normalized_path = normalize_video_for_web(video_path)
697
+ if normalized_path != video_path and os.path.exists(normalized_path):
698
+ return camera_id, normalized_path
699
+ return None
700
+
701
+ max_workers = min(4, len(items))
702
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
703
+ for result in executor.map(normalize_item, items):
704
+ if result is not None:
705
+ camera_id, normalized_path = result
706
+ fallback_map[int(camera_id)] = normalized_path
707
+
708
+ return fallback_map
709
+
710
+
711
+ def build_camera_video_entries(request_or_base_url, player_id: str, session_id: str, camera_map):
712
+ return [
713
+ {
714
+ "cameraId": int(camera_id),
715
+ "url": build_storage_url(
716
+ request_or_base_url,
717
+ safe_name(player_id),
718
+ safe_name(session_id),
719
+ "videos",
720
+ os.path.basename(video_path),
721
+ ),
722
+ }
723
+ for camera_id, video_path in sorted(camera_map.items())
724
+ if video_path and os.path.exists(video_path)
725
+ ]
726
+
727
+
728
+ def build_action_clip_entries(request_or_base_url, player_id: str, session_id: str, clip_map):
729
+ return [
730
+ {
731
+ "cameraId": int(camera_id),
732
+ "url": build_storage_url(
733
+ request_or_base_url,
734
+ safe_name(player_id),
735
+ safe_name(session_id),
736
+ "clips",
737
+ os.path.basename(clip_path),
738
+ ),
739
+ }
740
+ for camera_id, clip_path in sorted(clip_map.items())
741
+ if clip_path and os.path.exists(clip_path)
742
+ ]
743
+
744
+
745
+ def normalize_session_payload(session: dict, request: Request):
746
+ session_id = session.get("id")
747
+ player_id = session.get("playerId")
748
+ if not session_id or not player_id:
749
+ return session
750
+
751
+ session_dir = find_session_path(session_id)
752
+ if not session_dir:
753
+ return session
754
+
755
+ videos_dir = os.path.join(session_dir, "videos")
756
+ if not os.path.isdir(videos_dir):
757
+ return session
758
+
759
+ camera_map, fallback_camera_map = split_camera_video_variants(videos_dir)
760
+
761
+ if not camera_map:
762
+ return session
763
+
764
+ normalized_actions = []
765
+ for action in session.get("actions", []):
766
+ normalized_action = dict(action)
767
+ fps = float(normalized_action.get("fps") or 30.0)
768
+ fps = max(1.0, fps)
769
+
770
+ absolute_start_frame = normalized_action.get("sourceStartFrame")
771
+ absolute_end_frame = normalized_action.get("sourceEndFrame")
772
+ if absolute_start_frame is None or absolute_end_frame is None:
773
+ absolute_start_frame = normalized_action.get("startFrame")
774
+ absolute_end_frame = normalized_action.get("endFrame")
775
+
776
+ try:
777
+ absolute_start_frame = int(absolute_start_frame) if absolute_start_frame is not None else None
778
+ absolute_end_frame = int(absolute_end_frame) if absolute_end_frame is not None else None
779
+ except Exception:
780
+ absolute_start_frame = None
781
+ absolute_end_frame = None
782
+
783
+ if absolute_start_frame is not None and absolute_end_frame is not None and absolute_end_frame >= absolute_start_frame:
784
+ start_seconds = max(0.0, absolute_start_frame / fps)
785
+ end_seconds = max(start_seconds, (absolute_end_frame + 1) / fps)
786
+ normalized_action["startFrame"] = absolute_start_frame
787
+ normalized_action["endFrame"] = absolute_end_frame
788
+ else:
789
+ total_frames = int(normalized_action.get("totalFrames") or 0)
790
+ start_seconds = parse_video_timecode(normalized_action.get("start"), fps=fps)
791
+ if total_frames > 0:
792
+ end_seconds = start_seconds + (total_frames / fps)
793
+ else:
794
+ end_seconds = max(start_seconds, parse_video_timecode(normalized_action.get("end"), fps=fps))
795
+
796
+ normalized_action["cameraClips"] = normalized_action.get("cameraClips") or build_camera_video_entries(
797
+ request, player_id, session_id, camera_map
798
+ )
799
+ if fallback_camera_map:
800
+ normalized_action["sourceCameraClips"] = normalized_action.get("sourceCameraClips") or build_camera_video_entries(
801
+ request, player_id, session_id, fallback_camera_map
802
+ )
803
+ normalized_action["startSeconds"] = round(start_seconds, 6)
804
+ normalized_action["endSeconds"] = round(end_seconds, 6)
805
+ normalized_actions.append(normalized_action)
806
+
807
+ normalized_session = dict(session)
808
+ normalized_session["actions"] = normalized_actions
809
+ return normalized_session
810
+
811
+
812
+ def summarize_action_for_archive(action: dict):
813
+ return {
814
+ "id": action.get("id"),
815
+ "label": action.get("label", "Unknown"),
816
+ "start": action.get("start", "00:00:00:00"),
817
+ "end": action.get("end", "00:00:00:00"),
818
+ "fps": action.get("fps"),
819
+ "startFrame": action.get("startFrame"),
820
+ "endFrame": action.get("endFrame"),
821
+ "startSeconds": action.get("startSeconds", 0),
822
+ "endSeconds": action.get("endSeconds", 0),
823
+ "activeFoot": action.get("activeFoot"),
824
+ "totalFrames": action.get("totalFrames", 0),
825
+ "preFrames": action.get("preFrames", 0),
826
+ "inFrame": action.get("inFrame", 0),
827
+ "postFrames": action.get("postFrames", 0),
828
+ "sourceStartFrame": action.get("sourceStartFrame"),
829
+ "sourceEndFrame": action.get("sourceEndFrame"),
830
+ "sourceStartSeconds": action.get("sourceStartSeconds"),
831
+ "sourceEndSeconds": action.get("sourceEndSeconds"),
832
+ "cameraClips": [],
833
+ "sourceCameraClips": [],
834
+ "preMetrics": [],
835
+ "preActionMetrics": [],
836
+ "inActionMetrics": [],
837
+ "postMetrics": [],
838
+ "postActionMetrics": [],
839
+ "fullIntervalMetrics": [],
840
+ "skeleton": [],
841
+ }
842
+
843
+
844
+ def build_session_summary_payload(session: dict):
845
+ return {
846
+ "id": session.get("id"),
847
+ "playerId": session.get("playerId"),
848
+ "playerName": session.get("playerName"),
849
+ "summaryOnly": True,
850
+ "createdAt": session.get("createdAt"),
851
+ "targetSize": session.get("targetSize"),
852
+ "cameraCount": session.get("cameraCount", 0),
853
+ "actions": [
854
+ summarize_action_for_archive(action)
855
+ for action in session.get("actions", [])
856
+ if isinstance(action, dict)
857
+ ],
858
+ "failedActions": session.get("failedActions", []),
859
+ }
860
+
861
+
862
+ def session_summary_path(session_file: str):
863
+ return os.path.join(os.path.dirname(session_file), "session-summary.json")
864
+
865
+
866
+ def write_session_summary(session_file: str, session: dict):
867
+ summary = build_session_summary_payload(session)
868
+ with open(session_summary_path(session_file), "w", encoding="utf-8") as f:
869
+ json.dump(summary, f, indent=2)
870
+ return summary
871
+
872
+
873
+ def load_session_summary(session_file: str):
874
+ summary_file = session_summary_path(session_file)
875
+ if os.path.isfile(summary_file):
876
+ with open(summary_file, "r", encoding="utf-8") as f:
877
+ return json.load(f)
878
+
879
+ with open(session_file, "r", encoding="utf-8") as f:
880
+ session = json.load(f)
881
+
882
+ summary = build_session_summary_payload(session)
883
+ try:
884
+ with open(summary_file, "w", encoding="utf-8") as f:
885
+ json.dump(summary, f, indent=2)
886
+ except Exception:
887
+ pass
888
+ return summary
889
+
890
+
891
+ def json_default(value):
892
+ if isinstance(value, np.generic):
893
+ return value.item()
894
+ if isinstance(value, np.ndarray):
895
+ return value.tolist()
896
+ raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
897
+
898
+
899
+ def make_json_safe(value):
900
+ if value is None or isinstance(value, (str, bool, int)):
901
+ return value
902
+ if isinstance(value, float):
903
+ return value if np.isfinite(value) else None
904
+ if isinstance(value, np.generic):
905
+ return make_json_safe(value.item())
906
+ if isinstance(value, np.ndarray):
907
+ return make_json_safe(value.tolist())
908
+ if isinstance(value, dict):
909
+ return {str(key): make_json_safe(item) for key, item in value.items()}
910
+ if isinstance(value, (list, tuple, set)):
911
+ return [make_json_safe(item) for item in value]
912
+ if hasattr(value, "tolist"):
913
+ try:
914
+ return make_json_safe(value.tolist())
915
+ except Exception:
916
+ pass
917
+ return str(value)
918
+
919
+
920
+ def export_action_clips(camera_map, clips_dir, action_index, start_frame, end_frame, fps):
921
+ os.makedirs(clips_dir, exist_ok=True)
922
+ frame_count = max(0, end_frame - start_frame + 1)
923
+ clip_paths = {}
924
+
925
+ for camera_id, video_path in sorted(camera_map.items()):
926
+ cap = cv2.VideoCapture(video_path)
927
+ if not cap.isOpened():
928
+ continue
929
+
930
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
931
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
932
+ if width <= 0 or height <= 0:
933
+ cap.release()
934
+ continue
935
+
936
+ clip_name = f"action_{action_index:02d}_cam_{camera_id}.mp4"
937
+ clip_path = os.path.join(clips_dir, clip_name)
938
+ writer = cv2.VideoWriter(
939
+ clip_path,
940
+ cv2.VideoWriter_fourcc(*"mp4v"),
941
+ max(1.0, float(fps)),
942
+ (width, height),
943
+ )
944
+
945
+ cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
946
+ written = 0
947
+ while written < frame_count:
948
+ ok, frame = cap.read()
949
+ if not ok:
950
+ break
951
+ writer.write(frame)
952
+ written += 1
953
+
954
+ writer.release()
955
+ cap.release()
956
+
957
+ if written > 0 and os.path.exists(clip_path):
958
+ clip_paths[camera_id] = clip_path
959
+ elif os.path.exists(clip_path):
960
+ os.remove(clip_path)
961
+
962
+ return clip_paths
963
+
964
+
965
+ def load_session_by_id(session_id: str):
966
+ target_name = safe_name(session_id)
967
+ for session_file in list_session_files():
968
+ session_dir = os.path.basename(os.path.dirname(session_file))
969
+ if session_dir != target_name:
970
+ continue
971
+ with open(session_file, "r", encoding="utf-8") as f:
972
+ return json.load(f)
973
+ return None
974
+
975
+
976
+ def find_session_path(session_id: str):
977
+ target_name = safe_name(session_id)
978
+ for session_file in list_session_files():
979
+ session_dir = os.path.dirname(session_file)
980
+ if os.path.basename(session_dir) == target_name:
981
+ return session_dir
982
+ return None
983
+
984
+
985
+ def player_storage_path(player_id: str):
986
+ return os.path.join(STORAGE_ROOT, safe_name(player_id))
987
+
988
+
989
+ def get_cors_origins():
990
+ configured = os.environ.get("CORS_ALLOW_ORIGINS", "*").strip()
991
+ if not configured or configured == "*":
992
+ return ["*"]
993
+ return [origin.strip() for origin in configured.split(",") if origin.strip()]
994
+
995
+
996
+ @app.on_event("startup")
997
+ def startup_event():
998
+ ensure_runtime_ready()
999
+ sync_storage_from_hf(force=True)
1000
+
1001
+
1002
+ @app.get("/healthz")
1003
+ def healthz():
1004
+ runtime = ensure_runtime_ready()
1005
+ return {
1006
+ "status": "ok",
1007
+ "storageRoot": STORAGE_ROOT,
1008
+ "pipelineRoot": runtime.get("pipeline_root"),
1009
+ "pipelineSource": runtime.get("pipeline_source"),
1010
+ "assetsRepoId": runtime.get("assets_repo_id"),
1011
+ "assetsRepoType": runtime.get("assets_repo_type"),
1012
+ "assetsRevision": runtime.get("assets_revision"),
1013
+ }
1014
+
1015
+ @app.post("/api/cancel/{client_id}")
1016
+ def cancel_processing(client_id: str):
1017
+ current = current_progress_payload(client_id)
1018
+ current_status = current.get("status")
1019
+ if current_status in TERMINAL_PROGRESS_STATUSES:
1020
+ return {
1021
+ "status": current_status,
1022
+ "clientId": client_id,
1023
+ "sessionId": current.get("sessionId"),
1024
+ }
1025
+
1026
+ cancel_store[client_id] = True
1027
+ future = get_active_job_future(client_id)
1028
+ queued_cancelled = bool(future and future.cancel())
1029
+ set_progress(
1030
+ client_id,
1031
+ current.get("progress", 0.0),
1032
+ "Processing Cancelled" if queued_cancelled else "Cancellation Requested",
1033
+ current.get("step", 0),
1034
+ current.get("total", 0),
1035
+ "cancelled" if queued_cancelled else "cancelling",
1036
+ )
1037
+ if queued_cancelled:
1038
+ session_id = current.get("sessionId")
1039
+ player_id = current.get("playerId")
1040
+ if player_id and session_id:
1041
+ _player_dir, session_dir, _videos_dir = session_storage_paths(player_id, session_id)
1042
+ if os.path.isdir(session_dir):
1043
+ shutil.rmtree(session_dir, ignore_errors=True)
1044
+ unregister_active_job(client_id)
1045
+ clear_cancel_request(client_id)
1046
+ return {
1047
+ "status": "cancelled" if queued_cancelled else "cancelling",
1048
+ "clientId": client_id,
1049
+ "sessionId": current.get("sessionId"),
1050
+ }
1051
+
1052
+ @app.get("/api/progress/{client_id}")
1053
+ def get_progress(client_id: str):
1054
+ stored_progress = current_progress_payload(client_id)
1055
+ if stored_progress:
1056
+ return stored_progress
1057
+ return {
1058
+ "progress": 0.0,
1059
+ "step": 0,
1060
+ "total": 0,
1061
+ "phase": "Initializing",
1062
+ "status": "idle",
1063
+ "updatedAt": int(time.time() * 1000),
1064
+ }
1065
+
1066
+
1067
+ @app.get("/api/active-analyses")
1068
+ def get_active_analyses():
1069
+ jobs = []
1070
+ for client_id in active_job_client_ids():
1071
+ payload = current_progress_payload(client_id)
1072
+ if not payload:
1073
+ continue
1074
+ status = payload.get("status")
1075
+ if status not in ACTIVE_PROGRESS_STATUSES:
1076
+ continue
1077
+ jobs.append(build_active_job_payload(client_id, payload))
1078
+
1079
+ jobs.sort(key=lambda item: item.get("updatedAt", 0), reverse=True)
1080
+ return {"jobs": jobs}
1081
+
1082
+
1083
+ @app.get("/api/sessions/{session_id}")
1084
+ def get_session(session_id: str, request: Request):
1085
+ session = load_session_by_id(session_id)
1086
+ if session is None:
1087
+ raise HTTPException(status_code=404, detail="Session not found")
1088
+ return normalize_session_payload(session, request)
1089
+
1090
+
1091
+ @app.get("/api/archive")
1092
+ def get_archive(request: Request, full: bool = False):
1093
+ sessions = []
1094
+ for session_file in list_session_files():
1095
+ try:
1096
+ if full:
1097
+ with open(session_file, "r", encoding="utf-8") as f:
1098
+ session = json.load(f)
1099
+ sessions.append(normalize_session_payload(session, request))
1100
+ else:
1101
+ sessions.append(load_session_summary(session_file))
1102
+ except Exception:
1103
+ continue
1104
+ return {"sessions": sessions}
1105
+
1106
+
1107
+ @app.delete("/api/sessions/{session_id}")
1108
+ def delete_session(session_id: str):
1109
+ session_dir = find_session_path(session_id)
1110
+ if session_dir is None:
1111
+ raise HTTPException(status_code=404, detail="Session not found")
1112
+
1113
+ player_dir = os.path.dirname(session_dir)
1114
+ player_id = os.path.basename(player_dir)
1115
+ shutil.rmtree(session_dir, ignore_errors=True)
1116
+ delete_session_from_hf(player_id, session_id)
1117
+
1118
+ if os.path.isdir(player_dir) and not os.listdir(player_dir):
1119
+ os.rmdir(player_dir)
1120
+
1121
+ return {"status": "deleted", "sessionId": session_id}
1122
+
1123
+
1124
+ @app.delete("/api/players/{player_id}")
1125
+ def delete_player(player_id: str):
1126
+ player_dir = player_storage_path(player_id)
1127
+ if not os.path.isdir(player_dir):
1128
+ raise HTTPException(status_code=404, detail="Player storage not found")
1129
+
1130
+ shutil.rmtree(player_dir, ignore_errors=True)
1131
+ delete_player_from_hf(player_id)
1132
+ return {"status": "deleted", "playerId": player_id}
1133
+
1134
+ cors_origins = get_cors_origins()
1135
+ app.add_middleware(
1136
+ CORSMiddleware,
1137
+ allow_origins=cors_origins,
1138
+ allow_credentials=(cors_origins != ["*"]),
1139
+ allow_methods=["*"],
1140
+ allow_headers=["*"],
1141
+ )
1142
+
1143
+
1144
+ def format_metric_series(name, unit, values_list):
1145
+ return {
1146
+ "name": name,
1147
+ "unit": unit,
1148
+ "values": [
1149
+ {"frame": i, "value": safe_float(v)}
1150
+ for i, v in enumerate(values_list)
1151
+ ]
1152
+ }
1153
+
1154
+
1155
+ def safe_float(value):
1156
+ try:
1157
+ number = float(value)
1158
+ return None if np.isnan(number) else number
1159
+ except Exception:
1160
+ return None
1161
+
1162
+
1163
+ def frame_number(value):
1164
+ try:
1165
+ if value is None:
1166
+ return None
1167
+ return int(float(value))
1168
+ except Exception:
1169
+ return None
1170
+
1171
+
1172
+ def relative_frame(value, start_frame=0, fallback=0):
1173
+ frame = frame_number(value)
1174
+ if frame is None:
1175
+ return fallback
1176
+ start = frame_number(start_frame) or 0
1177
+ return frame - start if frame >= start else frame
1178
+
1179
+
1180
+ def safe_metric_value(value):
1181
+ if value is None:
1182
+ return None
1183
+ number = safe_float(value)
1184
+ if number is not None:
1185
+ return round(number, 3)
1186
+ if isinstance(value, (str, bool)):
1187
+ return value
1188
+ if isinstance(value, (dict, list, tuple)):
1189
+ return None
1190
+ return str(value)
1191
+
1192
+
1193
+ def point_to_float_list(point):
1194
+ if point is None:
1195
+ return None
1196
+ if hasattr(point, "tolist"):
1197
+ point = point.tolist()
1198
+ try:
1199
+ values = list(point)
1200
+ except Exception:
1201
+ return None
1202
+ if len(values) < 3:
1203
+ return None
1204
+ xyz = []
1205
+ for component in values[:3]:
1206
+ number = safe_float(component)
1207
+ if number is None:
1208
+ return None
1209
+ xyz.append(float(number))
1210
+ return xyz
1211
+
1212
+
1213
+ def map_value(mapping, key):
1214
+ if not isinstance(mapping, dict):
1215
+ return None
1216
+ if key in mapping:
1217
+ return mapping[key]
1218
+ str_key = str(key)
1219
+ if str_key in mapping:
1220
+ return mapping[str_key]
1221
+ target = frame_number(key)
1222
+ for current_key, value in mapping.items():
1223
+ if frame_number(current_key) == target:
1224
+ return value
1225
+ return None
1226
+
1227
+
1228
+ def skeleton_joint(raw_skel, joint_index):
1229
+ if raw_skel is None:
1230
+ return None
1231
+ if hasattr(raw_skel, "tolist") and not isinstance(raw_skel, dict):
1232
+ raw_skel = raw_skel.tolist()
1233
+ if isinstance(raw_skel, dict):
1234
+ if joint_index in raw_skel:
1235
+ return raw_skel[joint_index]
1236
+ return raw_skel.get(str(joint_index))
1237
+ if isinstance(raw_skel, (list, tuple)) and joint_index < len(raw_skel):
1238
+ return raw_skel[joint_index]
1239
+ return None
1240
+
1241
+
1242
+ def max_joint_index(raw_skel):
1243
+ if raw_skel is None:
1244
+ return 32
1245
+ if hasattr(raw_skel, "tolist") and not isinstance(raw_skel, dict):
1246
+ raw_skel = raw_skel.tolist()
1247
+ if isinstance(raw_skel, dict):
1248
+ keys = [frame_number(key) for key in raw_skel.keys()]
1249
+ keys = [key for key in keys if key is not None]
1250
+ return max(keys, default=32)
1251
+ if isinstance(raw_skel, (list, tuple)):
1252
+ return max(32, len(raw_skel) - 1)
1253
+ return 32
1254
+
1255
+
1256
+ def build_skeleton_frames(rep, analytics, start_frame, end_frame):
1257
+ tracking_data = analytics.get("tracking_data", {}) if isinstance(analytics, dict) else {}
1258
+ player_skeletons = tracking_data.get("player_skeletons") if isinstance(tracking_data, dict) else None
1259
+ ball_positions = tracking_data.get("ball_positions") if isinstance(tracking_data, dict) else None
1260
+
1261
+ if not isinstance(player_skeletons, dict):
1262
+ player_skeletons = rep.get("skel_history", {})
1263
+ if not isinstance(ball_positions, dict):
1264
+ ball_positions = rep.get("ball_history", {})
1265
+
1266
+ skeleton_frames = []
1267
+ for f_idx in range(start_frame, end_frame + 1):
1268
+ raw_skel = map_value(player_skeletons, f_idx)
1269
+ raw_ball = map_value(ball_positions, f_idx)
1270
+ n_joints = max(33, max_joint_index(raw_skel) + 1)
1271
+ joints = []
1272
+ for joint_index in range(n_joints):
1273
+ point = point_to_float_list(skeleton_joint(raw_skel, joint_index))
1274
+ joints.append(point if point is not None else [0.0, 0.0, 0.0])
1275
+ frame_payload = {"frame": f_idx - start_frame, "joints": joints}
1276
+ ball_point = point_to_float_list(raw_ball)
1277
+ if ball_point is not None:
1278
+ frame_payload["ball"] = ball_point
1279
+ skeleton_frames.append(frame_payload)
1280
+ return skeleton_frames
1281
+
1282
+
1283
+ METRIC_DISPLAY_NAMES = {
1284
+ "stationary_foot_ball_distance_pctw_shoulder": "Support Foot-Ball Distance (% Shoulder Width)",
1285
+ }
1286
+
1287
+
1288
+ def metric_name(key: str) -> str:
1289
+ return METRIC_DISPLAY_NAMES.get(key, key.replace("_", " ").title())
1290
+
1291
+
1292
+ FULL_INTERVAL_KEYS = [
1293
+ "head_angle",
1294
+ "left_knee_angle",
1295
+ "right_knee_angle",
1296
+ "trunk_pitch_angle",
1297
+ "active_foot_ball_distance",
1298
+ "stationary_foot_ball_distance_pctw_shoulder",
1299
+ "foot_anteroposterior_offset",
1300
+ "foot_inclination_angle",
1301
+ "left_knee_angles",
1302
+ "right_knee_angles",
1303
+ "torso_pitch_angles",
1304
+ "head_angles",
1305
+ "mid_foot_ball_distances",
1306
+ "left_right_foot_distances",
1307
+ ]
1308
+
1309
+ ACTION_METRIC_LAYOUTS = {
1310
+ "Pass": {
1311
+ "pre": ["body_to_ball_angle"],
1312
+ "in": [
1313
+ "foot_region",
1314
+ "ball_contact_zone",
1315
+ "head_angle",
1316
+ "left_knee_angle",
1317
+ "right_knee_angle",
1318
+ "trunk_pitch_angle",
1319
+ "stationary_foot_ball_distance_pctw_shoulder",
1320
+ "body_to_ball_angle",
1321
+ "l_r_foot_distance",
1322
+ "trunc_pitch_angle",
1323
+ "trunc_roll_angle",
1324
+ "left_foot_orientation_angle",
1325
+ "right_foot_orientation_angle",
1326
+ "difference_in_angles",
1327
+ "l_knee_angle",
1328
+ "r_knee_angle",
1329
+ "head_pitch_angle",
1330
+ "head_roll_angle",
1331
+ "stand_foot_angle",
1332
+ "active_foot_height_pct",
1333
+ ],
1334
+ "post": ["head_angle", "body_to_ball_angle"],
1335
+ "top_level_scalars": ["backward_weighted_angle", "forward_weighted_angle"],
1336
+ },
1337
+ "Shoot": {
1338
+ "pre": ["max_backward_swing_distance", "max_backward_swing_angle", "body_to_ball_angle"],
1339
+ "in": [
1340
+ "foot_region",
1341
+ "ball_contact_zone",
1342
+ "head_angle",
1343
+ "left_knee_angle",
1344
+ "right_knee_angle",
1345
+ "trunk_pitch_angle",
1346
+ "stationary_foot_ball_distance_pctw_shoulder",
1347
+ "foot_inclination_angle",
1348
+ "body_to_ball_angle",
1349
+ "l_r_foot_distance",
1350
+ "trunc_pitch_angle",
1351
+ "trunc_roll_angle",
1352
+ "left_foot_orientation_angle",
1353
+ "right_foot_orientation_angle",
1354
+ "difference_in_angles",
1355
+ "l_knee_angle",
1356
+ "r_knee_angle",
1357
+ "head_pitch_angle",
1358
+ "head_roll_angle",
1359
+ "stand_foot_angle",
1360
+ "l_elbow_shoulder_hip_angle",
1361
+ "r_elbow_shoulder_hip_angle",
1362
+ "active_ankle_angle",
1363
+ ],
1364
+ "post": ["max_forward_swing_distance", "max_forward_swing_angle", "head_angle", "body_to_ball_angle"],
1365
+ "top_level_scalars": ["backward_weighted_angle", "forward_weighted_angle"],
1366
+ },
1367
+ "Receive": {
1368
+ "pre": ["body_orientation_vs_ball", "head_angle"],
1369
+ "in": [
1370
+ "foot_region",
1371
+ "ball_contact_zone",
1372
+ "head_angle",
1373
+ "left_knee_angle",
1374
+ "right_knee_angle",
1375
+ "trunk_pitch_angle",
1376
+ "stationary_foot_ball_distance_pctw_shoulder",
1377
+ "foot_anteroposterior_offset",
1378
+ "l_knee_angle",
1379
+ "r_knee_angle",
1380
+ "trunc_pitch_angle",
1381
+ "trunc_roll_angle",
1382
+ "left_foot_orientation_angle",
1383
+ "right_foot_orientation_angle",
1384
+ "difference_in_angles",
1385
+ "l_r_foot_distance",
1386
+ "stand_foot_angle",
1387
+ "body_orientation_vs_ball",
1388
+ "active_foot_height_pct",
1389
+ ],
1390
+ "post": ["mid_feet_ball_dist", "ball_height_pct_body"],
1391
+ "top_level_scalars": [],
1392
+ },
1393
+ "Dribble": {
1394
+ "frames": [
1395
+ "head_angle",
1396
+ "left_knee_angle",
1397
+ "right_knee_angle",
1398
+ "trunk_pitch_angle",
1399
+ "left_heel_height",
1400
+ "right_heel_height",
1401
+ "active_foot_ball_distance",
1402
+ "ball_feet_distance",
1403
+ "trunk_pitch",
1404
+ "trunk_roll",
1405
+ "ball_possession_score",
1406
+ ],
1407
+ "top_level_scalars": [],
1408
+ },
1409
+ }
1410
+
1411
+
1412
+ def ordered_metric_keys(observed_keys, preferred_keys=None):
1413
+ seen = set()
1414
+ ordered = []
1415
+ for key in preferred_keys or []:
1416
+ if key in observed_keys and key not in seen:
1417
+ seen.add(key)
1418
+ ordered.append(key)
1419
+ for key in sorted(observed_keys):
1420
+ if key not in seen:
1421
+ seen.add(key)
1422
+ ordered.append(key)
1423
+ return ordered
1424
+
1425
+
1426
+ def normalized_metric_identity(name):
1427
+ return re.sub(r"\s+", " ", str(name or "").strip().lower())
1428
+
1429
+
1430
+ def dedupe_metric_items(items):
1431
+ deduped = []
1432
+ seen = set()
1433
+ for item in items:
1434
+ if not isinstance(item, dict):
1435
+ deduped.append(item)
1436
+ continue
1437
+ identity = normalized_metric_identity(item.get("name"))
1438
+ if not identity:
1439
+ deduped.append(item)
1440
+ continue
1441
+ if identity in seen:
1442
+ continue
1443
+ seen.add(identity)
1444
+ deduped.append(item)
1445
+ return deduped
1446
+
1447
+
1448
+ def is_frame_metric_payload(payload):
1449
+ if isinstance(payload, list):
1450
+ return True
1451
+ if not isinstance(payload, dict) or not payload:
1452
+ return False
1453
+ keys_are_frames = all(frame_number(key) is not None for key in payload.keys())
1454
+ has_metric_dict = any(isinstance(value, dict) for value in payload.values())
1455
+ return keys_are_frames and has_metric_dict
1456
+
1457
+
1458
+ def entries_from_frame_payload(payload, start_frame=0):
1459
+ entries = []
1460
+ if isinstance(payload, list):
1461
+ for index, item in enumerate(payload):
1462
+ if not isinstance(item, dict):
1463
+ continue
1464
+ entry = dict(item)
1465
+ entry["frame"] = relative_frame(entry.get("frame", index), start_frame, index)
1466
+ entries.append(entry)
1467
+ return entries
1468
+
1469
+ if not isinstance(payload, dict):
1470
+ return entries
1471
+
1472
+ def sort_key(item):
1473
+ frame = frame_number(item[0])
1474
+ return (frame is None, frame if frame is not None else 0)
1475
+
1476
+ for index, (frame_key, frame_payload) in enumerate(sorted(payload.items(), key=sort_key)):
1477
+ if not isinstance(frame_payload, dict):
1478
+ continue
1479
+ entry = dict(frame_payload)
1480
+ entry["frame"] = relative_frame(frame_key, start_frame, index)
1481
+ entries.append(entry)
1482
+ return entries
1483
+
1484
+
1485
+ def build_series_from_entries(entries, unit_for, skip_keys=None, preferred_keys=None):
1486
+ skip = {"frame"}
1487
+ if skip_keys:
1488
+ skip.update(skip_keys)
1489
+
1490
+ metric_keys = set()
1491
+ for entry in entries:
1492
+ metric_keys.update(
1493
+ key for key in entry.keys()
1494
+ if key not in skip and safe_float(entry.get(key)) is not None
1495
+ )
1496
+
1497
+ series = []
1498
+ for key in ordered_metric_keys(metric_keys, preferred_keys):
1499
+ series.append({
1500
+ "name": metric_name(key),
1501
+ "unit": unit_for(key),
1502
+ "values": [
1503
+ {
1504
+ "frame": relative_frame(entry.get("frame"), 0, index),
1505
+ "value": safe_float(entry.get(key)),
1506
+ }
1507
+ for index, entry in enumerate(entries)
1508
+ ],
1509
+ })
1510
+ return dedupe_metric_items(series)
1511
+
1512
+
1513
+ def build_series_from_frame_payload(payload, unit_for, start_frame=0, skip_keys=None, preferred_keys=None):
1514
+ return build_series_from_entries(
1515
+ entries_from_frame_payload(payload, start_frame),
1516
+ unit_for,
1517
+ skip_keys=skip_keys,
1518
+ preferred_keys=preferred_keys,
1519
+ )
1520
+
1521
+
1522
+ def build_scalar_metrics(payload, unit_for, skip_keys=None, preferred_keys=None):
1523
+ if not isinstance(payload, dict):
1524
+ return []
1525
+ skip = set(skip_keys or [])
1526
+ skip.add("frame")
1527
+ metrics = []
1528
+ observed_keys = {
1529
+ key for key, value in payload.items()
1530
+ if key not in skip and not isinstance(value, (dict, list, tuple))
1531
+ }
1532
+ for key in ordered_metric_keys(observed_keys, preferred_keys):
1533
+ if key in skip:
1534
+ continue
1535
+ value = safe_metric_value(payload.get(key))
1536
+ metrics.append({
1537
+ "name": metric_name(key),
1538
+ "value": value,
1539
+ "unit": unit_for(key) if isinstance(value, (int, float)) else "",
1540
+ })
1541
+ return dedupe_metric_items(metrics)
1542
+
1543
+
1544
+ def build_top_level_interval_metrics(analytics, unit_for, skip_keys=None, preferred_keys=None):
1545
+ skip = {
1546
+ "action",
1547
+ "active_foot",
1548
+ "touch_frame",
1549
+ "pre_action",
1550
+ "action_frame",
1551
+ "post_action",
1552
+ "frames",
1553
+ "in_action_data",
1554
+ "pre_action_data",
1555
+ "post_action_data",
1556
+ "full_interval_data",
1557
+ "per_frame",
1558
+ "tracking_data",
1559
+ }
1560
+ if skip_keys:
1561
+ skip.update(skip_keys)
1562
+
1563
+ observed_keys = {
1564
+ key for key, value in analytics.items()
1565
+ if key not in skip and isinstance(value, list)
1566
+ }
1567
+
1568
+ series = []
1569
+ for key in ordered_metric_keys(observed_keys, preferred_keys):
1570
+ if key in skip:
1571
+ continue
1572
+ values = analytics.get(key)
1573
+ if values is None:
1574
+ values = []
1575
+ if not isinstance(values, list):
1576
+ continue
1577
+ series.append(format_metric_series(metric_name(key), unit_for(key), values))
1578
+ order_index = {}
1579
+ for idx, key in enumerate(preferred_keys or []):
1580
+ name = metric_name(key)
1581
+ if name not in order_index:
1582
+ order_index[name] = idx
1583
+ return dedupe_metric_items(sorted(series, key=lambda item: order_index.get(item["name"], len(order_index))))
1584
+
1585
+
1586
+ async def save_upload_to_path(upload: UploadFile, destination: str):
1587
+ with open(destination, "wb") as f:
1588
+ while True:
1589
+ chunk = await upload.read(1024 * 1024)
1590
+ if not chunk:
1591
+ break
1592
+ f.write(chunk)
1593
+
1594
+
1595
+ def run_analysis_job(
1596
+ request_base_url: str,
1597
+ player_id: str,
1598
+ target_w: float,
1599
+ target_h: float,
1600
+ client_id: str,
1601
+ session_id: str,
1602
+ session_dir: str,
1603
+ actions_path: str,
1604
+ calib_path: str,
1605
+ camera_map,
1606
+ ):
1607
+ def ensure_not_cancelled():
1608
+ if is_cancel_requested(client_id):
1609
+ raise InterruptedError("Processing cancelled by user")
1610
+
1611
+ try:
1612
+ ensure_not_cancelled()
1613
+
1614
+ set_progress(client_id, 10.0, "Preparing AI Models", status="running", sessionId=session_id)
1615
+ runtime = ensure_runtime_ready()
1616
+ utils_paths = {
1617
+ "POSE_PATH": runtime["weights"]["POSE_PATH"],
1618
+ "YOLO_PATH": runtime["weights"]["YOLO_PATH"],
1619
+ "CALIBRATION_PATH": calib_path,
1620
+ "ACTIONS_PATH": actions_path,
1621
+ }
1622
+
1623
+ sizes = {
1624
+ "TARGET_SIZE": (int(target_w), int(target_h)),
1625
+ "YOLO_IMGSZ": 960,
1626
+ }
1627
+
1628
+ ensure_not_cancelled()
1629
+
1630
+ print(f"Starting physical pipeline execution for {session_id}...")
1631
+ set_progress(client_id, 20.0, "Extracting 3D Kinematics", status="running", sessionId=session_id)
1632
+
1633
+ def progress_tracker(current_act, total_act, step, total_frames):
1634
+ if is_cancel_requested(client_id):
1635
+ return False
1636
+
1637
+ processed_step = min(max(1, total_frames), max(0, step + 1))
1638
+ base_p = current_act / max(1, total_act)
1639
+ segment_p = (processed_step / max(1, total_frames)) * (1.0 / max(1, total_act))
1640
+ pct = 20.0 + round((base_p + segment_p) * 70.0, 1)
1641
+ set_progress(
1642
+ client_id,
1643
+ pct,
1644
+ f"Processing Action {current_act + 1}/{total_act}",
1645
+ processed_step,
1646
+ total_frames,
1647
+ status="running",
1648
+ sessionId=session_id,
1649
+ )
1650
+ return True
1651
+
1652
+ reports = runtime["run_pipeline"](camera_map, utils_paths, sizes, progress_tracker)
1653
+ ensure_not_cancelled()
1654
+
1655
+ set_progress(client_id, 92.0, "Preparing Playback Assets", status="running", sessionId=session_id)
1656
+ ensure_not_cancelled()
1657
+ fallback_camera_map = normalize_camera_videos_for_web(camera_map)
1658
+ ensure_not_cancelled()
1659
+ set_progress(client_id, 96.0, "Finalizing Results", status="running", sessionId=session_id)
1660
+ ensure_not_cancelled()
1661
+
1662
+ raw_reports_path = os.path.join(session_dir, "raw_reports.json")
1663
+ with open(raw_reports_path, "w", encoding="utf-8") as f:
1664
+ json.dump(reports, f, indent=2, default=json_default)
1665
+
1666
+ with open(actions_path, "r", encoding="utf-8") as f:
1667
+ raw_actions = json.load(f).get("actions", [])
1668
+
1669
+ formatted_actions = []
1670
+ failed_actions = []
1671
+ camera_videos = build_camera_video_entries(request_base_url, player_id, session_id, camera_map)
1672
+ source_camera_videos = build_camera_video_entries(request_base_url, player_id, session_id, fallback_camera_map)
1673
+ degree_keys = {
1674
+ "head_angle",
1675
+ "l_knee_angle",
1676
+ "r_knee_angle",
1677
+ "trunc_pitch_angle",
1678
+ "trunc_roll_angle",
1679
+ "trunk_pitch",
1680
+ "trunk_roll",
1681
+ "head_pitch_angle",
1682
+ "head_roll_angle",
1683
+ "left_foot_orientation_angle",
1684
+ "right_foot_orientation_angle",
1685
+ "difference_in_angles",
1686
+ "body_to_ball_angle",
1687
+ "body_orientation_vs_ball",
1688
+ "stand_foot_angle",
1689
+ "active_ankle_angle",
1690
+ "l_elbow_shoulder_hip_angle",
1691
+ "r_elbow_shoulder_hip_angle",
1692
+ "backward_weighted_angle",
1693
+ "forward_weighted_angle",
1694
+ "leg_separation_angle",
1695
+ "left_knee_angle",
1696
+ "right_knee_angle",
1697
+ "trunk_pitch_angle",
1698
+ "max_backward_swing_angle",
1699
+ "max_forward_swing_angle",
1700
+ "foot_inclination_angle",
1701
+ }
1702
+ UNITS = {key: "\u00b0" for key in degree_keys}
1703
+ UNITS.update({
1704
+ "l_r_foot_distance": "cm",
1705
+ "l_foot_ball_distance": "cm",
1706
+ "r_foot_ball_distance": "cm",
1707
+ "mid_feet_ball_dist": "cm",
1708
+ "active_foot_height_pct": "%",
1709
+ "ball_height_pct_body": "%",
1710
+ "ball_possession_score": "%",
1711
+ "ball_feet_distance": "cm",
1712
+ "active_foot_ball_distance": "cm",
1713
+ "stationary_foot_ball_distance_pctw_shoulder": "%",
1714
+ "left_heel_height": "cm",
1715
+ "right_heel_height": "cm",
1716
+ "foot_anteroposterior_offset": "cm",
1717
+ "max_backward_swing_distance": "cm",
1718
+ "max_forward_swing_distance": "cm",
1719
+ })
1720
+
1721
+ def unit_for(key):
1722
+ return UNITS.get(key, "")
1723
+
1724
+ for i, rep in enumerate(reports):
1725
+ ensure_not_cancelled()
1726
+ raw = raw_actions[i] if i < len(raw_actions) else {}
1727
+
1728
+ if "error" in rep:
1729
+ failed_actions.append({
1730
+ "id": f"err-{uuid.uuid4().hex[:6]}",
1731
+ "label": rep.get("action", raw.get("label", "Unknown")),
1732
+ "start": raw.get("start", "00:00:00:00"),
1733
+ "end": raw.get("end", "00:00:00:00"),
1734
+ "error": rep["error"],
1735
+ })
1736
+ continue
1737
+
1738
+ an = rep.get("analytics", {})
1739
+ if not isinstance(an, dict):
1740
+ an = {}
1741
+ action_name = rep.get("action", raw.get("label", an.get("action", "Unknown")))
1742
+ sf = int(rep.get("start_frame", 0))
1743
+ ef = int(rep.get("end_frame", sf))
1744
+ fps = float(rep.get("fps", 30))
1745
+ is_dribble = action_name.lower() == "dribble" or "per_frame" in an
1746
+ tf = relative_frame(an.get("touch_frame"), sf, (ef - sf) // 2)
1747
+ skeleton_frames = build_skeleton_frames(rep, an, sf, ef)
1748
+
1749
+ action_layout = ACTION_METRIC_LAYOUTS.get(action_name, {})
1750
+ active_foot = an.get("active_foot")
1751
+ pre_action_metrics = []
1752
+ post_action_metrics = []
1753
+
1754
+ if is_dribble:
1755
+ dribble_payload = an.get("per_frame", an.get("frames", []))
1756
+ pre_metrics = []
1757
+ in_action_metrics = []
1758
+ post_metrics = []
1759
+ full_interval_metrics = build_series_from_frame_payload(
1760
+ dribble_payload,
1761
+ unit_for,
1762
+ start_frame=sf,
1763
+ preferred_keys=action_layout.get("frames"),
1764
+ )
1765
+ else:
1766
+ pre_payload = an.get("pre_action_data", an.get("pre_action", []))
1767
+ post_payload = an.get("post_action_data", an.get("post_action", []))
1768
+ action_frame_data = an.get("in_action_data", an.get("action_frame", {}))
1769
+ if is_frame_metric_payload(pre_payload):
1770
+ pre_metrics = build_series_from_frame_payload(
1771
+ pre_payload,
1772
+ unit_for,
1773
+ start_frame=sf,
1774
+ preferred_keys=action_layout.get("pre"),
1775
+ )
1776
+ else:
1777
+ pre_metrics = []
1778
+ pre_action_metrics = build_scalar_metrics(
1779
+ pre_payload,
1780
+ unit_for,
1781
+ preferred_keys=action_layout.get("pre"),
1782
+ )
1783
+ in_action_metrics = build_scalar_metrics(
1784
+ action_frame_data,
1785
+ unit_for,
1786
+ skip_keys={"active_foot"},
1787
+ preferred_keys=action_layout.get("in"),
1788
+ )
1789
+ if "in_action_data" not in an:
1790
+ in_action_metrics.extend(
1791
+ build_scalar_metrics(
1792
+ an,
1793
+ unit_for,
1794
+ skip_keys={
1795
+ "action",
1796
+ "active_foot",
1797
+ "touch_frame",
1798
+ "pre_action",
1799
+ "action_frame",
1800
+ "post_action",
1801
+ "frames",
1802
+ "left_knee_angles",
1803
+ "right_knee_angles",
1804
+ "torso_pitch_angles",
1805
+ "head_angles",
1806
+ "mid_foot_ball_distances",
1807
+ "left_right_foot_distances",
1808
+ },
1809
+ preferred_keys=action_layout.get("top_level_scalars"),
1810
+ )
1811
+ )
1812
+ if is_frame_metric_payload(post_payload):
1813
+ post_metrics = build_series_from_frame_payload(
1814
+ post_payload,
1815
+ unit_for,
1816
+ start_frame=sf,
1817
+ preferred_keys=action_layout.get("post"),
1818
+ )
1819
+ else:
1820
+ post_metrics = []
1821
+ post_action_metrics = build_scalar_metrics(
1822
+ post_payload,
1823
+ unit_for,
1824
+ preferred_keys=action_layout.get("post"),
1825
+ )
1826
+
1827
+ full_payload = an.get("full_interval_data")
1828
+ if full_payload is not None:
1829
+ full_interval_metrics = build_series_from_frame_payload(
1830
+ full_payload,
1831
+ unit_for,
1832
+ start_frame=sf,
1833
+ preferred_keys=FULL_INTERVAL_KEYS,
1834
+ )
1835
+ else:
1836
+ full_interval_metrics = build_top_level_interval_metrics(
1837
+ an,
1838
+ unit_for,
1839
+ preferred_keys=FULL_INTERVAL_KEYS,
1840
+ )
1841
+
1842
+ formatted_actions.append({
1843
+ "id": f"{action_name.lower()}-{uuid.uuid4().hex[:6]}",
1844
+ "label": action_name,
1845
+ "activeFoot": active_foot,
1846
+ "start": raw.get("start", "00:00:00:00"),
1847
+ "end": raw.get("end", "00:00:00:00"),
1848
+ "fps": fps,
1849
+ "startFrame": sf,
1850
+ "endFrame": ef,
1851
+ "startSeconds": max(0.0, sf / max(1.0, fps)),
1852
+ "endSeconds": max(0.0, (ef + 1) / max(1.0, fps)),
1853
+ "totalFrames": ef - sf + 1,
1854
+ "preFrames": max(0, tf),
1855
+ "inFrame": max(0, tf),
1856
+ "postFrames": max(0, (ef - sf) - tf),
1857
+ "cameraClips": camera_videos,
1858
+ "sourceCameraClips": source_camera_videos,
1859
+ "preMetrics": dedupe_metric_items(pre_metrics),
1860
+ "preActionMetrics": dedupe_metric_items(pre_action_metrics),
1861
+ "inActionMetrics": dedupe_metric_items(in_action_metrics),
1862
+ "postMetrics": dedupe_metric_items(post_metrics),
1863
+ "postActionMetrics": dedupe_metric_items(post_action_metrics),
1864
+ "fullIntervalMetrics": dedupe_metric_items(full_interval_metrics),
1865
+ "skeleton": skeleton_frames,
1866
+ "rawAnalytics": an,
1867
+ })
1868
+
1869
+ print(f"Pipeline successful. Session {session_id} saved.")
1870
+ response_payload = {
1871
+ "id": session_id,
1872
+ "playerId": player_id,
1873
+ "createdAt": int(time.time() * 1000),
1874
+ "targetSize": [int(target_w), int(target_h)],
1875
+ "cameraCount": len(camera_map),
1876
+ "actions": formatted_actions,
1877
+ "failedActions": failed_actions,
1878
+ }
1879
+ response_payload = make_json_safe(response_payload)
1880
+ ensure_not_cancelled()
1881
+
1882
+ session_json_path = os.path.join(session_dir, "session.json")
1883
+ with open(session_json_path, "w", encoding="utf-8") as f:
1884
+ json.dump(response_payload, f, indent=2)
1885
+ write_session_summary(session_json_path, response_payload)
1886
+ try:
1887
+ push_session_to_hf(player_id, session_id, session_dir)
1888
+ except Exception as sync_error:
1889
+ print("--- STORAGE SYNC WARNING ---")
1890
+ print(f"Session {session_id} was created locally but could not be pushed to Hugging Face storage: {sync_error}")
1891
+
1892
+ set_progress(
1893
+ client_id,
1894
+ 100.0,
1895
+ "Completed",
1896
+ status="completed",
1897
+ sessionId=session_id,
1898
+ resultUrl=f"/results/{session_id}",
1899
+ )
1900
+ clear_cancel_request(client_id)
1901
+ return response_payload
1902
+
1903
+ except Exception as e:
1904
+ print("--- PIPELINE ERROR ---")
1905
+ traceback.print_exc()
1906
+ current_progress = current_progress_payload(client_id).get("progress", 0.0)
1907
+ was_cancelled = isinstance(e, InterruptedError) or is_cancel_requested(client_id)
1908
+ if was_cancelled:
1909
+ set_progress(
1910
+ client_id,
1911
+ current_progress,
1912
+ "Processing Cancelled",
1913
+ status="cancelled",
1914
+ sessionId=session_id,
1915
+ error=str(e),
1916
+ )
1917
+ else:
1918
+ set_progress(
1919
+ client_id,
1920
+ current_progress,
1921
+ "Failed",
1922
+ status="failed",
1923
+ sessionId=session_id,
1924
+ error=str(e),
1925
+ )
1926
+ clear_cancel_request(client_id)
1927
+ if session_dir and os.path.isdir(session_dir):
1928
+ shutil.rmtree(session_dir, ignore_errors=True)
1929
+ return None
1930
+
1931
+
1932
+ @app.post("/api/analyze")
1933
+ async def analyze_endpoint(
1934
+ request: Request,
1935
+ playerId: str = Form(...),
1936
+ targetW: float = Form(...),
1937
+ targetH: float = Form(...),
1938
+ clientId: str = Form(...),
1939
+ videoOrders: List[int] = Form(...),
1940
+ actionsJson: UploadFile = File(...),
1941
+ calibration: UploadFile = File(...),
1942
+ videos: List[UploadFile] = File(...),
1943
+ ):
1944
+ session_id = f"session-{int(time.time())}-{uuid.uuid4().hex[:6]}"
1945
+ _player_dir, session_dir, videos_dir = session_storage_paths(playerId, session_id)
1946
+ try:
1947
+ if is_cancel_requested(clientId):
1948
+ raise InterruptedError("Processing cancelled by user")
1949
+ set_progress(
1950
+ clientId,
1951
+ 2.0,
1952
+ "Uploading & Validating Data",
1953
+ status="uploading",
1954
+ sessionId=session_id,
1955
+ playerId=playerId,
1956
+ )
1957
+
1958
+ if len(videoOrders) != len(videos):
1959
+ raise ValueError("Each uploaded video must include a matching camera order")
1960
+ if len(set(videoOrders)) != len(videoOrders):
1961
+ raise ValueError("Camera order values must be unique")
1962
+
1963
+ os.makedirs(videos_dir, exist_ok=True)
1964
+
1965
+ actions_path = os.path.join(session_dir, "actions.json")
1966
+ await save_upload_to_path(actionsJson, actions_path)
1967
+
1968
+ calib_path = os.path.join(session_dir, "calibration.npz")
1969
+ await save_upload_to_path(calibration, calib_path)
1970
+
1971
+ camera_map = {}
1972
+ for idx, (camera_order, video) in enumerate(zip(videoOrders, videos)):
1973
+ original_name = video.filename or f"camera_{camera_order}.mp4"
1974
+ video_name = f"{idx:02d}_cam_{camera_order}_{safe_name(os.path.basename(original_name))}"
1975
+ vid_path = os.path.join(videos_dir, video_name)
1976
+ await save_upload_to_path(video, vid_path)
1977
+ camera_map[int(camera_order)] = vid_path
1978
+
1979
+ if is_cancel_requested(clientId):
1980
+ raise InterruptedError("Processing cancelled by user")
1981
+
1982
+ request_base_url = request_public_base_url(request)
1983
+ set_progress(
1984
+ clientId,
1985
+ 8.0,
1986
+ "Queued for Processing",
1987
+ status="queued",
1988
+ sessionId=session_id,
1989
+ playerId=playerId,
1990
+ )
1991
+ future = analysis_executor.submit(
1992
+ run_analysis_job,
1993
+ request_base_url,
1994
+ playerId,
1995
+ targetW,
1996
+ targetH,
1997
+ clientId,
1998
+ session_id,
1999
+ session_dir,
2000
+ actions_path,
2001
+ calib_path,
2002
+ camera_map,
2003
+ )
2004
+ register_active_job(clientId, future)
2005
+ future.add_done_callback(lambda _future, stored_client_id=clientId: unregister_active_job(stored_client_id))
2006
+
2007
+ return JSONResponse(
2008
+ status_code=202,
2009
+ content={
2010
+ "accepted": True,
2011
+ "status": "queued",
2012
+ "clientId": clientId,
2013
+ "sessionId": session_id,
2014
+ },
2015
+ )
2016
+
2017
+ except Exception as e:
2018
+ print("--- UPLOAD ERROR ---")
2019
+ traceback.print_exc()
2020
+ was_cancelled = isinstance(e, InterruptedError) or is_cancel_requested(clientId)
2021
+ status = "cancelled" if was_cancelled else "failed"
2022
+ phase = "Processing Cancelled" if was_cancelled else "Failed"
2023
+ set_progress(
2024
+ clientId,
2025
+ current_progress_payload(clientId).get("progress", 0.0),
2026
+ phase,
2027
+ status=status,
2028
+ sessionId=session_id,
2029
+ error=str(e),
2030
+ )
2031
+ if was_cancelled:
2032
+ clear_cancel_request(clientId)
2033
+ if session_dir and os.path.isdir(session_dir):
2034
+ shutil.rmtree(session_dir, ignore_errors=True)
2035
+ if was_cancelled:
2036
+ raise HTTPException(status_code=409, detail=str(e))
2037
+ raise HTTPException(status_code=500, detail=str(e))
2038
+
2039
+
2040
+ async def analyze_endpoint_inline(
2041
+ request: Request,
2042
+ playerId: str = Form(...),
2043
+ targetW: float = Form(...),
2044
+ targetH: float = Form(...),
2045
+ clientId: str = Form(...),
2046
+ videoOrders: List[int] = Form(...),
2047
+ actionsJson: UploadFile = File(...),
2048
+ calibration: UploadFile = File(...),
2049
+ videos: List[UploadFile] = File(...)
2050
+ ):
2051
+ temp_dir = None
2052
+ session_id = f"session-{int(time.time())}-{uuid.uuid4().hex[:6]}"
2053
+ player_dir, session_dir, videos_dir = session_storage_paths(playerId, session_id)
2054
+ try:
2055
+ if is_cancel_requested(clientId):
2056
+ raise InterruptedError("Processing cancelled by user")
2057
+ set_progress(clientId, 2.0, "Uploading & Validating Data")
2058
+
2059
+ os.makedirs(videos_dir, exist_ok=True)
2060
+ temp_dir = session_dir
2061
+
2062
+ # 1. Store incoming payloads
2063
+ actions_path = os.path.join(session_dir, "actions.json")
2064
+ with open(actions_path, "wb") as f:
2065
+ f.write(await actionsJson.read())
2066
+
2067
+ calib_path = os.path.join(session_dir, "calibration.npz")
2068
+ with open(calib_path, "wb") as f:
2069
+ f.write(await calibration.read())
2070
+
2071
+ if len(videoOrders) != len(videos):
2072
+ raise ValueError("Each uploaded video must include a matching camera order")
2073
+ if len(set(videoOrders)) != len(videoOrders):
2074
+ raise ValueError("Camera order values must be unique")
2075
+
2076
+ camera_map = {}
2077
+ for idx, (camera_order, video) in enumerate(zip(videoOrders, videos)):
2078
+ original_name = video.filename or f"camera_{camera_order}.mp4"
2079
+ video_name = f"{idx:02d}_cam_{camera_order}_{safe_name(os.path.basename(original_name))}"
2080
+ vid_path = os.path.join(videos_dir, video_name)
2081
+ with open(vid_path, "wb") as f:
2082
+ f.write(await video.read())
2083
+ camera_id = int(camera_order)
2084
+ camera_map[camera_id] = vid_path
2085
+
2086
+ if is_cancel_requested(clientId):
2087
+ raise InterruptedError("Processing cancelled by user")
2088
+
2089
+ set_progress(clientId, 10.0, "Preparing AI Models")
2090
+ runtime = ensure_runtime_ready()
2091
+ utils_paths = {
2092
+ "POSE_PATH": runtime["weights"]["POSE_PATH"],
2093
+ "YOLO_PATH": runtime["weights"]["YOLO_PATH"],
2094
+ "CALIBRATION_PATH": calib_path,
2095
+ "ACTIONS_PATH": actions_path
2096
+ }
2097
+
2098
+ sizes = {
2099
+ "TARGET_SIZE": (int(targetW), int(targetH)),
2100
+ "YOLO_IMGSZ": 960
2101
+ }
2102
+
2103
+ print("Starting physical pipeline execution...")
2104
+ set_progress(clientId, 20.0, "Extracting 3D Kinematics")
2105
+
2106
+ def progress_tracker(current_act, total_act, step, total_frames):
2107
+ if is_cancel_requested(clientId):
2108
+ return False # Signal pipeline to abort
2109
+
2110
+ processed_step = min(max(1, total_frames), max(0, step + 1))
2111
+ base_p = current_act / max(1, total_act)
2112
+ segment_p = (processed_step / max(1, total_frames)) * (1.0 / max(1, total_act))
2113
+ # Rescale 20% to 90% for processing
2114
+ pct = 20.0 + round((base_p + segment_p) * 70.0, 1)
2115
+ set_progress(clientId, pct, f"Processing Action {current_act + 1}/{total_act}", processed_step, total_frames)
2116
+ return True
2117
+
2118
+ # 2. Yield to worker thread to allow concurrent polling from front-end
2119
+ def execute_pipeline():
2120
+ return runtime["run_pipeline"](camera_map, utils_paths, sizes, progress_tracker)
2121
+
2122
+ reports = await run_in_threadpool(execute_pipeline)
2123
+ set_progress(clientId, 92.0, "Preparing Playback Assets")
2124
+ fallback_camera_map = await run_in_threadpool(normalize_camera_videos_for_web, camera_map)
2125
+ set_progress(clientId, 96.0, "Finalizing Results")
2126
+
2127
+ raw_reports_path = os.path.join(session_dir, "raw_reports.json")
2128
+ with open(raw_reports_path, "w", encoding="utf-8") as f:
2129
+ json.dump(reports, f, indent=2, default=json_default)
2130
+
2131
+ # 3. Format output dict perfectly mapping to the Frontend Types
2132
+ with open(actions_path, "r") as f:
2133
+ raw_actions = json.load(f).get("actions", [])
2134
+
2135
+ formatted_actions = []
2136
+ failed_actions = []
2137
+ camera_videos = build_camera_video_entries(request, playerId, session_id, camera_map)
2138
+ source_camera_videos = build_camera_video_entries(request, playerId, session_id, fallback_camera_map)
2139
+ for i, rep in enumerate(reports):
2140
+ raw = raw_actions[i] if i < len(raw_actions) else {}
2141
+
2142
+ if "error" in rep:
2143
+ failed_actions.append({
2144
+ "id": f"err-{uuid.uuid4().hex[:6]}",
2145
+ "label": rep.get("action", raw.get("label", "Unknown")),
2146
+ "start": raw.get("start", "00:00:00:00"),
2147
+ "end": raw.get("end", "00:00:00:00"),
2148
+ "error": rep["error"]
2149
+ })
2150
+ continue
2151
+
2152
+ an = rep.get("analytics", {})
2153
+ if not isinstance(an, dict):
2154
+ an = {}
2155
+ action_name = rep.get("action", raw.get("label", an.get("action", "Unknown")))
2156
+ sf = int(rep.get("start_frame", 0))
2157
+ ef = int(rep.get("end_frame", sf))
2158
+ fps = float(rep.get("fps", 30))
2159
+ is_dribble = action_name.lower() == "dribble" or "per_frame" in an
2160
+ tf = relative_frame(an.get("touch_frame"), sf, (ef - sf) // 2)
2161
+
2162
+ # --- Skeleton: support full COCO-25/WB joint range (0–32) ---
2163
+ skeleton_frames = build_skeleton_frames(rep, an, sf, ef)
2164
+
2165
+ # --- Unit dictionary for known metric names ---
2166
+ DEG = "\u00b0"
2167
+ UNITS = {
2168
+ "head_angle": "°", "l_knee_angle": "°", "r_knee_angle": "°",
2169
+ "trunc_pitch_angle": "°", "trunc_roll_angle": "°",
2170
+ "trunk_pitch": "°", "trunk_roll": "°",
2171
+ "head_pitch_angle": "°", "head_roll_angle": "°",
2172
+ "left_foot_orientation_angle": "°", "right_foot_orientation_angle": "°",
2173
+ "difference_in_angles": "°", "body_to_ball_angle": "°",
2174
+ "body_orientation_vs_ball": "°", "stand_foot_angle": "°",
2175
+ "active_ankle_angle": "°", "l_elbow_shoulder_hip_angle": "°",
2176
+ "r_elbow_shoulder_hip_angle": "°", "backward_weighted_angle": "°",
2177
+ "forward_weighted_angle": "°", "leg_separation_angle": "°",
2178
+ "l_r_foot_distance": "cm", "l_foot_ball_distance": "cm",
2179
+ "r_foot_ball_distance": "cm", "mid_feet_ball_dist": "cm",
2180
+ "active_foot_height_pct": "%", "ball_height_pct_body": "%",
2181
+ "ball_possession_score": "%", "ball_feet_distance": "cm",
2182
+ }
2183
+ UNITS.update({
2184
+ "left_knee_angle": DEG,
2185
+ "right_knee_angle": DEG,
2186
+ "trunk_pitch_angle": DEG,
2187
+ "max_backward_swing_angle": DEG,
2188
+ "max_forward_swing_angle": DEG,
2189
+ "foot_inclination_angle": DEG,
2190
+ "active_foot_ball_distance": "cm",
2191
+ "stationary_foot_ball_distance_pctw_shoulder": "%",
2192
+ "left_heel_height": "cm",
2193
+ "right_heel_height": "cm",
2194
+ "foot_anteroposterior_offset": "cm",
2195
+ "max_backward_swing_distance": "cm",
2196
+ "max_forward_swing_distance": "cm",
2197
+ })
2198
+
2199
+ def unit_for(key):
2200
+ return UNITS.get(key, "")
2201
+
2202
+ action_layout = ACTION_METRIC_LAYOUTS.get(action_name, {})
2203
+ active_foot = an.get("active_foot")
2204
+ pre_action_metrics = []
2205
+ post_action_metrics = []
2206
+
2207
+ if is_dribble:
2208
+ dribble_payload = an.get("per_frame", an.get("frames", []))
2209
+ pre_metrics = []
2210
+ in_action_metrics = []
2211
+ post_metrics = []
2212
+ full_interval_metrics = build_series_from_frame_payload(
2213
+ dribble_payload,
2214
+ unit_for,
2215
+ start_frame=sf,
2216
+ preferred_keys=action_layout.get("frames"),
2217
+ )
2218
+ else:
2219
+ pre_payload = an.get("pre_action_data", an.get("pre_action", []))
2220
+ post_payload = an.get("post_action_data", an.get("post_action", []))
2221
+ action_frame_data = an.get("in_action_data", an.get("action_frame", {}))
2222
+ if is_frame_metric_payload(pre_payload):
2223
+ pre_metrics = build_series_from_frame_payload(
2224
+ pre_payload,
2225
+ unit_for,
2226
+ start_frame=sf,
2227
+ preferred_keys=action_layout.get("pre"),
2228
+ )
2229
+ else:
2230
+ pre_metrics = []
2231
+ pre_action_metrics = build_scalar_metrics(
2232
+ pre_payload,
2233
+ unit_for,
2234
+ preferred_keys=action_layout.get("pre"),
2235
+ )
2236
+ in_action_metrics = build_scalar_metrics(
2237
+ action_frame_data,
2238
+ unit_for,
2239
+ skip_keys={"active_foot"},
2240
+ preferred_keys=action_layout.get("in"),
2241
+ )
2242
+ if "in_action_data" not in an:
2243
+ in_action_metrics.extend(
2244
+ build_scalar_metrics(
2245
+ an,
2246
+ unit_for,
2247
+ skip_keys={
2248
+ "action",
2249
+ "active_foot",
2250
+ "touch_frame",
2251
+ "pre_action",
2252
+ "action_frame",
2253
+ "post_action",
2254
+ "frames",
2255
+ "left_knee_angles",
2256
+ "right_knee_angles",
2257
+ "torso_pitch_angles",
2258
+ "head_angles",
2259
+ "mid_foot_ball_distances",
2260
+ "left_right_foot_distances",
2261
+ },
2262
+ preferred_keys=action_layout.get("top_level_scalars"),
2263
+ )
2264
+ )
2265
+ if is_frame_metric_payload(post_payload):
2266
+ post_metrics = build_series_from_frame_payload(
2267
+ post_payload,
2268
+ unit_for,
2269
+ start_frame=sf,
2270
+ preferred_keys=action_layout.get("post"),
2271
+ )
2272
+ else:
2273
+ post_metrics = []
2274
+ post_action_metrics = build_scalar_metrics(
2275
+ post_payload,
2276
+ unit_for,
2277
+ preferred_keys=action_layout.get("post"),
2278
+ )
2279
+
2280
+ full_payload = an.get("full_interval_data")
2281
+ if full_payload is not None:
2282
+ full_interval_metrics = build_series_from_frame_payload(
2283
+ full_payload,
2284
+ unit_for,
2285
+ start_frame=sf,
2286
+ preferred_keys=FULL_INTERVAL_KEYS,
2287
+ )
2288
+ else:
2289
+ full_interval_metrics = build_top_level_interval_metrics(
2290
+ an,
2291
+ unit_for,
2292
+ preferred_keys=FULL_INTERVAL_KEYS,
2293
+ )
2294
+
2295
+ formatted_actions.append({
2296
+ "id": f"{action_name.lower()}-{uuid.uuid4().hex[:6]}",
2297
+ "label": action_name,
2298
+ "activeFoot": active_foot,
2299
+ "start": raw.get("start", "00:00:00:00"),
2300
+ "end": raw.get("end", "00:00:00:00"),
2301
+ "fps": fps,
2302
+ "startFrame": sf,
2303
+ "endFrame": ef,
2304
+ "startSeconds": max(0.0, sf / max(1.0, fps)),
2305
+ "endSeconds": max(0.0, (ef + 1) / max(1.0, fps)),
2306
+ "totalFrames": ef - sf + 1,
2307
+ "preFrames": max(0, tf),
2308
+ "inFrame": max(0, tf),
2309
+ "postFrames": max(0, (ef - sf) - tf),
2310
+ "cameraClips": camera_videos,
2311
+ "sourceCameraClips": source_camera_videos,
2312
+ "preMetrics": dedupe_metric_items(pre_metrics),
2313
+ "preActionMetrics": dedupe_metric_items(pre_action_metrics),
2314
+ "inActionMetrics": dedupe_metric_items(in_action_metrics),
2315
+ "postMetrics": dedupe_metric_items(post_metrics),
2316
+ "postActionMetrics": dedupe_metric_items(post_action_metrics),
2317
+ "fullIntervalMetrics": dedupe_metric_items(full_interval_metrics),
2318
+ "skeleton": skeleton_frames,
2319
+ "rawAnalytics": an,
2320
+ })
2321
+
2322
+ print("Pipeline successful. Yielding payload payload.")
2323
+ response_payload = {
2324
+ "id": session_id,
2325
+ "playerId": playerId,
2326
+ "createdAt": int(time.time() * 1000),
2327
+ "targetSize": [int(targetW), int(targetH)],
2328
+ "cameraCount": len(camera_map),
2329
+ "actions": formatted_actions,
2330
+ "failedActions": failed_actions
2331
+ }
2332
+ response_payload = make_json_safe(response_payload)
2333
+
2334
+ session_json_path = os.path.join(session_dir, "session.json")
2335
+ with open(session_json_path, "w", encoding="utf-8") as f:
2336
+ json.dump(response_payload, f, indent=2)
2337
+ write_session_summary(session_json_path, response_payload)
2338
+ try:
2339
+ push_session_to_hf(playerId, session_id, session_dir)
2340
+ except Exception as sync_error:
2341
+ print("--- STORAGE SYNC WARNING ---")
2342
+ print(f"Session {session_id} was created locally but could not be pushed to Hugging Face storage: {sync_error}")
2343
+
2344
+ set_progress(clientId, 100.0, "Completed", status="completed")
2345
+ return response_payload
2346
+
2347
+ except Exception as e:
2348
+ print("--- PIPELINE ERROR ---")
2349
+ traceback.print_exc()
2350
+ was_cancelled = isinstance(e, InterruptedError) or is_cancel_requested(clientId)
2351
+ if was_cancelled:
2352
+ set_progress(clientId, current_progress_payload(clientId).get("progress", 0.0), "Processing Cancelled", status="cancelled")
2353
+ clear_cancel_request(clientId)
2354
+ else:
2355
+ set_progress(clientId, current_progress_payload(clientId).get("progress", 0.0), "Failed", status="failed")
2356
+ if temp_dir and os.path.isdir(temp_dir):
2357
+ shutil.rmtree(temp_dir, ignore_errors=True)
2358
+ if was_cancelled:
2359
+ raise HTTPException(status_code=409, detail=str(e))
2360
+ raise HTTPException(status_code=500, detail=str(e))
2361
+
2362
+ if __name__ == "__main__":
2363
+ import uvicorn
2364
+ # Start ASGI interface natively mapping locally to the React vite environment
2365
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8000")))
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.100.0
2
+ uvicorn>=0.23.0
3
+ python-multipart>=0.0.6
4
+ huggingface_hub>=0.34.0
5
+ lap>=0.5.12
6
+ numpy
7
+ scipy
8
+ opencv-python
9
+ onnxruntime
10
+ supervision
11
+ torch
12
+ ultralytics
13
+ plotly