Spaces:
Running
Running
Commit ·
592de64
0
Parent(s):
inital commit at migration
Browse files- .dockerignore +5 -0
- .env.example +5 -0
- .gitignore +3 -0
- Dockerfile +11 -0
- README.md +21 -0
- app.py +409 -0
- requirements.txt +14 -0
.dockerignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.env.*
|
| 3 |
+
__pycache__/
|
| 4 |
+
.venv/
|
| 5 |
+
test_app.py
|
.env.example
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CORE_REPO_ID=your-account/sporalize-core
|
| 2 |
+
STORAGE_REPO_ID=your-account/sporalize-application-storage
|
| 3 |
+
HF_TOKEN=
|
| 4 |
+
ANALYSIS_PROXY_SECRET=
|
| 5 |
+
BACKEND_URL=http://localhost:4000
|
.gitignore
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
__pycache__/
|
| 3 |
+
.venv/
|
Dockerfile
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 HF_HOME=/home/appuser/.cache/huggingface
|
| 3 |
+
RUN apt-get update && apt-get install -y --no-install-recommends libglib2.0-0 libgl1 && rm -rf /var/lib/apt/lists/*
|
| 4 |
+
RUN useradd --create-home --uid 1000 appuser
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
COPY requirements.txt .
|
| 7 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
+
COPY --chown=appuser:appuser app.py .
|
| 9 |
+
USER appuser
|
| 10 |
+
EXPOSE 7860
|
| 11 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
|
README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Sporalize AI API
|
| 3 |
+
sdk: docker
|
| 4 |
+
app_port: 7860
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
# Sporalize AI API
|
| 8 |
+
|
| 9 |
+
Deploy `AI/API` as a public Docker Space and `AI/Core` as a private dataset. The API loads `pipeline.py` and weights from Core at startup. The public service is accessible only through the Node backend using `ANALYSIS_PROXY_SECRET`; the health endpoint is public. Supabase credentials stay in Node.
|
| 10 |
+
|
| 11 |
+
Copy `.env.example` to `.env` for local use. Set `HF_TOKEN` with read access to Core and write access to a new private storage dataset. Set `BACKEND_URL` and the same `ANALYSIS_PROXY_SECRET` as Node. `CORE_REVISION` can pin the private Core dataset revision.
|
| 12 |
+
|
| 13 |
+
Model settings are constants near the top of `app.py`: `POSE_MODEL_FILE`, `POSE_MODEL_REPO_ID`, `YOLO_MODEL_FILE`, `YOLO_MODEL_REPO_ID`, and `YOLO_IMGSZ`. Change those values to select weights or detector input size. Video uploads do not have an application-level size limit.
|
| 14 |
+
|
| 15 |
+
The storage dataset contains only the pipeline's `results.json` and source videos under `sessions/<session UUID>/`. Calibration and action input files are temporary. `results.json` is the direct pipeline return after NumPy values are serialized and non-finite values become `null`.
|
| 16 |
+
|
| 17 |
+
`POST /api/analyze` accepts `playerId`, `clientId`, `videoOrders`, either uploaded `videos` or `videoDriveLinks`, `actionsJson`, `calibration`, and optional `sessionName`. Drive videos must be publicly downloadable. The service asks Node to authorize/create the session, saves results and videos to the HF dataset, then sends the path and action frame/FPS details to Node. Node owns the database and stores frame indices and FPS.
|
| 18 |
+
|
| 19 |
+
The authorized session endpoint adapts stored reports to the Results screen shape and provides private video URLs. Other endpoints include `/api/progress/{clientId}`, `/api/active-analyses`, `/api/cancel/{clientId}`, and `/api/retry-save/{clientId}`. All except `/healthz` require trusted proxy headers. Run one process because jobs and retry payloads are held in memory and do not survive a restart.
|
| 20 |
+
|
| 21 |
+
Apply `../../Database/migrations/20261016_node_analysis.sql` in Supabase. The older `20261016_analysis_persistence.sql` migration is not used.
|
app.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hmac
|
| 2 |
+
import hashlib
|
| 3 |
+
import importlib.util
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import math
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import shutil
|
| 10 |
+
import tempfile
|
| 11 |
+
import time
|
| 12 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 13 |
+
from contextlib import asynccontextmanager
|
| 14 |
+
from datetime import datetime, timezone
|
| 15 |
+
from functools import lru_cache
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from urllib.request import urlretrieve
|
| 18 |
+
from uuid import UUID, uuid5
|
| 19 |
+
from zipfile import ZipFile
|
| 20 |
+
|
| 21 |
+
import httpx
|
| 22 |
+
import numpy as np
|
| 23 |
+
from dotenv import load_dotenv
|
| 24 |
+
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
| 25 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 26 |
+
from huggingface_hub import HfApi, hf_hub_download, snapshot_download
|
| 27 |
+
|
| 28 |
+
load_dotenv(Path(__file__).with_name(".env"))
|
| 29 |
+
jobs = {}
|
| 30 |
+
executor = ThreadPoolExecutor(max_workers=1)
|
| 31 |
+
logger = logging.getLogger("sporalize")
|
| 32 |
+
POSE_MODEL_FILE = "Weights/rtmpose-x-halpe26.onnx"
|
| 33 |
+
POSE_MODEL_REPO_ID = ""
|
| 34 |
+
POSE_MODEL_HUB_FILE = "rtmpose-x-halpe26.onnx"
|
| 35 |
+
POSE_MODEL_REPO_TYPE = "model"
|
| 36 |
+
YOLO_MODEL_FILE = "Weights/yolo11x.pt"
|
| 37 |
+
YOLO_MODEL_REPO_ID = "Ultralytics/YOLO11"
|
| 38 |
+
YOLO_MODEL_HUB_FILE = "yolo11x.pt"
|
| 39 |
+
YOLO_MODEL_REPO_TYPE = "model"
|
| 40 |
+
YOLO_IMGSZ = 896
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def hub():
|
| 44 |
+
return HfApi(token=os.environ["HF_TOKEN"])
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def backend(path, user_id, method="GET", body=None):
|
| 48 |
+
for attempt in range(3):
|
| 49 |
+
try:
|
| 50 |
+
response = httpx.request(method, f'{os.environ["BACKEND_URL"].rstrip("/")}/internal/analysis{path}',
|
| 51 |
+
json=body, timeout=60, headers={"x-sporalize-proxy-secret": os.environ["ANALYSIS_PROXY_SECRET"], "x-sporalize-user-id": user_id})
|
| 52 |
+
response.raise_for_status()
|
| 53 |
+
return response.json() if response.content else None
|
| 54 |
+
except (httpx.TransportError, httpx.HTTPStatusError) as error:
|
| 55 |
+
if attempt == 2 or isinstance(error, httpx.HTTPStatusError) and error.response.status_code < 500:
|
| 56 |
+
raise
|
| 57 |
+
time.sleep(2 ** attempt)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@lru_cache(maxsize=1)
|
| 61 |
+
def runtime():
|
| 62 |
+
root = Path(snapshot_download(repo_id=os.environ["CORE_REPO_ID"], repo_type="dataset",
|
| 63 |
+
revision=os.environ.get("CORE_REVISION"), token=os.environ["HF_TOKEN"],
|
| 64 |
+
allow_patterns=["pipeline.py", "Weights/*"]))
|
| 65 |
+
spec = importlib.util.spec_from_file_location("sporalize_core", root / "pipeline.py")
|
| 66 |
+
module = importlib.util.module_from_spec(spec)
|
| 67 |
+
spec.loader.exec_module(module)
|
| 68 |
+
pose_file = POSE_MODEL_FILE
|
| 69 |
+
yolo_file = YOLO_MODEL_FILE
|
| 70 |
+
pose = (root / pose_file).resolve()
|
| 71 |
+
yolo = (root / yolo_file).resolve()
|
| 72 |
+
if root.resolve() not in pose.parents or root.resolve() not in yolo.parents:
|
| 73 |
+
raise RuntimeError("Model files must be inside the Core repository")
|
| 74 |
+
if not pose.is_file():
|
| 75 |
+
cache = Path(os.environ.get("HF_HOME", str(Path.home() / ".cache/huggingface"))) / "sporalize"
|
| 76 |
+
cache.mkdir(parents=True, exist_ok=True)
|
| 77 |
+
if not (cache / Path(pose_file).name).is_file():
|
| 78 |
+
if POSE_MODEL_REPO_ID:
|
| 79 |
+
downloaded = hf_hub_download(POSE_MODEL_REPO_ID, POSE_MODEL_HUB_FILE,
|
| 80 |
+
repo_type=POSE_MODEL_REPO_TYPE, token=os.environ["HF_TOKEN"])
|
| 81 |
+
shutil.copyfile(downloaded, cache / Path(pose_file).name)
|
| 82 |
+
elif pose_file == "Weights/rtmpose-x-halpe26.onnx":
|
| 83 |
+
with tempfile.TemporaryDirectory() as temporary:
|
| 84 |
+
archive = Path(temporary) / "pose.zip"
|
| 85 |
+
urlretrieve("https://download.openmmlab.com/mmpose/v1/projects/rtmposev1/onnx_sdk/rtmpose-x_simcc-body7_pt-body7-halpe26_700e-384x288-7fb6e239_20230606.zip", archive)
|
| 86 |
+
with ZipFile(archive) as bundle:
|
| 87 |
+
member = next(name for name in bundle.namelist() if name.endswith("end2end.onnx"))
|
| 88 |
+
with bundle.open(member) as source, (cache / Path(pose_file).name).open("wb") as target:
|
| 89 |
+
shutil.copyfileobj(source, target)
|
| 90 |
+
else:
|
| 91 |
+
raise RuntimeError("Configured pose model is missing from Core and no pose model repository was configured")
|
| 92 |
+
pose = cache / Path(pose_file).name
|
| 93 |
+
if not yolo.is_file():
|
| 94 |
+
yolo = Path(hf_hub_download(YOLO_MODEL_REPO_ID, YOLO_MODEL_HUB_FILE,
|
| 95 |
+
repo_type=YOLO_MODEL_REPO_TYPE, token=os.environ["HF_TOKEN"]))
|
| 96 |
+
return module.run_pipeline, {"POSE_PATH": str(pose), "YOLO_PATH": str(yolo)}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@asynccontextmanager
|
| 100 |
+
async def lifespan(app):
|
| 101 |
+
for name in ("CORE_REPO_ID", "STORAGE_REPO_ID", "HF_TOKEN", "BACKEND_URL"):
|
| 102 |
+
if not os.environ.get(name):
|
| 103 |
+
raise RuntimeError(f"{name} is required")
|
| 104 |
+
if len(os.environ.get("ANALYSIS_PROXY_SECRET", "")) < 32:
|
| 105 |
+
raise RuntimeError("ANALYSIS_PROXY_SECRET must contain at least 32 characters")
|
| 106 |
+
if os.environ["STORAGE_REPO_ID"].lower() == "shoraky/sporalizelabs-storage":
|
| 107 |
+
raise RuntimeError("Use a separate storage dataset for this application")
|
| 108 |
+
if not 32 <= len(os.environ["ANALYSIS_PROXY_SECRET"]) <= 4096:
|
| 109 |
+
raise RuntimeError("ANALYSIS_PROXY_SECRET length is invalid")
|
| 110 |
+
runtime()
|
| 111 |
+
yield
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
app = FastAPI(title="Sporalize AI", lifespan=lifespan)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@app.exception_handler(httpx.HTTPStatusError)
|
| 118 |
+
async def backend_error(request: Request, error: httpx.HTTPStatusError):
|
| 119 |
+
status = error.response.status_code
|
| 120 |
+
return JSONResponse({"detail": "Backend request failed"}, status_code=status if status < 500 else 502)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@app.middleware("http")
|
| 124 |
+
async def authenticate(request: Request, call_next):
|
| 125 |
+
if request.url.path != "/healthz":
|
| 126 |
+
secret = os.environ.get("ANALYSIS_PROXY_SECRET", "")
|
| 127 |
+
if len(secret) < 32 or not hmac.compare_digest(secret, request.headers.get("x-sporalize-proxy-secret", "")):
|
| 128 |
+
return JSONResponse({"detail": "Unauthorized"}, status_code=403)
|
| 129 |
+
try:
|
| 130 |
+
request.state.user_id = str(UUID(request.headers.get("x-sporalize-user-id", "")))
|
| 131 |
+
except ValueError:
|
| 132 |
+
return JSONResponse({"detail": "Invalid user identity"}, status_code=403)
|
| 133 |
+
return await call_next(request)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def json_value(value):
|
| 137 |
+
if isinstance(value, np.ndarray):
|
| 138 |
+
return json_value(value.tolist())
|
| 139 |
+
if isinstance(value, np.generic):
|
| 140 |
+
return json_value(value.item())
|
| 141 |
+
if isinstance(value, dict):
|
| 142 |
+
return {str(key): json_value(item) for key, item in value.items()}
|
| 143 |
+
if isinstance(value, (list, tuple)):
|
| 144 |
+
return [json_value(item) for item in value]
|
| 145 |
+
if isinstance(value, float) and not math.isfinite(value):
|
| 146 |
+
return None
|
| 147 |
+
return value
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def drive_file_id(link):
|
| 151 |
+
value = link.strip()
|
| 152 |
+
if re.fullmatch(r"[A-Za-z0-9_-]{10,}", value):
|
| 153 |
+
return value
|
| 154 |
+
if not any(host in value for host in ("drive.google.com", "drive.usercontent.google.com")):
|
| 155 |
+
raise ValueError("Not a Google Drive file link")
|
| 156 |
+
match = re.search(r"/file/d/([A-Za-z0-9_-]{10,})|/d/([A-Za-z0-9_-]{10,})|[?&]id=([A-Za-z0-9_-]{10,})", value)
|
| 157 |
+
if not match:
|
| 158 |
+
raise ValueError("Could not find a Google Drive file ID")
|
| 159 |
+
return next(group for group in match.groups() if group)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
async def download_drive_file(link, destination):
|
| 163 |
+
file_id = drive_file_id(link)
|
| 164 |
+
url = f"https://drive.google.com/uc?export=download&confirm=t&id={file_id}"
|
| 165 |
+
total = 0
|
| 166 |
+
async with httpx.AsyncClient(follow_redirects=True, timeout=300) as client:
|
| 167 |
+
async with client.stream("GET", url) as response:
|
| 168 |
+
response.raise_for_status()
|
| 169 |
+
content_type = response.headers.get("content-type", "").lower()
|
| 170 |
+
if "text/html" in content_type:
|
| 171 |
+
raise ValueError("Google Drive did not return a public video; check link access")
|
| 172 |
+
with destination.open("wb") as output:
|
| 173 |
+
async for chunk in response.aiter_bytes(1024 * 1024):
|
| 174 |
+
total += len(chunk)
|
| 175 |
+
output.write(chunk)
|
| 176 |
+
if total == 0:
|
| 177 |
+
raise ValueError("Google Drive returned an empty file")
|
| 178 |
+
return file_id, total
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def run_job(client_id, work, cameras):
|
| 182 |
+
job = jobs[client_id]
|
| 183 |
+
session_id = job["sessionId"]
|
| 184 |
+
saved = False
|
| 185 |
+
try:
|
| 186 |
+
if job.get("cancel"):
|
| 187 |
+
raise InterruptedError()
|
| 188 |
+
job.update(status="running", progress=5, phase="Analyzing")
|
| 189 |
+
pipeline, weights = runtime()
|
| 190 |
+
|
| 191 |
+
def progress(action, total, frame, frames):
|
| 192 |
+
job.update(progress=5 + 85 * (action + (frame + 1) / max(frames, 1)) / max(total, 1),
|
| 193 |
+
updatedAt=int(time.time() * 1000))
|
| 194 |
+
return not job.get("cancel")
|
| 195 |
+
|
| 196 |
+
reports = pipeline(cameras, {**weights, "CALIBRATION_PATH": str(work / "calibration.npz"), "ACTIONS_PATH": str(work / "actions.json")}, {"YOLO_IMGSZ": YOLO_IMGSZ}, progress)
|
| 197 |
+
if job.get("cancel"):
|
| 198 |
+
raise InterruptedError()
|
| 199 |
+
(work / "results.json").write_text(json.dumps(json_value(reports), allow_nan=False), encoding="utf-8")
|
| 200 |
+
job.update(progress=95, phase="Saving")
|
| 201 |
+
hub().upload_folder(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
|
| 202 |
+
folder_path=str(work), path_in_repo=f"sessions/{session_id}",
|
| 203 |
+
allow_patterns=["results.json", "videos/*"])
|
| 204 |
+
saved = True
|
| 205 |
+
completion = {"status": "completed", "path": f"sessions/{session_id}", "actions": [
|
| 206 |
+
{"index": index, **{key: json_value(report[key]) for key in ("action", "start_frame", "end_frame", "fps", "error") if key in report}}
|
| 207 |
+
for index, report in enumerate(reports)]}
|
| 208 |
+
job["completion"] = completion
|
| 209 |
+
backend(f"/{session_id}", job["userId"], "PUT", completion)
|
| 210 |
+
job.pop("completion", None)
|
| 211 |
+
job.update(status="completed", phase="Completed", progress=100, resultUrl=f"/api/sessions/{session_id}")
|
| 212 |
+
except Exception as error:
|
| 213 |
+
status = "cancelled" if isinstance(error, InterruptedError) else "failed"
|
| 214 |
+
job.update(status=status, phase="Sync failed" if saved else status.capitalize(), error="Files saved; retry backend sync" if saved else "Analysis cancelled" if status == "cancelled" else "Analysis or saving failed")
|
| 215 |
+
if saved:
|
| 216 |
+
job["status"] = "failed"
|
| 217 |
+
logger.exception("Session %s %s", session_id, status)
|
| 218 |
+
try:
|
| 219 |
+
if not saved:
|
| 220 |
+
backend(f"/{session_id}", job["userId"], "PUT", {"status": status})
|
| 221 |
+
except Exception:
|
| 222 |
+
logger.exception("Could not update session %s", session_id)
|
| 223 |
+
finally:
|
| 224 |
+
job["updatedAt"] = int(time.time() * 1000)
|
| 225 |
+
shutil.rmtree(work, ignore_errors=True)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
@app.get("/healthz")
|
| 229 |
+
def health():
|
| 230 |
+
return {"ok": True}
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
@app.post("/api/drive/probe")
|
| 234 |
+
def probe_drive(payload: dict):
|
| 235 |
+
links = payload.get("links") or ([payload["link"]] if payload.get("link") else [])
|
| 236 |
+
if not isinstance(links, list) or not 1 <= len(links) <= 16:
|
| 237 |
+
raise HTTPException(400, "Provide between 1 and 16 Drive links")
|
| 238 |
+
results = []
|
| 239 |
+
for link in links:
|
| 240 |
+
result = {"link": str(link)}
|
| 241 |
+
try:
|
| 242 |
+
file_id = drive_file_id(str(link))
|
| 243 |
+
with httpx.stream("GET", f"https://drive.google.com/uc?export=download&confirm=t&id={file_id}", follow_redirects=True, timeout=30) as response:
|
| 244 |
+
content_type = response.headers.get("content-type", "")
|
| 245 |
+
size = int(response.headers.get("content-length", "0") or 0)
|
| 246 |
+
if not response.is_success or "text/html" in content_type.lower():
|
| 247 |
+
raise ValueError("File is not publicly downloadable")
|
| 248 |
+
result.update(ok=True, fileId=file_id, fileName=f"{file_id}.mp4", sizeBytes=size or None, contentType=content_type)
|
| 249 |
+
except Exception as error:
|
| 250 |
+
result.update(ok=False, code="unavailable", error=str(error))
|
| 251 |
+
results.append(result)
|
| 252 |
+
return {"results": results}
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@app.post("/api/analyze", status_code=202)
|
| 256 |
+
async def analyze(request: Request, playerId: str = Form(...), clientId: UUID = Form(...),
|
| 257 |
+
videoOrders: list[int] = Form(...), videos: list[UploadFile] = File(None),
|
| 258 |
+
videoDriveLinks: list[str] = Form(None),
|
| 259 |
+
actionsJson: UploadFile = File(...), calibration: UploadFile = File(...),
|
| 260 |
+
sessionName: str = Form("")):
|
| 261 |
+
videos = videos or []
|
| 262 |
+
videoDriveLinks = videoDriveLinks or []
|
| 263 |
+
if videos and videoDriveLinks or len(videos or videoDriveLinks) < 2 or len(videos or videoDriveLinks) != len(videoOrders) or len(set(videoOrders)) != len(videoOrders):
|
| 264 |
+
raise HTTPException(422, "Provide at least two videos with unique camera orders")
|
| 265 |
+
client_id, user_id = str(clientId), request.state.user_id
|
| 266 |
+
if client_id in jobs:
|
| 267 |
+
raise HTTPException(409, "Analysis already exists")
|
| 268 |
+
reference = playerId.strip()
|
| 269 |
+
if not reference or len(reference) > 100 or len(sessionName) > 150:
|
| 270 |
+
raise HTTPException(422, "Invalid player reference or session name")
|
| 271 |
+
session = backend("", user_id, "POST", {"clientId": client_id, "playerId": reference, "name": sessionName})
|
| 272 |
+
session_id, player_id = session["session_id"], session["player_id"]
|
| 273 |
+
now = int(time.time() * 1000)
|
| 274 |
+
jobs[client_id] = {"clientId": client_id, "sessionId": session_id, "playerId": player_id,
|
| 275 |
+
"userId": user_id, "status": "uploading", "phase": "Uploading", "progress": 0, "createdAt": now, "updatedAt": now}
|
| 276 |
+
work = Path(tempfile.mkdtemp(prefix="sporalize-"))
|
| 277 |
+
try:
|
| 278 |
+
(work / "videos").mkdir()
|
| 279 |
+
cameras = {}
|
| 280 |
+
uploads = [(actionsJson, work / "actions.json"), (calibration, work / "calibration.npz")]
|
| 281 |
+
video_names = []
|
| 282 |
+
sources = list(zip(videoOrders, videos)) if videos else list(zip(videoOrders, videoDriveLinks))
|
| 283 |
+
for order, source in sources:
|
| 284 |
+
if videos:
|
| 285 |
+
video = source
|
| 286 |
+
suffix = Path(video.filename or "video.mp4").suffix.lower()
|
| 287 |
+
else:
|
| 288 |
+
video = None
|
| 289 |
+
suffix = ".mp4"
|
| 290 |
+
if suffix not in {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"}:
|
| 291 |
+
raise HTTPException(422, "Unsupported video format")
|
| 292 |
+
target = work / "videos" / f"camera_{order}{suffix}"
|
| 293 |
+
video_names.append(target.name)
|
| 294 |
+
cameras[order] = str(target)
|
| 295 |
+
if video is not None:
|
| 296 |
+
uploads.append((video, target))
|
| 297 |
+
else:
|
| 298 |
+
await download_drive_file(source, target)
|
| 299 |
+
for upload, target in uploads:
|
| 300 |
+
with target.open("wb") as output:
|
| 301 |
+
while chunk := await upload.read(1024 * 1024):
|
| 302 |
+
output.write(chunk)
|
| 303 |
+
jobs[client_id].update(status="queued", phase="Queued")
|
| 304 |
+
jobs[client_id]["videoNames"] = video_names
|
| 305 |
+
executor.submit(run_job, client_id, work, cameras)
|
| 306 |
+
return {"accepted": True, "status": "queued", "clientId": client_id, "sessionId": session_id}
|
| 307 |
+
except Exception:
|
| 308 |
+
jobs[client_id].update(status="failed", phase="Failed")
|
| 309 |
+
shutil.rmtree(work)
|
| 310 |
+
try:
|
| 311 |
+
backend(f"/{session_id}", user_id, "PUT", {"status": "failed"})
|
| 312 |
+
except Exception:
|
| 313 |
+
logger.exception("Could not report failed upload %s", session_id)
|
| 314 |
+
raise
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def owned_job(client_id, user_id):
|
| 318 |
+
job = jobs.get(client_id)
|
| 319 |
+
if not job or job["userId"] != user_id:
|
| 320 |
+
raise HTTPException(404, "Analysis not found")
|
| 321 |
+
return job
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
@app.get("/api/progress/{client_id}")
|
| 325 |
+
def progress(client_id: str, request: Request):
|
| 326 |
+
return {key: value for key, value in owned_job(client_id, request.state.user_id).copy().items() if key not in {"userId", "cancel", "completion"}}
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
@app.post("/api/retry-save/{client_id}")
|
| 330 |
+
def retry_save(client_id: str, request: Request):
|
| 331 |
+
job = owned_job(client_id, request.state.user_id)
|
| 332 |
+
if job.get("completion"):
|
| 333 |
+
backend(f'/{job["sessionId"]}', job["userId"], "PUT", job["completion"])
|
| 334 |
+
job.pop("completion", None)
|
| 335 |
+
job.update(status="completed", phase="Completed", progress=100, resultUrl=f'/api/sessions/{job["sessionId"]}')
|
| 336 |
+
return progress(client_id, request)
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
@app.get("/api/active-analyses")
|
| 340 |
+
def active(request: Request):
|
| 341 |
+
return {"jobs": [progress(key, request) for key, job in list(jobs.items()) if job["userId"] == request.state.user_id and job["status"] in {"uploading", "queued", "running"}]}
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
@app.post("/api/cancel/{client_id}")
|
| 345 |
+
def cancel(client_id: str, request: Request):
|
| 346 |
+
job = owned_job(client_id, request.state.user_id)
|
| 347 |
+
if job["status"] not in {"completed", "failed", "cancelled", "sync_failed"}:
|
| 348 |
+
job["cancel"] = True
|
| 349 |
+
return {"status": job["status"], "clientId": client_id}
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def session_row(session_id, user_id):
|
| 353 |
+
row = backend(f"/{session_id}", user_id)
|
| 354 |
+
if row["path"] != f"sessions/{session_id}":
|
| 355 |
+
raise HTTPException(409, "Session uses a different storage layout")
|
| 356 |
+
return row
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
@app.get("/api/sessions/{session_id}")
|
| 360 |
+
def results(session_id: UUID, request: Request):
|
| 361 |
+
row = session_row(session_id, request.state.user_id)
|
| 362 |
+
path = hf_hub_download(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
|
| 363 |
+
filename=f'{row["path"]}/results.json', token=os.environ["HF_TOKEN"])
|
| 364 |
+
reports = json.loads(Path(path).read_text(encoding="utf-8"))
|
| 365 |
+
prefix = f'{row["path"]}/videos/camera_'
|
| 366 |
+
camera_files = sorted({name[len(prefix):] for name in hub().list_repo_files(
|
| 367 |
+
repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset") if name.startswith(prefix)})
|
| 368 |
+
fps_values = [float(report.get("fps") or 30) for report in reports if report.get("fps")]
|
| 369 |
+
actions, failed = [], []
|
| 370 |
+
for index, report in enumerate(reports):
|
| 371 |
+
start = int(report.get("start_frame") or 0)
|
| 372 |
+
end = int(report.get("end_frame") or start)
|
| 373 |
+
action_fps = float(report.get("fps") or (fps_values[0] if fps_values else 30))
|
| 374 |
+
label = report.get("action", "Unknown")
|
| 375 |
+
if report.get("error"):
|
| 376 |
+
failed.append({"id": str(uuid5(session_id, str(index))), "label": label, "start": str(start), "end": str(end), "error": report["error"]})
|
| 377 |
+
continue
|
| 378 |
+
analytics = report.get("analytics") or {}
|
| 379 |
+
metrics = [{"name": key.replace("_", " ").title(), "value": value, "unit": ""} for key, value in analytics.items() if isinstance(value, (int, float, str, bool)) or value is None]
|
| 380 |
+
clips = [{"cameraId": int(name.split("_", 1)[1].split(".", 1)[0]), "url": f"/storage/{session_id}/videos/{name}"} for name in camera_files]
|
| 381 |
+
digest = hashlib.sha256(f"{session_id}:{index}".encode()).hexdigest()
|
| 382 |
+
action_id = f"{digest[:8]}-{digest[8:12]}-5{digest[13:16]}-8{digest[17:20]}-{digest[20:32]}"
|
| 383 |
+
actions.append({"id": action_id, "label": label,
|
| 384 |
+
"start": str(start), "end": str(end), "fps": action_fps,
|
| 385 |
+
"startFrame": start, "endFrame": end, "startSeconds": start / action_fps,
|
| 386 |
+
"endSeconds": (end + 1) / action_fps, "totalFrames": max(0, end - start + 1),
|
| 387 |
+
"preFrames": 0, "inFrame": max(0, (end - start) // 2), "postFrames": 0,
|
| 388 |
+
"cameraClips": clips, "sourceCameraClips": [], "preMetrics": [],
|
| 389 |
+
"preActionMetrics": [], "inActionMetrics": metrics, "postMetrics": [],
|
| 390 |
+
"postActionMetrics": [], "fullIntervalMetrics": [],
|
| 391 |
+
"skeleton": report.get("skeleton_history", []), "rawAnalytics": analytics})
|
| 392 |
+
created = row.get("created_at")
|
| 393 |
+
created_at = datetime.fromisoformat(created.replace("Z", "+00:00")) if created else datetime.now(timezone.utc)
|
| 394 |
+
if created_at.tzinfo is None:
|
| 395 |
+
created_at = created_at.replace(tzinfo=timezone.utc)
|
| 396 |
+
return {"id": str(session_id), "playerId": row["player_id"], "playerName": "",
|
| 397 |
+
"name": row.get("name", "Analysis session"),
|
| 398 |
+
"createdAt": int(created_at.timestamp() * 1000),
|
| 399 |
+
"targetSize": [0, 0], "cameraCount": len(camera_files), "actions": actions, "failedActions": failed}
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
@app.api_route("/storage/{session_id}/videos/{filename}", methods=["GET", "HEAD"])
|
| 403 |
+
def video(session_id: UUID, filename: str, request: Request):
|
| 404 |
+
row = session_row(session_id, request.state.user_id)
|
| 405 |
+
if Path(filename).name != filename or "\\" in filename:
|
| 406 |
+
raise HTTPException(404, "Video not found")
|
| 407 |
+
path = hf_hub_download(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
|
| 408 |
+
filename=f'{row["path"]}/videos/{filename}', token=os.environ["HF_TOKEN"])
|
| 409 |
+
return FileResponse(path)
|
requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.0
|
| 2 |
+
uvicorn>=0.30.0
|
| 3 |
+
python-multipart>=0.0.9
|
| 4 |
+
python-dotenv>=1.0.0
|
| 5 |
+
huggingface_hub>=0.34.0
|
| 6 |
+
httpx>=0.27.0
|
| 7 |
+
numpy
|
| 8 |
+
scipy
|
| 9 |
+
opencv-python-headless
|
| 10 |
+
onnxruntime
|
| 11 |
+
rtmlib>=0.0.14
|
| 12 |
+
torch
|
| 13 |
+
ultralytics
|
| 14 |
+
lap>=0.5.12
|