icanedit2 commited on
Commit
32bc095
·
0 Parent(s):

Deploy backend to HF with player video visibility fix

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +380 -0
  2. .gitattributes +9 -0
  3. .gitignore +62 -0
  4. DEPLOYMENT.md +39 -0
  5. Dockerfile +49 -0
  6. INDEPENDENT_MODELS_MIGRATION.md +91 -0
  7. QUICK_REFERENCE.md +228 -0
  8. README.md +209 -0
  9. SETUP_COMPLETE.md +268 -0
  10. SHOT_DETECTION.md +315 -0
  11. SHOT_IMPLEMENTATION_SUMMARY.md +230 -0
  12. TEAM_ANALYSIS_OUTPUTS.md +56 -0
  13. __init__.py +1 -0
  14. analysis/__init__.py +12 -0
  15. analysis/dispatcher.py +82 -0
  16. analysis/personal_analysis.py +559 -0
  17. analysis/skill_diagnostic.py +145 -0
  18. analysis/team_analysis.py +307 -0
  19. analysis/team_analysis_old.py +545 -0
  20. analytics_engine/__init__.py +26 -0
  21. analytics_engine/base.py +90 -0
  22. analytics_engine/clip_generator.py +260 -0
  23. analytics_engine/coordinator.py +261 -0
  24. analytics_engine/decision_quality.py +173 -0
  25. analytics_engine/defensive_reaction.py +273 -0
  26. analytics_engine/fatigue_tracker.py +228 -0
  27. analytics_engine/lineup_impact.py +261 -0
  28. analytics_engine/spacing_engine.py +192 -0
  29. analytics_engine/transition_effort.py +234 -0
  30. app/__init__.py +4 -0
  31. app/api/admin.py +1158 -0
  32. app/api/advanced_analytics.py +405 -0
  33. app/api/analysis.py +689 -0
  34. app/api/analytics.py +402 -0
  35. app/api/auth.py +382 -0
  36. app/api/communications.py +147 -0
  37. app/api/personal_analysis.py +343 -0
  38. app/api/player_routes.py +679 -0
  39. app/api/players.py +279 -0
  40. app/api/stat_import.py +77 -0
  41. app/api/teams.py +211 -0
  42. app/api/videos.py +614 -0
  43. app/config.py +94 -0
  44. app/core/__init__.py +78 -0
  45. app/core/security.py +106 -0
  46. app/dependencies.py +202 -0
  47. app/main.py +272 -0
  48. app/middleware/__init__.py +1 -0
  49. app/middleware/timeout.py +20 -0
  50. app/models/__init__.py +105 -0
.env.example ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Supabase configuration
2
+ SUPABASE_URL=your_supabase_url_here
3
+ SUPABASE_KEY=your_supabase_anon_key_here
4
+ SUPABASE_SERVICE_KEY=your_supabase_service_role_key_here
5
+
6
+ # JWT Configuration
7
+ JWT_SECRET=your_jwt_secret_here
8
+ JWT_ALGORITHM=HS256
9
+ JWT_EXPIRATION_MINUTES=1440
10
+
11
+ # Application settings
12
+ DEBUG=false
13
+ HOST=0.0.0.0
14
+ PORT=8000
15
+ REQUEST_TIMEOUT_SECONDS=120
16
+
17
+ # CORS (comma-separated list). Example:
18
+ # CORS_ORIGINS=http://localhost:5173,http://localhost:3000,https://yourapp.com
19
+ CORS_ORIGINS=
20
+
21
+ # File storage
22
+ UPLOAD_DIR=./uploads
23
+ MAX_UPLOAD_SIZE_MB=500
24
+ SERVE_UPLOADS_IN_DEBUG=true
25
+
26
+ # GPU settings
27
+ GPU_ENABLED=true
28
+ CUDA_DEVICE=0
29
+
30
+ # Model paths
31
+ PLAYER_DETECTOR_PATH=models/player_detector.pt
32
+ BALL_DETECTOR_PATH=models/ball_detector_model.pt
33
+ COURT_KEYPOINT_DETECTOR_PATH=models/court_keypoint_detector.pt
34
+ POSE_MODEL_PATH=models/yolov8n-pose.pt
35
+ # Train simple CNN model
36
+
37
+
38
+ What matters is not just that the model predicts 18 keypoints, but that your backend has a canonical meaning for each index so the system always knows:
39
+
40
+ which side of the court the point belongs to
41
+
42
+ whether it is boundary, paint, or midline
43
+
44
+ where it should land on the tactical map
45
+
46
+ how to recover if some points are missing or swapped
47
+
48
+ From your new model:
49
+
50
+ 0 = far left, top boundary
51
+
52
+ 10 = far right, top boundary
53
+
54
+ 5 = far left, bottom boundary
55
+
56
+ 15 = far right, bottom boundary
57
+
58
+ 8 and 9 = centre line top/bottom
59
+
60
+ 2, 3, 6, 7 = left paint box
61
+
62
+ 12, 13, 16, 17 = right paint box
63
+
64
+ 1 and 4 = left baseline inner markers
65
+
66
+ 11 and 14 = right baseline inner markers
67
+
68
+ So the first thing is: your code must stop treating these as just “18 anonymous points”. It should treat them as a court geometry schema.
69
+
70
+ Recommended canonical keypoint meaning
71
+
72
+ Use this exact mapping in code:
73
+
74
+ COURT_KEYPOINTS = {
75
+ 0: "left_outer_top",
76
+ 1: "left_baseline_upper_inner",
77
+ 2: "left_paint_outer_top",
78
+ 3: "left_paint_outer_bottom",
79
+ 4: "left_baseline_lower_inner",
80
+ 5: "left_outer_bottom",
81
+
82
+ 6: "left_paint_inner_top",
83
+ 7: "left_paint_inner_bottom",
84
+
85
+ 8: "midline_top",
86
+ 9: "midline_bottom",
87
+
88
+ 10: "right_outer_top",
89
+ 11: "right_baseline_upper_inner",
90
+ 12: "right_paint_outer_top",
91
+ 13: "right_paint_outer_bottom",
92
+ 14: "right_baseline_lower_inner",
93
+ 15: "right_outer_bottom",
94
+
95
+ 16: "right_paint_inner_top",
96
+ 17: "right_paint_inner_bottom",
97
+ }
98
+
99
+ That gives the model output real meaning.
100
+
101
+ Very important logic rule
102
+
103
+ Your system should always think in two spaces:
104
+
105
+ 1. Image space
106
+
107
+ These are the predicted coordinates from the model:
108
+
109
+ [(x0, y0), (x1, y1), ..., (x17, y17)]
110
+ 2. Court space
111
+
112
+ These are the fixed coordinates on your tactical board.
113
+
114
+ For example:
115
+
116
+ COURT_MODEL_POINTS = {
117
+ 0: (0, 0),
118
+ 1: (0, 10),
119
+ 2: (0, 20),
120
+ 3: (0, 30),
121
+ 4: (0, 40),
122
+ 5: (0, 50),
123
+
124
+ 6: (15, 20),
125
+ 7: (15, 30),
126
+
127
+ 8: (47, 0),
128
+ 9: (47, 50),
129
+
130
+ 10: (94, 0),
131
+ 11: (94, 10),
132
+ 12: (94, 20),
133
+ 13: (94, 30),
134
+ 14: (94, 40),
135
+ 15: (94, 50),
136
+
137
+ 16: (79, 20),
138
+ 17: (79, 30),
139
+ }
140
+
141
+ These values are example tactical-map coordinates using your 94 × 50 style court.
142
+
143
+ Then your homography becomes:
144
+
145
+ src = predicted_image_points
146
+ dst = tactical_model_points
147
+ H, _ = cv2.findHomography(src, dst, cv2.RANSAC)
148
+
149
+ That is what makes player projection work.
150
+
151
+ The real refinement you need
152
+
153
+ Your current logic probably assumes:
154
+
155
+ all points are present
156
+
157
+ all points are in the correct order
158
+
159
+ no side confusion happens
160
+
161
+ That is risky.
162
+
163
+ You need 4 layers of logic:
164
+
165
+ 1. Semantic index map
166
+
167
+ Every index must have a fixed court meaning.
168
+
169
+ 2. Side validation
170
+
171
+ The system should check if left-side points are actually left of right-side points.
172
+
173
+ Example:
174
+
175
+ x(0) < x(8) < x(10)
176
+
177
+ x(5) < x(9) < x(15)
178
+
179
+ left paint should be left of midline
180
+
181
+ right paint should be right of midline
182
+
183
+ 3. Structural validation
184
+
185
+ Check the court shape is geometrically plausible.
186
+
187
+ Examples:
188
+
189
+ 0 and 10 should be roughly same y-level
190
+
191
+ 5 and 15 should be roughly same y-level
192
+
193
+ 8 should be above 9
194
+
195
+ 2 and 6 should be roughly same y-level
196
+
197
+ 3 and 7 should be roughly same y-level
198
+
199
+ 12 and 16 should be roughly same y-level
200
+
201
+ 13 and 17 should be roughly same y-level
202
+
203
+ 4. Missing-point recovery
204
+
205
+ If one or two points are low-confidence or absent, use the rest and continue.
206
+
207
+ Best mental grouping for the 18 points
208
+
209
+ This helps your code a lot.
210
+
211
+ Outer boundary
212
+ OUTER_CORNERS = [0, 5, 10, 15]
213
+ Midline
214
+ MIDLINE = [8, 9]
215
+ Left baseline vertical markers
216
+ LEFT_BASELINE_CHAIN = [0, 1, 2, 3, 4, 5]
217
+ Right baseline vertical markers
218
+ RIGHT_BASELINE_CHAIN = [10, 11, 12, 13, 14, 15]
219
+ Left paint box
220
+ LEFT_PAINT = [2, 6, 7, 3]
221
+ Right paint box
222
+ RIGHT_PAINT = [12, 16, 17, 13]
223
+
224
+ This lets you reason about the court as shapes, not isolated dots.
225
+
226
+ Adjacency logic you should encode
227
+
228
+ This is useful for drawing, validation, and debugging.
229
+
230
+ COURT_EDGES = [
231
+ (0, 8), (8, 10), # top boundary
232
+ (5, 9), (9, 15), # bottom boundary
233
+ (0, 1), (1, 2), (2, 3), (3, 4), (4, 5), # left side chain
234
+ (10, 11), (11, 12), (12, 13), (13, 14), (14, 15), # right side chain
235
+ (8, 9), # center line
236
+ (2, 6), (6, 7), (7, 3), # left paint
237
+ (12, 16), (16, 17), (17, 13), # right paint
238
+ ]
239
+
240
+ This makes your rendered court match the actual geometry in your Roboflow template.
241
+
242
+ What the model should “know”
243
+
244
+ The model itself does not truly know basketball meaning unless your logic gives it that meaning.
245
+
246
+ So after inference, do this:
247
+
248
+ Step 1: collect points with confidence
249
+ keypoints = [
250
+ {"id": 0, "x": x0, "y": y0, "conf": c0},
251
+ ...
252
+ ]
253
+ Step 2: attach semantics
254
+ for kp in keypoints:
255
+ kp["name"] = COURT_KEYPOINTS[kp["id"]]
256
+ kp["side"] = (
257
+ "left" if kp["id"] in [0,1,2,3,4,5,6,7]
258
+ else "right" if kp["id"] in [10,11,12,13,14,15,16,17]
259
+ else "center"
260
+ )
261
+ Step 3: validate court orientation
262
+
263
+ For example:
264
+
265
+ left average x must be less than centre average x
266
+
267
+ centre average x must be less than right average x
268
+
269
+ Step 4: build homography only from valid points
270
+ Strong recommendation: use named coordinates instead of raw index logic everywhere
271
+
272
+ Instead of this:
273
+
274
+ p0 = pts[0]
275
+ p1 = pts[1]
276
+ p2 = pts[2]
277
+
278
+ Do this:
279
+
280
+ named = {COURT_KEYPOINTS[i]: pts[i] for i in range(len(pts))}
281
+
282
+ left_outer_top = named["left_outer_top"]
283
+ midline_top = named["midline_top"]
284
+ right_outer_top = named["right_outer_top"]
285
+
286
+ That makes the whole pipeline easier to debug.
287
+
288
+ Suggested tactical coordinates for your exact new model
289
+
290
+ Here is a cleaner court-map layout you can start with:
291
+
292
+ TACTICAL_POINTS = {
293
+ 0: (0, 0),
294
+ 1: (0, 8),
295
+ 2: (0, 18),
296
+ 3: (0, 32),
297
+ 4: (0, 42),
298
+ 5: (0, 50),
299
+
300
+ 6: (18, 18),
301
+ 7: (18, 32),
302
+
303
+ 8: (47, 0),
304
+ 9: (47, 50),
305
+
306
+ 10: (94, 0),
307
+ 11: (94, 8),
308
+ 12: (94, 18),
309
+ 13: (94, 32),
310
+ 14: (94, 42),
311
+ 15: (94, 50),
312
+
313
+ 16: (76, 18),
314
+ 17: (76, 32),
315
+ }
316
+
317
+ You can tune these to match your SVG court exactly.
318
+
319
+ Validation function you should add
320
+
321
+ Something like this:
322
+
323
+ def validate_court_keypoints(points):
324
+ """
325
+ points: dict[int, tuple[float, float]]
326
+ """
327
+ errors = []
328
+
329
+ required = [0,5,8,9,10,15]
330
+ for idx in required:
331
+ if idx not in points:
332
+ errors.append(f"Missing critical point {idx}")
333
+
334
+ if all(k in points for k in [0, 8, 10]):
335
+ if not (points[0][0] < points[8][0] < points[10][0]):
336
+ errors.append("Top row x-order invalid")
337
+
338
+ if all(k in points for k in [5, 9, 15]):
339
+ if not (points[5][0] < points[9][0] < points[15][0]):
340
+ errors.append("Bottom row x-order invalid")
341
+
342
+ if all(k in points for k in [8, 9]):
343
+ if not (points[8][1] < points[9][1]):
344
+ errors.append("Midline top/bottom order invalid")
345
+
346
+ if all(k in points for k in [2, 6]):
347
+ if not (points[2][0] < points[6][0]):
348
+ errors.append("Left paint width invalid")
349
+
350
+ if all(k in points for k in [16, 12]):
351
+ if not (points[16][0] < points[12][0]):
352
+ errors.append("Right paint width invalid")
353
+
354
+ return errors
355
+
356
+ This alone will save you from many bad projections.
357
+
358
+ Best upgrade for your backend prompt / Antigravity
359
+
360
+ Tell it this:
361
+
362
+ The court keypoint model has 18 semantically fixed points. The backend must not treat them as anonymous ordered points only. It must maintain a canonical index-to-court-location mapping, validate court orientation and geometry after inference, and compute homography using only structurally valid keypoints. Missing or low-confidence points should be handled with graceful degradation, and all downstream tactical projection must rely on named semantic court points rather than raw positional assumptions.
363
+
364
+ In simple terms
365
+
366
+ Your new logic should understand:
367
+
368
+ 0–5 = left boundary column
369
+
370
+ 10–15 = right boundary column
371
+
372
+ 8 and 9 = centre line
373
+
374
+ 2,3,6,7 = left paint rectangle
375
+
376
+ 12,13,16,17 = right paint rectangle
377
+
378
+ So yes — your model can still work, but your code now needs a court-geometry interpretation layer, not just raw keypoint indexing.
379
+
380
+ I can write you the exact Python module for this next: a court_keypoints.py file with mappings, validation, homography preparation, and named-point conversion.
.gitattributes ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ *.png filter=xet diff=xet merge=xet
2
+ *.jpg filter=xet diff=xet merge=xet
3
+ *.jpeg filter=xet diff=xet merge=xet
4
+ *.gif filter=xet diff=xet merge=xet
5
+ *.pt filter=xet diff=xet merge=xet
6
+ *.pth filter=xet diff=xet merge=xet
7
+ *.weights filter=xet diff=xet merge=xet
8
+ *.png filter=lfs diff=lfs merge=lfs -text
9
+ *.pkl filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ build/
9
+ develop-eggs/
10
+ dist/
11
+ downloads/
12
+ eggs/
13
+ .eggs/
14
+ lib/
15
+ lib64/
16
+ parts/
17
+ sdist/
18
+ var/
19
+ wheels/
20
+ *.egg-info/
21
+ .installed.cfg
22
+ *.egg
23
+ venv/
24
+ ENV/
25
+
26
+ # Jupyter Notebook
27
+ .ipynb_checkpoints
28
+
29
+ # pyenv
30
+ .python-version
31
+
32
+ # celery beat schedule file
33
+ celerybeat-schedule
34
+
35
+ # dotenv
36
+ .env
37
+ .venv
38
+
39
+ # pytest
40
+ .pytest_cache/
41
+
42
+ # mypy
43
+ .mypy_cache/
44
+
45
+ # Project specific
46
+ models/*.pt
47
+ *.pt
48
+ input_videos/
49
+ output_videos/
50
+ uploads/
51
+ tmp/
52
+ analysis_log.txt
53
+ *.mp4
54
+ *.avi
55
+ *.mov
56
+
57
+ # IDEs
58
+ .vscode/
59
+ .idea/
60
+ .DS_Store
61
+ stubs/
62
+ *.pkl
DEPLOYMENT.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BAKO-AI Deployment Guide
2
+
3
+ This guide explains how to manage and deploy the BAKO-AI analysis pipeline to Hugging Face.
4
+
5
+ ## 🏗 Architecture Overview
6
+ - **Application**: Hosted on Hugging Face Spaces (`icanedit2/BakoAI`).
7
+ - **Models**: Hosted on a dedicated Hugging Face Model Repository (`icanedit2/bakoai-models`).
8
+ - **Storage/DB**: Managed via Supabase.
9
+
10
+ ## 🚀 Deployment Workflow
11
+
12
+ ### 1. Push Code Changes
13
+ To push local changes from the `back-end` folder to the Hugging Face Space root:
14
+ ```bash
15
+ # From the root directory:
16
+ git subtree split --prefix back-end -b hf-production-build
17
+ git push hf hf-production-build:main --force
18
+ git branch -D hf-production-build
19
+ ```
20
+
21
+ ### 🧠 2. Managing Models
22
+ Models are stored in the `icanedit2/bakoai-models` repository to prevent `git push` from deleting them.
23
+ - To add a new model, upload it to the `bakoai-models` repo.
24
+ - Update `download_models.py` in the Space repo to include the new filename.
25
+ - The build process will automatically download models into `/home/user/app/models/`.
26
+
27
+ ### 🔐 3. Security (Hugging Face Secrets)
28
+ Ensure the following Secrets are set in your Space Settings:
29
+ - `HF_TOKEN`: Required for downloading models from private repositories.
30
+ - `SUPABASE_URL`: Your Supabase project URL.
31
+ - `SUPABASE_KEY`: Your Supabase anonymous/service key.
32
+ - `JWT_SECRET`: For authentication.
33
+
34
+ ## 🎬 Video Playback Optimization
35
+ All annotated videos are processed through FFmpeg with the following settings for maximum browser compatibility:
36
+ - **Codec**: libx264
37
+ - **Pixel Format**: yuv420p
38
+ - **Dimensions**: Forced to even values (H.264 requirement)
39
+ - **Streaming**: `+faststart` enabled for instant playback.
Dockerfile ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use an official Python runtime as a parent image
2
+ FROM python:3.11-slim
3
+
4
+ # Set environment variables
5
+ ENV PYTHONDONTWRITEBYTECODE=1 \
6
+ PYTHONUNBUFFERED=1 \
7
+ DEBIAN_FRONTEND=noninteractive
8
+
9
+ # Set the working directory
10
+ WORKDIR /app
11
+
12
+ # Install system dependencies for OpenCV, PyTesseract, and PDF processing
13
+ RUN apt-get update && apt-get install -y \
14
+ build-essential \
15
+ ffmpeg \
16
+ libsm6 \
17
+ libxext6 \
18
+ poppler-utils \
19
+ tesseract-ocr \
20
+ libgl1 \
21
+ && rm -rf /var/lib/apt/lists/*
22
+
23
+ # Create a non-root user required by Hugging Face Spaces
24
+ RUN useradd -m -u 1000 user
25
+
26
+ # Set the working directory
27
+ WORKDIR /app
28
+
29
+ # Ensure /app is owned by the user so we don't get PermissionError on startup
30
+ RUN chown -R user:user /app
31
+
32
+ USER user
33
+ ENV PATH="/home/user/.local/bin:$PATH"
34
+
35
+ # Copy requirements first to leverage Docker cache
36
+ COPY --chown=user requirements.txt .
37
+
38
+ # Install dependencies
39
+ RUN pip install --no-cache-dir --upgrade pip && \
40
+ pip install --no-cache-dir -r requirements.txt
41
+
42
+ # Copy the rest of the application code
43
+ COPY --chown=user . .
44
+
45
+ # Expose the port Hugging Face Spaces expects
46
+ EXPOSE 7860
47
+
48
+ # Command to run the application
49
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
INDEPENDENT_MODELS_MIGRATION.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Independent Models Migration
2
+
3
+ **Date:** February 24, 2026
4
+
5
+ ## Summary
6
+ The system has been updated to use independently trained YOLOv5 models for player and ball detection instead of the combined models.
7
+
8
+ ## Changes Made
9
+
10
+ ### 1. Config Files Updated
11
+ - **`back-end/configs/configs.py`**
12
+ - `PLAYER_DETECTOR_PATH`: Changed from `TEAM_MODEL_PATH` → `models/player_detector_v1.pt`
13
+ - `BALL_DETECTOR_PATH`: Changed from `TEAM_MODEL_PATH` → `models/ball_detector_v1.pt`
14
+
15
+ - **`back-end/app/config.py`**
16
+ - `player_detector_path`: Updated from `"models/nbl_v3_combined.pt"` → `"models/player_detector_v1.pt"`
17
+ - `ball_detector_path`: Updated from `"models/nbl_v3_combined.pt"` → `"models/ball_detector_v1.pt"`
18
+ - `team_model_path`: Now uses `"models/nbl_v4_combined.pt"` (for team analysis)
19
+ - `personal_model_path`: Still uses `"models/nbl_v2_combined.pt"` (for personal analysis)
20
+
21
+ - **`back-end/test_system.py`**
22
+ - Model loader updated to use new independent model paths
23
+
24
+ ### 2. Model Files in Production
25
+ ```
26
+ back-end/models/
27
+ ├── player_detector_v1.pt (165 MB) - trained Feb 24 16:23
28
+ ├── ball_detector_v1.pt (165 MB) - trained Feb 24 17:10
29
+ ├── court_keypoint_detector.pt (399 MB) - existing
30
+ ├── nbl_v3_combined.pt (165 MB) - kept for reference
31
+ └── nbl_v4_combined.pt (5.3 MB) - for team analysis
32
+ ```
33
+
34
+ ### 3. Training Metrics
35
+
36
+ #### Player Detector (train5)
37
+ - Model: YOLOv5l6u
38
+ - Epochs: 100
39
+ - mAP50: 0.745
40
+ - mAP50-95: 0.381
41
+ - Per-class performance:
42
+ - player: 0.851 (mAP50)
43
+ - hoop: 0.836
44
+ - referee: 0.802
45
+ - basketball: 0.744
46
+ - shot-clock: 0.518
47
+
48
+ #### Ball Detector (train6)
49
+ - Model: YOLOv5l6u
50
+ - Epochs: 100
51
+ - mAP50: 0.731
52
+ - mAP50-95: 0.403
53
+ - Per-class performance:
54
+ - player: 0.865 (mAP50)
55
+ - hoop: 0.909
56
+ - referee: 0.780
57
+ - basketball: 0.646
58
+ - shot-clock: 0.454
59
+
60
+ ### 4. System Components Affected
61
+ The following components now use independent models:
62
+ - `trackers/player_tracker.py` - loads `player_detector_v1.pt`
63
+ - `trackers/ball_tracker.py` - loads `ball_detector_v1.pt`
64
+ - All video analysis pipelines pass the correct model paths via config
65
+
66
+ ### 5. Backward Compatibility
67
+ - Combined models are still available in `models/` for reference
68
+ - `team_analysis.py` still uses team-oriented combined models for compatibility
69
+ - `personal_analysis.py` still uses personal analysis combined models
70
+
71
+ ## Benefits
72
+ 1. **Specialized Training**: Each detector trained specifically for its object class
73
+ 2. **Better Performance**: Dedicated models allow for task-specific optimization
74
+ 3. **Modular Architecture**: Easier to update individual detectors without affecting others
75
+ 4. **Production Ready**: Both models trained on identical NBL-6 dataset for consistency
76
+
77
+ ## Rollback Instructions
78
+ If needed, to revert to combined models:
79
+ ```python
80
+ # In back-end/configs/configs.py
81
+ PLAYER_DETECTOR_PATH = TEAM_MODEL_PATH # = 'models/nbl_v4_combined.pt'
82
+ BALL_DETECTOR_PATH = TEAM_MODEL_PATH # = 'models/nbl_v4_combined.pt'
83
+ ```
84
+
85
+ ## Verification
86
+ All imports verified working:
87
+ ```python
88
+ from configs import PLAYER_DETECTOR_PATH, BALL_DETECTOR_PATH
89
+ # PLAYER_DETECTOR_PATH = 'models/player_detector_v1.pt' ✓
90
+ # BALL_DETECTOR_PATH = 'models/ball_detector_v1.pt' ✓
91
+ ```
QUICK_REFERENCE.md ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Basketball Analysis System - Quick Reference
2
+
3
+ ## 🚀 Quick Start Commands
4
+
5
+ ### Using the convenience script (Recommended):
6
+
7
+ ```bash
8
+ # Check if system is ready
9
+ ./run.sh --check
10
+
11
+ # Run analysis on a video
12
+ ./run.sh input_videos/video_1.mp4
13
+
14
+ # Run full system test
15
+ ./run.sh --test
16
+ ```
17
+
18
+ ### Using Python directly:
19
+
20
+ ```bash
21
+ # Activate virtual environment first
22
+ source venv/bin/activate
23
+
24
+ # Check system
25
+ python test_system.py --check-only
26
+
27
+ # Analyze a video
28
+ python main.py input_videos/video_1.mp4
29
+
30
+ # With custom output
31
+ python main.py input_videos/video_1.mp4 --output_video output_videos/my_output.avi
32
+ ```
33
+
34
+ ---
35
+
36
+ ## 📋 System Status
37
+
38
+ ### ✅ Models Ready
39
+ All three pre-trained models from the basketball_analysis repository are installed:
40
+ - `player_detector.pt` - YOLO v11 player detection
41
+ - `ball_detector.pt` - YOLO v5 ball detection with motion blur handling
42
+ - `court_keypoint_detector.pt` - YOLO v8 court keypoint detection
43
+
44
+ ### 📁 Directory Structure
45
+ ```
46
+ back-end/
47
+ ├── models/ # Pre-trained YOLO models ✓
48
+ ├── input_videos/ # Place test videos here
49
+ ├── output_videos/ # Analysis results saved here
50
+ ├── stubs/ # Cached intermediate results (auto-created)
51
+ ├── main.py # Main analysis pipeline
52
+ ├── test_system.py # System testing script
53
+ ├── run.sh # Convenience script
54
+ └── TESTING_GUIDE.md # Detailed testing guide
55
+ ```
56
+
57
+ ---
58
+
59
+ ## 🎯 What Gets Analyzed
60
+
61
+ The system performs comprehensive basketball video analysis:
62
+
63
+ 1. **Player Detection & Tracking** - Detects and tracks all players across frames
64
+ 2. **Ball Detection & Tracking** - Tracks basketball with interpolation for smooth trajectories
65
+ 3. **Court Keypoint Detection** - Identifies court lines, corners, and key zones
66
+ 4. **Team Assignment** - Classifies players by jersey color using zero-shot classification
67
+ 5. **Ball Possession** - Determines which player has the ball at each moment
68
+ 6. **Pass Detection** - Identifies passes between players
69
+ 7. **Interception Detection** - Detects when passes are intercepted
70
+ 8. **Tactical View** - Creates top-down tactical visualization (mini-map)
71
+ 9. **Speed & Distance Calculation** - Calculates player movement metrics
72
+
73
+ ---
74
+
75
+ ## 📊 Output Features
76
+
77
+ The analyzed video includes overlays for:
78
+ - ✓ Player bounding boxes (color-coded by team)
79
+ - ✓ Ball tracking visualization
80
+ - ✓ Court keypoint markers
81
+ - ✓ Team ball control statistics
82
+ - ✓ Pass and interception indicators
83
+ - ✓ Tactical view (top-down mini-map)
84
+ - ✓ Player speed and distance metrics
85
+ - ✓ Frame numbers
86
+
87
+ ---
88
+
89
+ ## ⚡ Performance Tips
90
+
91
+ ### Speed Optimization
92
+ 1. **Use GPU** - System automatically uses CUDA if available
93
+ 2. **Use Stubs** - Cached results make re-runs much faster
94
+ 3. **Start Small** - Test with short clips (10-30 seconds) first
95
+ 4. **Resolution** - Lower resolution videos process faster
96
+
97
+ ### Expected Processing Times
98
+ - **With GPU**: ~1-3 minutes per minute of video
99
+ - **CPU Only**: ~5-15 minutes per minute of video
100
+ - **First Run**: Slower (no cached stubs)
101
+ - **Subsequent Runs**: Much faster (uses cached stubs)
102
+
103
+ ---
104
+
105
+ ## 🎥 Video Requirements
106
+
107
+ ### Recommended Video Characteristics
108
+ - **Format**: MP4 or AVI
109
+ - **Resolution**: 720p or 1080p
110
+ - **Frame Rate**: 30fps or higher
111
+ - **Content**: Clear view of basketball court
112
+ - **Lighting**: Good lighting conditions
113
+ - **Camera**: Stable camera angle (minimal movement)
114
+
115
+ ### What Makes a Good Test Video
116
+ ✓ Clear court view
117
+ ✓ Multiple players visible
118
+ ✓ Ball clearly visible
119
+ ✓ Court lines visible
120
+ ✓ Stable camera position
121
+
122
+ ---
123
+
124
+ ## 🔧 Troubleshooting
125
+
126
+ ### Dependencies Not Installed
127
+ ```bash
128
+ source venv/bin/activate
129
+ pip install -r requirements.txt
130
+ ```
131
+
132
+ ### CUDA Out of Memory
133
+ The system will automatically fall back to CPU if GPU memory is insufficient.
134
+
135
+ ### Slow Processing
136
+ - Use GPU if available
137
+ - Process shorter clips
138
+ - Enable stub caching (default)
139
+ - Reduce video resolution
140
+
141
+ ### Poor Detection Quality
142
+ - Use higher quality videos
143
+ - Ensure good lighting
144
+ - Use clear court views
145
+ - Avoid extreme camera angles
146
+
147
+ ---
148
+
149
+ ## 📖 Documentation
150
+
151
+ - **TESTING_GUIDE.md** - Comprehensive testing guide with detailed instructions
152
+ - **test_system.py** - Automated system testing and validation
153
+ - **main.py** - Main analysis pipeline (see code for details)
154
+
155
+ ---
156
+
157
+ ## 🎬 Example Workflow
158
+
159
+ ### First Time Setup
160
+ ```bash
161
+ # 1. Check system is ready
162
+ ./run.sh --check
163
+
164
+ # 2. Run test with sample video
165
+ ./run.sh --test
166
+ ```
167
+
168
+ ### Analyzing Your Own Videos
169
+ ```bash
170
+ # 1. Add your video to input_videos/
171
+ cp /path/to/your/video.mp4 input_videos/
172
+
173
+ # 2. Run analysis
174
+ ./run.sh input_videos/your_video.mp4
175
+
176
+ # 3. Check output
177
+ ls -lh output_videos/analyzed_your_video.avi
178
+ ```
179
+
180
+ ### Iterating on Analysis
181
+ ```bash
182
+ # First run (slower - creates stubs)
183
+ ./run.sh input_videos/video_1.mp4
184
+
185
+ # Subsequent runs (faster - uses stubs)
186
+ ./run.sh input_videos/video_1.mp4
187
+
188
+ # Force fresh analysis (delete stubs)
189
+ rm -rf stubs/
190
+ ./run.sh input_videos/video_1.mp4
191
+ ```
192
+
193
+ ---
194
+
195
+ ## 🔍 Verification Checklist
196
+
197
+ Before deploying or integrating:
198
+
199
+ - [ ] System check passes (`./run.sh --check`)
200
+ - [ ] Models load successfully
201
+ - [ ] Test video analyzes successfully
202
+ - [ ] Output video is created and viewable
203
+ - [ ] All analysis features visible in output:
204
+ - [ ] Player detection
205
+ - [ ] Ball tracking
206
+ - [ ] Court keypoints
207
+ - [ ] Team assignment
208
+ - [ ] Ball possession
209
+ - [ ] Passes/interceptions
210
+ - [ ] Tactical view
211
+ - [ ] Speed/distance metrics
212
+ - [ ] Stub caching works (second run faster)
213
+
214
+ ---
215
+
216
+ ## 📞 Need Help?
217
+
218
+ 1. Check **TESTING_GUIDE.md** for detailed troubleshooting
219
+ 2. Run `./run.sh --check` to diagnose issues
220
+ 3. Verify all dependencies: `source venv/bin/activate && pip list`
221
+ 4. Check error messages in console output
222
+ 5. Test with provided sample videos first
223
+
224
+ ---
225
+
226
+ **System Ready! 🏀**
227
+
228
+ Start with: `./run.sh --check`
README.md ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: BakoAI
3
+ emoji: 🏀
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # 🏀 Basketball Video Analysis
12
+
13
+ Analyze basketball footage with automated detection of players, ball, team assignment, and more. This repository integrates object tracking, zero-shot classification, and custom keypoint detection for a fully annotated basketball game experience.
14
+
15
+ Leveraging the convenience of Roboflow for dataset management and Ultralytics' YOLO models for both training and inference, this project provides a robust framework for basketball video analysis.
16
+
17
+ Training notebooks are included to help you customize and fine-tune models to suit your specific needs, ensuring a seamless and efficient workflow.
18
+
19
+ ## 📁 Table of Contents
20
+
21
+ 1. [Features](#-features)
22
+ 2. [Prerequisites](#-prerequisites)
23
+ 3. [Demo Video](#-demo-video)
24
+ 4. [Installation](#-installation)
25
+ 5. [Training the Models](#-training-the-models)
26
+ 6. [Usage](#-usage)
27
+ 7. [Project Structure](#-project-structure)
28
+ 8. [Future Work](#-future-work)
29
+ 9. [Contributing](#-contributing)
30
+ 10. [License](#-license)
31
+
32
+ ---
33
+
34
+ ## ✨ Features
35
+
36
+ - Player and ball detection/tracking using pretrained models.
37
+ - Court keypoint detection for visualizing important zones.
38
+ - Team assignment with jersey color classification.
39
+ - Ball possession detection, pass detection, and interception detection.
40
+ - Easy stubbing to skip repeated computation for fast iteration.
41
+ - Various “drawers” to overlay detected elements onto frames.
42
+
43
+ ---
44
+
45
+ ## 🎮 Demo Video
46
+
47
+ Below is the final annotated output video.
48
+
49
+ [![BasketBall Analysis Demo Video](https://img.youtube.com/vi/xWpP0LjEUng/0.jpg)](https://youtu.be/xWpP0LjEUng)
50
+
51
+ ## 🔧 Prerequisites
52
+
53
+ - Python 3.8+
54
+ - (Optional) Docker
55
+
56
+ ---
57
+
58
+ ## ⚙️ Installation
59
+
60
+ Setup your environment locally or via Docker.
61
+
62
+ ### Python Environment
63
+
64
+ 1. Create a virtual environment (e.g., venv/conda).
65
+ 2. Install the required packages:
66
+
67
+ ```bash
68
+ pip install -r requirements.txt
69
+ ```
70
+
71
+ ### Docker
72
+
73
+ #### Build the Docker image:
74
+
75
+ ```bash
76
+ docker build -t basketball-analysis .
77
+ ```
78
+
79
+ #### Verify the image:
80
+
81
+ ```bash
82
+ docker images
83
+ ```
84
+
85
+ ## 🎓 Training the Models
86
+
87
+ Harnessing the powerful tools offered by Roboflow and Ultralytics makes it straightforward to manage datasets, handle annotations, and train advanced object detection models. Roboflow provides an intuitive platform for dataset preprocessing and augmentation, while Ultralytics’ YOLO architectures (v5, v8, and beyond) deliver state-of-the-art detection performance.
88
+
89
+ This repository relies on trained models for detecting basketballs, players, and court keypoints. You have two options to get these models:
90
+
91
+ 1. Download the Pretrained Weights
92
+
93
+ - ball_detector_model.pt
94
+ (https://drive.google.com/file/d/1KejdrcEnto2AKjdgdo1U1syr5gODp6EL/view?usp=sharing)
95
+ - court_keypoint_detector.pt
96
+ (https://drive.google.com/file/d/1nGoG-pUkSg4bWAUIeQ8aN6n7O1fOkXU0/view?usp=sharing)
97
+ - player_detector.pt
98
+ (https://drive.google.com/file/d/1fVBLZtPy9Yu6Tf186oS4siotkioHBLHy/view?usp=sharing)
99
+
100
+ Simply download these files and place them into the `models/` folder in your project. This allows you to run the pipelines without manually retraining.
101
+
102
+ 2. Train Your Own Models
103
+ The training scripts are provided in the `training_notebooks/` folder. These Jupyter notebooks use Roboflow datasets and the Ultralytics YOLO frameworks to train various detection tasks:
104
+
105
+ - `basketball_ball_training.ipynb`: Trains a basketball ball detector (using YOLOv5). Incorporates motion blur augmentations to improve ball detection accuracy on fast-moving game footage.
106
+ - `basketball_court_keypoint_training.ipynb`: Uses YOLOv8 to detect keypoints on the court (e.g., lines, corners, key zones).
107
+ - `basketball_player_detection_training.ipynb`: Trains a player detection model (using YOLO v11) to identify players in each frame.
108
+
109
+ You can easily run these notebooks in Google Colab or another environment with GPU access. After training, download the newly generated `.pt` files and place them in the `models/` folder.
110
+
111
+ ## Once you have your models in place, you may proceed with the usage steps described above. If you want to retrain or fine-tune for your specific dataset, remember to adjust the paths in the notebooks and in `main.py` to point to the newly generated models.
112
+
113
+ ## 🚀 Usage
114
+
115
+ You can run this repository’s core functionality (analysis pipeline) with Python or Docker.
116
+
117
+ ### 1) Using Python Directly
118
+
119
+ Run the main entry point with your chosen video file:
120
+
121
+ ```bash
122
+ python main.py path_to_input_video.mp4 --output_video output_videos/output_result.avi
123
+ ```
124
+
125
+ - By default, intermediate “stubs” (pickled detection results) are used if found, allowing you to skip repeated detection/tracking.
126
+ - Use the `--stub_path` flag to specify a custom stub folder, or disable stubs if you want to run everything fresh.
127
+
128
+ ### 2) Using Docker
129
+
130
+ #### Build the container if not built already:
131
+
132
+ ```bash
133
+ docker build -t basketball-analysis .
134
+ ```
135
+
136
+ #### Run the container, mounting your local input video folder:
137
+
138
+ ```bash
139
+ docker run \
140
+ -v $(pwd)/videos:/app/videos \
141
+ -v $(pwd)/output_videos:/app/output_videos \
142
+ basketball-analysis \
143
+ python main.py videos/input_video.mp4 --output_video output_videos/output_result.avi
144
+ ```
145
+
146
+ ---
147
+
148
+ ## 🏰 Project Structure
149
+
150
+ - `main.py`
151
+ – Orchestrates the entire pipeline: reading video frames, running detection/tracking, team assignment, drawing results, and saving the output video.
152
+
153
+ - `trackers/`
154
+ – Houses `PlayerTracker` and `BallTracker`, which use detection models to generate bounding boxes and track objects across frames.
155
+
156
+ - `utils/`
157
+ – Contains helper functions like `bbox_utils.py` for geometric calculations, `stubs_utils.py` for reading and saving intermediate results, and `video_utils.py` for reading/saving videos.
158
+
159
+ - `drawers/`
160
+ – Contains classes that overlay bounding boxes, court lines, passes, etc., onto frames.
161
+
162
+ - `ball_aquisition/`
163
+ – Logic for identifying which player is in possession of the ball.
164
+
165
+ - `pass_and_interception_detector/`
166
+ – Identifies passing events and interceptions.
167
+
168
+ - `court_keypoint_detector/`
169
+ – Detects lines and keypoints on the court using the specified model.
170
+
171
+ - `team_assigner/`
172
+ – Uses zero-shot classification (Hugging Face or similar) to assign players to teams based on jersey color.
173
+
174
+ - `configs/`
175
+ – Holds default paths for models, stubs, and output video.
176
+
177
+ ---
178
+
179
+ ## 🔮 Future Work
180
+
181
+ As we continue to enhance the capabilities of this basketball video analysis tool, several areas for future development have been identified:
182
+
183
+ 1. **Integrating a Pose Model for Advanced Rule Detection**
184
+ Incorporating a pose detection model could enable the identification of complex basketball rules such as double dribbling and traveling. By analyzing player movements and positions, the system could automatically flag these infractions, adding another layer of analysis to the video footage.
185
+
186
+ These enhancements will further refine the analysis capabilities and provide users with more comprehensive insights into basketball games.
187
+
188
+ ## 🤝 Contributing
189
+
190
+ Contributions are welcome!
191
+
192
+ 1. Fork the repository.
193
+ 2. Create a new branch for your feature or bug fix.
194
+ 3. Submit a pull request with a clear explanation of your changes.
195
+
196
+ ---
197
+
198
+ ## 🐜 License
199
+
200
+ This project is licensed under the MIT License.
201
+ See `LICENSE` for details.
202
+
203
+ ---
204
+
205
+ ## 💬 Questions or Feedback?
206
+
207
+ Feel free to open an issue or reach out via email if you have questions about the project, suggestions for improvements, or just want to say hi!
208
+
209
+ Enjoy analyzing basketball footage with automatic detection and tracking!
SETUP_COMPLETE.md ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🏀 Basketball Analysis System - Setup Complete! ✅
2
+
3
+ ## System Status: READY FOR TESTING
4
+
5
+ Your basketball analysis system is now fully configured and ready to test with the pre-trained models from the [basketball_analysis repository](https://github.com/abdullahtarek/basketball_analysis).
6
+
7
+ ---
8
+
9
+ ## ✅ What's Been Set Up
10
+
11
+ ### 1. **Pre-trained Models** (All Downloaded & Verified)
12
+ - ✓ `player_detector.pt` (164.65 MB) - YOLO v11 player detection
13
+ - ✓ `ball_detector_model.pt` (164.67 MB) - YOLO v5 ball detection
14
+ - ✓ `court_keypoint_detector.pt` (398.37 MB) - YOLO v8 court keypoints
15
+
16
+ ### 2. **Dependencies** (All Installed)
17
+ - ✓ OpenCV 4.13.0
18
+ - ✓ NumPy 2.3.5
19
+ - ✓ Pandas 3.0.0
20
+ - ✓ PyTorch 2.10.0+cpu (CPU version for compatibility)
21
+ - ✓ Ultralytics 8.4.9
22
+ - ✓ Supervision 0.27.0
23
+ - ✓ Transformers 5.0.0
24
+ - ✓ Pillow 12.0.0
25
+
26
+ ### 3. **Test Videos Available**
27
+ - ✓ video_1.mp4 (4.24 MB)
28
+ - ✓ video_2.mp4 (6.69 MB)
29
+ - ✓ video_3.mp4 (9.06 MB)
30
+ - ✓ video_4.mp4 (6.60 MB)
31
+
32
+ ### 4. **Testing Scripts Created**
33
+ - ✓ `test_system.py` - Comprehensive system testing
34
+ - ✓ `run.sh` - Convenient bash wrapper
35
+ - ✓ `TESTING_GUIDE.md` - Detailed testing guide
36
+ - ✓ `QUICK_REFERENCE.md` - Quick command reference
37
+
38
+ ---
39
+
40
+ ## 🚀 Quick Start - Test the System Now!
41
+
42
+ ### Option 1: Using the Convenience Script (Easiest)
43
+
44
+ ```bash
45
+ # Navigate to back-end directory
46
+ cd /home/okidi6/Documents/Personalised-AI-Basketball-Skill-Analysis-System./back-end
47
+
48
+ # Run analysis on first test video
49
+ ./run.sh input_videos/video_1.mp4
50
+ ```
51
+
52
+ ### Option 2: Using Python Directly
53
+
54
+ ```bash
55
+ # Navigate to back-end directory
56
+ cd /home/okidi6/Documents/Personalised-AI-Basketball-Skill-Analysis-System./back-end
57
+
58
+ # Activate virtual environment
59
+ source venv/bin/activate
60
+
61
+ # Run analysis
62
+ python main.py input_videos/video_1.mp4
63
+ ```
64
+
65
+ ### Option 3: Using the Test Script
66
+
67
+ ```bash
68
+ cd /home/okidi6/Documents/Personalised-AI-Basketball-Skill-Analysis-System./back-end
69
+
70
+ source venv/bin/activate
71
+
72
+ # Run full test
73
+ python test_system.py
74
+ ```
75
+
76
+ ---
77
+
78
+ ## 📊 What the Analysis Does
79
+
80
+ The system performs comprehensive basketball video analysis:
81
+
82
+ 1. **Player Detection & Tracking** - Identifies and tracks all players
83
+ 2. **Ball Detection & Tracking** - Tracks basketball with smooth interpolation
84
+ 3. **Court Keypoint Detection** - Identifies court lines and zones
85
+ 4. **Team Assignment** - Classifies players by jersey color
86
+ 5. **Ball Possession** - Determines who has the ball
87
+ 6. **Pass Detection** - Identifies passes between players
88
+ 7. **Interception Detection** - Detects intercepted passes
89
+ 8. **Tactical View** - Creates top-down mini-map
90
+ 9. **Speed & Distance** - Calculates player movement metrics
91
+
92
+ ### Output Features
93
+ The analyzed video will include:
94
+ - Player bounding boxes (color-coded by team)
95
+ - Ball tracking visualization
96
+ - Court keypoint overlays
97
+ - Team ball control statistics
98
+ - Pass and interception markers
99
+ - Tactical view (mini-map)
100
+ - Player speed and distance metrics
101
+ - Frame numbers
102
+
103
+ ---
104
+
105
+ ## ⏱️ Expected Processing Time
106
+
107
+ ### First Run (No Cached Data)
108
+ - **CPU Only**: ~5-15 minutes per minute of video
109
+ - The system will create "stubs" (cached intermediate results)
110
+
111
+ ### Subsequent Runs (With Cached Data)
112
+ - **Much Faster**: Reuses cached player/ball detections
113
+ - Only recomputes final visualization
114
+
115
+ ### Performance Tips
116
+ - Start with short clips (10-30 seconds) for faster testing
117
+ - Keep the `stubs/` directory for faster re-runs
118
+ - Delete `stubs/` to force fresh analysis
119
+
120
+ ---
121
+
122
+ ## 📁 Output Location
123
+
124
+ Analyzed videos will be saved to:
125
+ ```
126
+ /home/okidi6/Documents/Personalised-AI-Basketball-Skill-Analysis-System./back-end/output_videos/
127
+ ```
128
+
129
+ Default output filename: `output_video.avi` (or custom name if specified)
130
+
131
+ ---
132
+
133
+ ## 🎥 Adding Your Own Test Videos
134
+
135
+ 1. Place basketball video files in:
136
+ ```
137
+ /home/okidi6/Documents/Personalised-AI-Basketball-Skill-Analysis-System./back-end/input_videos/
138
+ ```
139
+
140
+ 2. Supported formats: `.mp4`, `.avi`
141
+
142
+ 3. Recommended video characteristics:
143
+ - Resolution: 720p or 1080p
144
+ - Frame rate: 30fps or higher
145
+ - Clear view of basketball court
146
+ - Good lighting
147
+ - Stable camera angle
148
+
149
+ 4. Run analysis:
150
+ ```bash
151
+ ./run.sh input_videos/your_video.mp4
152
+ ```
153
+
154
+ ---
155
+
156
+ ## 🔍 System Verification
157
+
158
+ All checks passed ✅:
159
+ - [x] All dependencies installed
160
+ - [x] All models present and loading correctly
161
+ - [x] Test videos available
162
+ - [x] Directory structure set up
163
+ - [x] Virtual environment configured
164
+
165
+ ---
166
+
167
+ ## 📖 Documentation
168
+
169
+ - **TESTING_GUIDE.md** - Comprehensive testing guide with troubleshooting
170
+ - **QUICK_REFERENCE.md** - Quick command reference
171
+ - **test_system.py** - Automated system testing
172
+ - **run.sh** - Convenience script for running analysis
173
+
174
+ ---
175
+
176
+ ## 🐛 Troubleshooting
177
+
178
+ ### If you encounter issues:
179
+
180
+ 1. **Ensure virtual environment is activated**:
181
+ ```bash
182
+ source venv/bin/activate
183
+ ```
184
+
185
+ 2. **Check system status**:
186
+ ```bash
187
+ python test_system.py --check-only
188
+ ```
189
+
190
+ 3. **View detailed logs**: Check console output for error messages
191
+
192
+ 4. **Clear cache and retry**:
193
+ ```bash
194
+ rm -rf stubs/
195
+ python main.py input_videos/video_1.mp4
196
+ ```
197
+
198
+ ---
199
+
200
+ ## 📝 Notes
201
+
202
+ - **CPU vs GPU**: Currently using PyTorch CPU version for compatibility
203
+ - Processing will be slower than GPU but fully functional
204
+ - If you have CUDA-capable GPU and want to use it, you can reinstall PyTorch with CUDA support
205
+
206
+ - **Stub Caching**: The system caches intermediate results in `stubs/`
207
+ - First run: Slower (creates cache)
208
+ - Subsequent runs: Much faster (uses cache)
209
+ - Delete `stubs/` to force fresh analysis
210
+
211
+ - **Video Quality**: Better quality input videos = better detection results
212
+ - Clear court view
213
+ - Good lighting
214
+ - Stable camera
215
+ - Visible players and ball
216
+
217
+ ---
218
+
219
+ ## 🎯 Next Steps
220
+
221
+ ### 1. Test the System (NOW!)
222
+ ```bash
223
+ cd /home/okidi6/Documents/Personalised-AI-Basketball-Skill-Analysis-System./back-end
224
+ ./run.sh input_videos/video_1.mp4
225
+ ```
226
+
227
+ ### 2. Review Output
228
+ - Check `output_videos/` for the analyzed video
229
+ - Verify all analysis features are working
230
+ - Test with different input videos
231
+
232
+ ### 3. Integration Planning
233
+ Once testing is successful:
234
+ - Integrate with FastAPI backend
235
+ - Add video upload endpoints
236
+ - Implement async processing
237
+ - Store results in Supabase
238
+ - Connect to frontend
239
+
240
+ ---
241
+
242
+ ## 🎉 Ready to Go!
243
+
244
+ Your system is fully prepared and ready for testing. Run your first analysis now:
245
+
246
+ ```bash
247
+ cd /home/okidi6/Documents/Personalised-AI-Basketball-Skill-Analysis-System./back-end
248
+ ./run.sh input_videos/video_1.mp4
249
+ ```
250
+
251
+ **Good luck with your testing! 🏀**
252
+
253
+ ---
254
+
255
+ ## 📞 Support Resources
256
+
257
+ - **TESTING_GUIDE.md** - Detailed testing instructions
258
+ - **QUICK_REFERENCE.md** - Command reference
259
+ - **Original Repository**: https://github.com/abdullahtarek/basketball_analysis
260
+ - **System Check**: `python test_system.py --check-only`
261
+
262
+ ---
263
+
264
+ **System Status**: ✅ **READY FOR TESTING**
265
+
266
+ **Last Verified**: 2026-02-01 13:16 UTC
267
+
268
+ **All Systems**: ✅ **GO!**
SHOT_DETECTION.md ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Shot Success Rate Detection
2
+
3
+ ## Overview
4
+
5
+ The shot success rate detection feature adds comprehensive shooting analytics to both **Personal** and **Team** analysis modes. This critical basketball metric tracks not only shot attempts but also whether shots are made or missed, providing essential performance insights.
6
+
7
+ ## Features
8
+
9
+ ### 🎯 Shot Detection Capabilities
10
+
11
+ 1. **Shot Attempt Detection**
12
+ - Automatically identifies shooting motions by analyzing ball trajectory
13
+ - Detects upward arc patterns characteristic of basketball shots
14
+ - Filters out non-shot ball movements
15
+
16
+ 2. **Shot Outcome Detection**
17
+ - Determines if shots are **made** (ball goes through hoop)
18
+ - Identifies **missed** shots (ball does not pass through hoop region)
19
+ - Provides confidence scores for each outcome determination
20
+
21
+ 3. **Shot Type Classification**
22
+ - **Layup**: Close-range shots with low arc
23
+ - **Mid-range**: Medium-distance jump shots
24
+ - **Three-pointer**: Long-range shots beyond the arc
25
+ - Automatic classification based on distance and trajectory
26
+
27
+ 4. **Statistical Analysis**
28
+ - Overall shooting percentage
29
+ - Breakdown by shot type (layup %, mid-range %, 3PT %)
30
+ - Made/missed shot counts
31
+ - Shot locations and timing
32
+
33
+ ## How It Works
34
+
35
+ ### Detection Algorithm
36
+
37
+ The shot detector uses a multi-stage approach:
38
+
39
+ 1. **Ball Trajectory Analysis**
40
+ - Tracks ball position across frames
41
+ - Calculates velocity and direction
42
+ - Identifies upward arcs indicating shot attempts
43
+
44
+ 2. **Hoop Detection**
45
+ - Uses YOLO model to detect basketball hoop location
46
+ - Falls back to heuristic methods if hoop model unavailable
47
+ - Maintains stable hoop position across frames
48
+
49
+ 3. **Outcome Determination**
50
+ - Checks if ball passes through hoop region
51
+ - Verifies ball is descending (downward trajectory)
52
+ - Measures proximity to rim/backboard
53
+ - Assigns confidence score based on trajectory clarity
54
+
55
+ 4. **Shot Type Classification**
56
+ - Calculates horizontal distance from shooter to hoop
57
+ - Analyzes shot arc height
58
+ - Classifies based on basketball shot type definitions
59
+
60
+ ### Key Parameters
61
+
62
+ ```python
63
+ ShotDetector(
64
+ min_shot_arc_height=50, # Minimum pixels for valid shot arc
65
+ hoop_proximity_threshold=100, # Max distance to count as "made"
66
+ trajectory_window=30, # Frames to analyze trajectory
67
+ success_time_window=15 # Frames after peak to check outcome
68
+ )
69
+ ```
70
+
71
+ ## Usage
72
+
73
+ ### Personal Analysis Mode
74
+
75
+ ```python
76
+ from analysis.personal_analysis import run_personal_analysis
77
+
78
+ # Run analysis with shot detection (enabled by default)
79
+ results = await run_personal_analysis(
80
+ video_path="training_session.mp4",
81
+ options={'detect_shots': True}
82
+ )
83
+
84
+ # Access shooting statistics
85
+ print(f"Shot Attempts: {results['shot_attempts']}")
86
+ print(f"Shots Made: {results['shots_made']}")
87
+ print(f"Success Rate: {results['shot_success_rate']}%")
88
+ print(f"Shot Breakdown: {results['shot_breakdown_by_type']}")
89
+ ```
90
+
91
+ ### Team Analysis Mode
92
+
93
+ ```python
94
+ from analysis.team_analysis import run_team_analysis
95
+
96
+ # Run team analysis (shot detection automatic)
97
+ results = await run_team_analysis(video_path="game_footage.mp4")
98
+
99
+ # Access team shooting stats
100
+ print(f"Team 1 Shooting: {results['team_1_shooting_percentage']}%")
101
+ print(f"Team 2 Shooting: {results['team_2_shooting_percentage']}%")
102
+ print(f"Overall: {results['overall_shooting_percentage']}%")
103
+ ```
104
+
105
+ ### Testing
106
+
107
+ Use the included test script to verify shot detection:
108
+
109
+ ```bash
110
+ # Test personal analysis
111
+ python test_shot_detection.py input_videos/training.mp4 --mode personal
112
+
113
+ # Test team analysis
114
+ python test_shot_detection.py input_videos/game.mp4 --mode team
115
+
116
+ # Test both modes
117
+ python test_shot_detection.py input_videos/video.mp4 --mode both
118
+ ```
119
+
120
+ ## Output Data Structure
121
+
122
+ ### Personal Analysis
123
+
124
+ ```json
125
+ {
126
+ "shot_attempts": 15,
127
+ "shots_made": 9,
128
+ "shots_missed": 6,
129
+ "shot_success_rate": 60.0,
130
+ "shot_form_consistency": 75.3,
131
+ "shot_breakdown_by_type": {
132
+ "layup": {
133
+ "attempts": 5,
134
+ "made": 4,
135
+ "missed": 1,
136
+ "percentage": 80.0
137
+ },
138
+ "mid-range": {
139
+ "attempts": 7,
140
+ "made": 3,
141
+ "missed": 4,
142
+ "percentage": 42.9
143
+ },
144
+ "three-pointer": {
145
+ "attempts": 3,
146
+ "made": 2,
147
+ "missed": 1,
148
+ "percentage": 66.7
149
+ }
150
+ },
151
+ "shot_details": [
152
+ {
153
+ "start_frame": 245,
154
+ "outcome": "made",
155
+ "shot_type": "mid-range",
156
+ "timestamp_seconds": 8.2,
157
+ "confidence": 0.85,
158
+ "arc_height_pixels": 120.5
159
+ }
160
+ ]
161
+ }
162
+ ```
163
+
164
+ ### Team Analysis
165
+
166
+ ```json
167
+ {
168
+ "total_shot_attempts": 42,
169
+ "total_shots_made": 24,
170
+ "total_shots_missed": 18,
171
+ "overall_shooting_percentage": 57.1,
172
+
173
+ "team_1_shot_attempts": 23,
174
+ "team_1_shots_made": 14,
175
+ "team_1_shooting_percentage": 60.9,
176
+ "team_1_shot_breakdown": {
177
+ "layup": {"attempts": 8, "made": 6, "percentage": 75.0},
178
+ "mid-range": {"attempts": 10, "made": 5, "percentage": 50.0},
179
+ "three-pointer": {"attempts": 5, "made": 3, "percentage": 60.0}
180
+ },
181
+
182
+ "team_2_shot_attempts": 19,
183
+ "team_2_shots_made": 10,
184
+ "team_2_shooting_percentage": 52.6,
185
+ "team_2_shot_breakdown": {
186
+ "layup": {"attempts": 6, "made": 4, "percentage": 66.7},
187
+ "mid-range": {"attempts": 9, "made": 4, "percentage": 44.4},
188
+ "three-pointer": {"attempts": 4, "made": 2, "percentage": 50.0}
189
+ }
190
+ }
191
+ ```
192
+
193
+ ## API Integration
194
+
195
+ ### Updated Analytics Models
196
+
197
+ **PlayerAnalyticsSummary** now includes:
198
+ - `total_shot_attempts`
199
+ - `total_shots_made`
200
+ - `total_shots_missed`
201
+ - `shot_success_rate`
202
+
203
+ **TeamAnalyticsSummary** now includes:
204
+ - `total_shot_attempts`
205
+ - `total_shots_made`
206
+ - `team_shooting_percentage`
207
+
208
+ ### Database Storage
209
+
210
+ Shot statistics are automatically saved to the `analytics` table with the following metric types:
211
+
212
+ - `shot_attempt` - Total attempts (count)
213
+ - `shot_made` - Successful shots (count)
214
+ - `shot_missed` - Missed shots (count)
215
+ - `shot_percentage` - Success rate (0-100)
216
+ - `layup_percentage` - Layup success rate
217
+ - `midrange_percentage` - Mid-range success rate
218
+ - `three_point_percentage` - Three-point success rate
219
+
220
+ ## Performance Considerations
221
+
222
+ ### Accuracy Factors
223
+
224
+ Shot detection accuracy depends on:
225
+
226
+ 1. **Video Quality**: Higher resolution improves detection
227
+ 2. **Camera Angle**: Side or elevated views work best
228
+ 3. **Hoop Visibility**: Hoop must be visible in frame
229
+ 4. **Ball Tracking**: Clear ball visibility throughout trajectory
230
+ 5. **Frame Rate**: Higher FPS (30+) improves trajectory analysis
231
+
232
+ ### Optimization
233
+
234
+ - Ball tracking uses interpolation for smoother trajectories
235
+ - Hoop detection caches stable positions
236
+ - Trajectory analysis uses sliding windows for efficiency
237
+ - Batch processing for multiple frames
238
+
239
+ ## Limitations & Future Enhancements
240
+
241
+ ### Current Limitations
242
+
243
+ 1. Requires hoop to be visible in frame
244
+ 2. May struggle with extreme camera angles
245
+ 3. Blocked shots may be misclassified
246
+ 4. Requires clear ball visibility
247
+
248
+ ### Planned Enhancements
249
+
250
+ 1. **Machine Learning Enhancement**
251
+ - Train ML model specifically for shot outcome
252
+ - Learn from labeled shooting data
253
+ - Improve classification accuracy
254
+
255
+ 2. **Advanced Metrics**
256
+ - Shot charts (visual heat maps)
257
+ - Shooting zones analysis
258
+ - Shot quality assessment
259
+ - Contested vs. uncontested shots
260
+
261
+ 3. **3D Trajectory Analysis**
262
+ - Account for camera perspective
263
+ - More accurate distance calculations
264
+ - Better arc analysis
265
+
266
+ 4. **Free Throw Detection**
267
+ - Specific free throw line detection
268
+ - Free throw percentage tracking
269
+
270
+ ## Troubleshooting
271
+
272
+ ### Common Issues
273
+
274
+ **Issue**: No shots detected
275
+ - **Solution**: Ensure ball is visible and video contains shooting actions
276
+ - Check that `detect_shots=True` in options
277
+ - Verify video contains full shot arcs (not cut off)
278
+
279
+ **Issue**: Low accuracy (many "unknown" outcomes)
280
+ - **Solution**: Ensure hoop is visible in frame
281
+ - Check video quality and resolution
282
+ - Consider using hoop detection model
283
+
284
+ **Issue**: Wrong shot type classification
285
+ - **Solution**: May need to calibrate distance thresholds
286
+ - Ensure court keypoints are detected for better spatial understanding
287
+
288
+ **Issue**: Performance is slow
289
+ - **Solution**: Process shorter video clips
290
+ - Reduce trajectory window size
291
+ - Use lower resolution for testing
292
+
293
+ ## Examples
294
+
295
+ See `test_shot_detection.py` for complete examples of:
296
+ - Personal training session analysis
297
+ - Team game footage analysis
298
+ - Accessing and displaying shot statistics
299
+ - Interpreting shot details
300
+
301
+ ## Contributing
302
+
303
+ To improve shot detection:
304
+
305
+ 1. Test with diverse video footage
306
+ 2. Report accuracy issues with specific videos
307
+ 3. Suggest parameter tuning for different scenarios
308
+ 4. Contribute labeled shot data for validation
309
+
310
+ ## Related Documentation
311
+
312
+ - [Personal Analysis Pipeline](./analysis/personal_analysis.py)
313
+ - [Team Analysis Pipeline](./analysis/team_analysis.py)
314
+ - [Analytics API](./app/api/analytics.py)
315
+ - [Analytics Models](./app/models/analytics.py)
SHOT_IMPLEMENTATION_SUMMARY.md ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Shot Success Rate Implementation - Summary
2
+
3
+ ## ✅ Implementation Complete
4
+
5
+ Shot success rate detection has been successfully implemented for your basketball analysis system!
6
+
7
+ ## 📦 What Was Added
8
+
9
+ ### 1. **New Shot Detector Module** (`shot_detector/`)
10
+ - `shot_detector.py` - Core detection logic with 500+ lines
11
+ - Detects shot attempts via ball trajectory analysis
12
+ - Determines shot outcomes (made/missed)
13
+ - Classifies shot types (layup, mid-range, 3-pointer)
14
+ - Calculates comprehensive statistics
15
+
16
+ ### 2. **Integration with Personal Analysis**
17
+ - Added shot detection to `analysis/personal_analysis.py`
18
+ - Tracks individual shooting performance
19
+ - Returns detailed shot statistics per session
20
+
21
+ ### 3. **Integration with Team Analysis**
22
+ - Added shot detection to `analysis/team_analysis.py`
23
+ - Separates shots by team
24
+ - Provides team-specific shooting percentages
25
+
26
+ ### 4. **Updated Data Models**
27
+ - Enhanced `PlayerAnalyticsSummary` with shot metrics
28
+ - Enhanced `TeamAnalyticsSummary` with shooting stats
29
+ - New fields: shots_made, shots_missed, shot_success_rate
30
+
31
+ ### 5. **Testing & Documentation**
32
+ - `test_shot_detection.py` - Complete test script
33
+ - `SHOT_DETECTION.md` - Full documentation
34
+ - Usage examples and troubleshooting guide
35
+
36
+ ## 🎯 Key Features
37
+
38
+ ### Shot Detection Capabilities
39
+ - ✅ Automatic shot attempt detection
40
+ - ✅ Made vs. missed determination
41
+ - ✅ Shot type classification
42
+ - ✅ Confidence scoring
43
+ - ✅ Timing and location tracking
44
+ - ✅ Statistical aggregation
45
+
46
+ ### Statistics Provided
47
+
48
+ **Personal Mode:**
49
+ - Total attempts, made, missed
50
+ - Overall shooting percentage
51
+ - Breakdown by shot type
52
+ - Individual shot details
53
+ - Form consistency correlation
54
+
55
+ **Team Mode:**
56
+ - Overall game statistics
57
+ - Team 1 vs Team 2 comparison
58
+ - Per-team shooting percentages
59
+ - Shot type distribution
60
+ - Player-specific attribution
61
+
62
+ ## 📊 Output Example
63
+
64
+ ```json
65
+ {
66
+ "shot_attempts": 15,
67
+ "shots_made": 9,
68
+ "shots_missed": 6,
69
+ "shot_success_rate": 60.0,
70
+ "shot_breakdown_by_type": {
71
+ "layup": {"attempts": 5, "made": 4, "percentage": 80.0},
72
+ "mid-range": {"attempts": 7, "made": 3, "percentage": 42.9},
73
+ "three-pointer": {"attempts": 3, "made": 2, "percentage": 66.7}
74
+ }
75
+ }
76
+ ```
77
+
78
+ ## 🚀 How to Use
79
+
80
+ ### Quick Start
81
+
82
+ ```bash
83
+ # Test with a video
84
+ python test_shot_detection.py input_videos/your_video.mp4 --mode personal
85
+
86
+ # Or use in your API
87
+ # Shot detection is now automatically enabled in both analysis modes
88
+ ```
89
+
90
+ ### API Usage
91
+
92
+ ```python
93
+ from analysis.personal_analysis import run_personal_analysis
94
+
95
+ results = await run_personal_analysis("video.mp4")
96
+ print(f"Shooting: {results['shots_made']}/{results['shot_attempts']}")
97
+ print(f"Percentage: {results['shot_success_rate']}%")
98
+ ```
99
+
100
+ ## 🔧 How It Works
101
+
102
+ 1. **Ball Trajectory Tracking**
103
+ - Tracks ball position frame-by-frame
104
+ - Calculates velocity and acceleration
105
+ - Identifies upward arcs characteristic of shots
106
+
107
+ 2. **Hoop Detection**
108
+ - Locates basketball hoop in frame
109
+ - Uses YOLO model (class 2: hoop) if available
110
+ - Maintains stable position across frames
111
+
112
+ 3. **Outcome Analysis**
113
+ - Checks if ball passes through hoop region
114
+ - Verifies downward trajectory at rim level
115
+ - Measures proximity to hoop center
116
+ - Assigns confidence based on trajectory clarity
117
+
118
+ 4. **Classification**
119
+ - Calculates shooter-to-hoop distance
120
+ - Analyzes arc height and trajectory
121
+ - Classifies as layup, mid-range, or 3-pointer
122
+
123
+ ## ⚙️ Configuration
124
+
125
+ Tunable parameters in `ShotDetector`:
126
+
127
+ ```python
128
+ ShotDetector(
129
+ min_shot_arc_height=50, # Min pixels for valid shot
130
+ hoop_proximity_threshold=100, # Distance to count as "made"
131
+ trajectory_window=30, # Frames to analyze
132
+ success_time_window=15 # Frames to check outcome
133
+ )
134
+ ```
135
+
136
+ ## 📈 Impact on Your System
137
+
138
+ ### Before (Missing)
139
+ - ❌ Shot attempts only (no outcomes)
140
+ - ❌ No made/missed tracking
141
+ - ❌ No shooting percentages
142
+ - ❌ No shot type breakdown
143
+
144
+ ### After (Complete!) ✅
145
+ - ✅ Full shot attempt detection
146
+ - ✅ Made vs missed determination
147
+ - ✅ Shooting percentage calculation
148
+ - ✅ Shot type classification
149
+ - ✅ Per-team statistics (team mode)
150
+ - ✅ Individual shot details
151
+ - ✅ Confidence scoring
152
+
153
+ ## 🎓 Advanced Features
154
+
155
+ - **Shot Details**: Every shot includes timestamp, location, type, outcome
156
+ - **Confidence Scores**: Each determination has reliability metric
157
+ - **Type Breakdown**: Separate stats for layups, mid-range, 3-pointers
158
+ - **Team Attribution**: Assigns shots to correct team in team mode
159
+ - **Error Handling**: Graceful fallbacks if hoop not detected
160
+
161
+ ## 🧪 Testing
162
+
163
+ The system has been validated with:
164
+ - ✅ Syntax compilation checks
165
+ - ✅ Module import verification
166
+ - ✅ Integration with existing pipelines
167
+ - ✅ Test script creation
168
+
169
+ **Next Steps for Testing:**
170
+ 1. Run with actual video: `python test_shot_detection.py input_videos/your_video.mp4`
171
+ 2. Verify shot counts match visual inspection
172
+ 3. Check shooting percentages for accuracy
173
+ 4. Fine-tune parameters if needed
174
+
175
+ ## 📝 Files Modified/Created
176
+
177
+ **New Files:**
178
+ - `shot_detector/__init__.py`
179
+ - `shot_detector/shot_detector.py`
180
+ - `test_shot_detection.py`
181
+ - `SHOT_DETECTION.md`
182
+ - `SHOT_IMPLEMENTATION_SUMMARY.md` (this file)
183
+
184
+ **Modified Files:**
185
+ - `analysis/personal_analysis.py`
186
+ - `analysis/team_analysis.py`
187
+ - `app/models/analytics.py`
188
+
189
+ ## 🎯 What Makes This Important
190
+
191
+ Shot success rate is arguably **THE most critical basketball metric**:
192
+
193
+ 1. **Universal Metric**: Used at all levels (NBA, college, high school)
194
+ 2. **Performance Indicator**: Direct measure of scoring efficiency
195
+ 3. **Training Feedback**: Shows improvement over time
196
+ 4. **Game Analysis**: Identifies hot/cold shooters
197
+ 5. **Strategic Planning**: Informs shot selection and play calling
198
+
199
+ Your system now provides this essential analysis automatically!
200
+
201
+ ## 🔜 Future Enhancements (Optional)
202
+
203
+ Potential improvements for later:
204
+ - Shot charts (visual heat maps)
205
+ - Contested vs. uncontested shots
206
+ - Shot clock awareness
207
+ - Free throw specific detection
208
+ - Arc angle optimization
209
+ - Machine learning for better accuracy
210
+
211
+ ## 💡 Pro Tips
212
+
213
+ 1. **Best Results**: Use videos where hoop is visible
214
+ 2. **Camera Angle**: Side or elevated views work best
215
+ 3. **Video Quality**: Higher resolution = better detection
216
+ 4. **Frame Rate**: 30+ FPS recommended
217
+ 5. **Testing**: Start with short clips to verify accuracy
218
+
219
+ ## ✨ Summary
220
+
221
+ Your basketball analysis system now includes comprehensive **shot success rate detection**! This was the #1 missing critical feature, and it's now fully implemented with:
222
+
223
+ - Automatic detection of shot attempts
224
+ - Made vs. missed determination
225
+ - Shot type classification
226
+ - Statistical aggregation
227
+ - Team and personal mode support
228
+ - Detailed documentation and testing tools
229
+
230
+ **Ready to use!** 🏀
TEAM_ANALYSIS_OUTPUTS.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Team Video Analysis - Output Metrics & Analytics
2
+
3
+ This document outlines the comprehensive analysis metrics and data points generated by the **Team Analysis Mode** of the AI Basketball Skill Analysis System.
4
+
5
+ ## 1. General Game Metadata
6
+ - **Total Frames**: Total number of frames processed.
7
+ - **Duration**: Total game time analyzed (seconds/minutes).
8
+ - **Player Count**: Number of unique players detected and tracked.
9
+ - **Team Identification**: Automatic assignment of players to Team 1 or Team 2 based on jersey color analysis.
10
+
11
+ ## 2. Possession & Control
12
+ - **Team Possession Percentage**: Percentage of time each team spent in possession of the ball.
13
+ - **Ball Possession Log**: Frame-by-frame tracking of which specific player ID had control of the ball.
14
+ - **Persistence Handling**: Grace periods for ball occlusions to ensure consistent possession attribution.
15
+
16
+ ## 3. Shooting Analytics (Team & Individual)
17
+ The system analyzes every shot attempt for both teams, providing:
18
+ - **Shot Volume**: Total attempts, makes, and misses.
19
+ - **Shooting Percentage**: Overall and team-specific success rates.
20
+ - **Shot Type Classification**:
21
+ - **Layups**: Close-range shots near the hoop.
22
+ - **Mid-range**: Medium-distance jump shots.
23
+ - **Three-Pointers**: Long-range shots (calibrated using 2D Euclidean distance).
24
+ - **Tactical Shot Quality**:
25
+ - **Contestedness**: Classified as *Wide Open*, *Contested*, or *Heavily Contested* based on defender proximity.
26
+ - **Shot Quality**: Assessment (e.g., *High Quality*, *Average*, *Difficult Make*) based on outcome and pressure.
27
+ - **Shot Breakdown**: Detailed stats per shot type for each team.
28
+
29
+ ## 4. Playmaking & Defense
30
+ - **Pass Detection**: Identifies successful passes between teammates.
31
+ - **Total Passes**: Count per team/game.
32
+ - **Interceptions**: Detects when a defender gains possession of the ball from the opposing team's pass or dribble.
33
+ - **Total Interceptions**: Defensive impact count.
34
+
35
+ ## 5. Movement & Spatial Data
36
+ - **Tactical View Transformation**: Conversion of camera-view coordinates to a top-down 2D court representation.
37
+ - **Speed Tracking**: Real-time speed calculation for all tracked players (meters per second).
38
+ - **Distance Covered**: Cumulative distance traveled by each player throughout the analyzed period.
39
+ - **Court Keypoints**: Detectable landmarks and boundaries of the basketball court for spatial context.
40
+
41
+ ## 6. Visual Overlays (Output Video)
42
+ The analyzed video output includes:
43
+ - **Player Tracking Ellipses**: Color-coded by team (e.g., Grey for Team 1, Red for Team 2).
44
+ - **Possession Indicator**: A triangle above the player currently holding the ball.
45
+ - **Live Scoreboard**: Persistent display of Team 1 vs. Team 2 score and possession dominance.
46
+ - **Made/Missed Notifications**: Contextual notifications showing shot outcome, distance, and contestedness.
47
+ - **Hoop Detection Visuals**: Highlighting the rim and backboard regions used for analysis.
48
+ - **Tactical Map (Optional)**: Mini top-down view showing player positioning.
49
+
50
+ ## 7. Data Structure (API/Json)
51
+ For technical integration, the system returns a JSON object containing:
52
+ - `events`: Array of triggered events (passes, shots, interceptions) with timestamps.
53
+ - `detections`: Raw tracking data for every frame.
54
+ - `shot_details`: Array of objects containing outcome, type, distance, and quality metrics for every shot.
55
+ - `team_1_shot_breakdown` & `team_2_shot_breakdown`: Nested objects with attempts/makes/percentages per type.
56
+ - `duration_seconds`: Calculated total video length.
__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from .ball_aquisition_detector import BallAquisitionDetector
analysis/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Analysis module for video processing pipelines.
3
+ """
4
+ from analysis.dispatcher import dispatch_analysis
5
+ from analysis.team_analysis import run_team_analysis
6
+ from analysis.personal_analysis import run_personal_analysis
7
+
8
+ __all__ = [
9
+ "dispatch_analysis",
10
+ "run_team_analysis",
11
+ "run_personal_analysis",
12
+ ]
analysis/dispatcher.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Analysis Dispatcher - Routes analysis requests to appropriate pipelines.
3
+
4
+ This module dispatches video analysis jobs to either TEAM or PERSONAL
5
+ analysis pipelines based on the analysis_mode configuration.
6
+ """
7
+ import os
8
+ import sys
9
+ import time
10
+ from typing import Dict, Any, Optional
11
+ from datetime import datetime
12
+
13
+ # Add parent dir to path for template imports
14
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
15
+
16
+ from app.models.video import AnalysisMode
17
+
18
+
19
+ async def dispatch_analysis(video_path: str, mode: AnalysisMode, options: Optional[Dict[str, Any]] = None, video_id: Optional[str] = None) -> Dict[str, Any]:
20
+ """
21
+ Dispatch video analysis to the appropriate pipeline.
22
+
23
+ Args:
24
+ video_path: Path to the video file
25
+ mode: Analysis mode (TEAM or PERSONAL)
26
+ video_id: UUID of the video for status updates
27
+
28
+ Returns:
29
+ Dictionary containing analysis results
30
+ """
31
+ start_time = time.time()
32
+
33
+ if mode == AnalysisMode.TEAM:
34
+ from analysis.team_analysis import run_team_analysis
35
+ result = await run_team_analysis(video_path, options=options, video_id=video_id)
36
+ else:
37
+ from analysis.personal_analysis import run_personal_analysis
38
+ result = await run_personal_analysis(video_path, options=options, video_id=video_id)
39
+
40
+ processing_time = time.time() - start_time
41
+
42
+ return {
43
+ **result,
44
+ "processing_time_seconds": processing_time,
45
+ }
46
+
47
+
48
+ def get_video_metrics(video_path: str) -> Dict[str, Any]:
49
+ """
50
+ Extract basic video metrics using OpenCV.
51
+
52
+ Args:
53
+ video_path: Path to the video file
54
+
55
+ Returns:
56
+ Dictionary with fps, frame_count, duration, width, height
57
+ """
58
+ try:
59
+ import cv2
60
+ cap = cv2.VideoCapture(video_path)
61
+
62
+ if not cap.isOpened():
63
+ return {}
64
+
65
+ fps = cap.get(cv2.CAP_PROP_FPS)
66
+ frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
67
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
68
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
69
+ duration = frame_count / fps if fps > 0 else 0
70
+
71
+ cap.release()
72
+
73
+ return {
74
+ "fps": fps,
75
+ "frame_count": frame_count,
76
+ "width": width,
77
+ "height": height,
78
+ "duration_seconds": duration,
79
+ }
80
+ except Exception as e:
81
+ print(f"Error extracting video metrics: {e}")
82
+ return {}
analysis/personal_analysis.py ADDED
@@ -0,0 +1,559 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Personal Analysis Pipeline - Individual skill analysis with pose estimation.
3
+
4
+ This module provides personal training video analysis focused on a single player,
5
+ extracting skill metrics like shot form, dribbling patterns, and movement quality.
6
+ """
7
+ import os
8
+ import sys
9
+ import math
10
+ from typing import Dict, Any, List, Tuple, Optional
11
+
12
+ # Add parent directory for template imports
13
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
14
+
15
+
16
+ async def run_personal_analysis(video_path: str, options: Optional[Dict[str, Any]] = None, video_id: Optional[str] = None) -> Dict[str, Any]:
17
+ """
18
+ Run personal analysis pipeline on a training video.
19
+
20
+ Focuses on a single primary subject and extracts:
21
+ - Pose keypoints and joint angles
22
+ - Shot form analysis and success rate
23
+ - Dribbling patterns
24
+ - Movement metrics (speed, distance)
25
+
26
+ Args:
27
+ video_path: Path to the video file
28
+ options: Optional configuration dict with keys:
29
+ - detections_stride: Frame stride for detections
30
+ - max_detections: Max detections to return
31
+ - detect_shots: Whether to run shot detection (default: True)
32
+
33
+ Returns:
34
+ Dictionary containing personal analysis results
35
+ """
36
+ from utils import read_video
37
+ from app.config import get_settings
38
+ from shot_detector import ShotDetector
39
+ from trackers import BallTracker
40
+ from configs import PERSONAL_MODEL_PATH
41
+ from analysis.skill_diagnostic import SkillDiagnosticService
42
+
43
+ settings = get_settings()
44
+
45
+ # Read video frames
46
+ video_frames = read_video(video_path)
47
+ total_frames = len(video_frames)
48
+
49
+ if total_frames == 0:
50
+ return {
51
+ "error": "Could not read video frames",
52
+ "total_frames": 0,
53
+ }
54
+
55
+ # Get video FPS
56
+ fps = 30
57
+ try:
58
+ import cv2
59
+ cap = cv2.VideoCapture(video_path)
60
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30
61
+ cap.release()
62
+ except:
63
+ pass
64
+
65
+ duration_seconds = total_frames / fps
66
+
67
+ # Initialize YOLO pose model
68
+ try:
69
+ from ultralytics import YOLO
70
+ pose_model = YOLO(settings.pose_model_path)
71
+ has_pose = True
72
+ except Exception as e:
73
+ print(f"Could not load pose model: {e}")
74
+ has_pose = False
75
+
76
+ # Track all detections and select primary subject
77
+ all_detections = []
78
+ player_stats = {} # track_id -> {frames: int, total_area: float, ball_interaction: int}
79
+
80
+ if has_pose:
81
+ # Run pose detection with tracking
82
+ batch_size = 20
83
+ for i in range(0, len(video_frames), batch_size):
84
+ batch = video_frames[i:i+batch_size]
85
+ results = pose_model.track(batch, conf=0.5, classes=[0], persist=True) # Class 0 = person
86
+
87
+ # Check for ball in these frames to find interaction
88
+ # (Heuristic: who is closest to the ball or hoop later)
89
+
90
+ for frame_offset, result in enumerate(results):
91
+ frame_idx = i + frame_offset
92
+
93
+ if result.boxes is not None and len(result.boxes) > 0:
94
+ for j, box in enumerate(result.boxes):
95
+ bbox = box.xyxy[0].tolist()
96
+ track_id = int(box.id[0]) if box.id is not None else -1
97
+ if track_id == -1: continue
98
+
99
+ # Calculate bbox area
100
+ area = (bbox[2] - bbox[0]) * (bbox[3] - bbox[1])
101
+
102
+ if track_id not in player_stats:
103
+ player_stats[track_id] = {'frames': 0, 'total_area': 0, 'interaction_score': 0}
104
+
105
+ player_stats[track_id]['frames'] += 1
106
+ player_stats[track_id]['total_area'] += area
107
+
108
+ # Get keypoints
109
+ keypoints = None
110
+ if result.keypoints is not None and j < len(result.keypoints):
111
+ kp = result.keypoints[j].xy[0].tolist()
112
+ keypoints = kp
113
+
114
+ # Heuristic: person moving their arms significantly or near the center
115
+ # tends to be the player.
116
+ if len(kp) > 10:
117
+ wrist_y = kp[10][1]
118
+ shoulder_y = kp[6][1]
119
+ if wrist_y < shoulder_y: # Arm raised
120
+ player_stats[track_id]['interaction_score'] += 1
121
+
122
+ all_detections.append({
123
+ "frame": frame_idx,
124
+ "track_id": track_id,
125
+ "bbox": bbox,
126
+ "keypoints": keypoints,
127
+ })
128
+
129
+ # Select primary subject (Prioritize interaction > presence > size)
130
+ if player_stats:
131
+ scores = {}
132
+ for tid, stats in player_stats.items():
133
+ presence = stats['frames'] / total_frames
134
+ avg_size = stats['total_area'] / stats['frames']
135
+ interaction = stats['interaction_score'] / stats['frames']
136
+
137
+ # Weighted score
138
+ scores[tid] = (interaction * 1.0) + (presence * 0.5) + (avg_size / 200000 * 0.3)
139
+
140
+ primary_player = max(scores, key=scores.get)
141
+ else:
142
+ primary_player = None
143
+
144
+ # Filter detections for primary player
145
+ primary_detections = [d for d in all_detections if d["track_id"] == primary_player]
146
+
147
+ # Build per-frame detections for overlays (optional, can be large)
148
+ detections_stride = 1
149
+ max_detections = 200_000
150
+ if options:
151
+ try:
152
+ detections_stride = int(options.get("detections_stride", detections_stride))
153
+ except Exception:
154
+ pass
155
+ try:
156
+ max_detections = int(options.get("max_detections", max_detections))
157
+ except Exception:
158
+ pass
159
+
160
+ detections_stride = max(1, min(30, detections_stride))
161
+ max_detections = max(1_000, max_detections)
162
+
163
+ detections: List[Dict[str, Any]] = []
164
+ for det in primary_detections:
165
+ frame_num = int(det.get("frame", 0))
166
+ if frame_num % detections_stride != 0:
167
+ continue
168
+ bbox = det.get("bbox")
169
+ if not bbox:
170
+ continue
171
+ detections.append({
172
+ "frame": frame_num,
173
+ "object_type": "player",
174
+ "track_id": int(det.get("track_id", 0) or 0),
175
+ "bbox": bbox,
176
+ "confidence": 1.0,
177
+ "keypoints": det.get("keypoints"),
178
+ "team_id": None,
179
+ "has_ball": False,
180
+ })
181
+ if len(detections) >= max_detections:
182
+ break
183
+
184
+ # Analyze pose data for skill metrics
185
+ shot_attempts = 0
186
+ form_scores = []
187
+ dribble_count = 0
188
+ positions = []
189
+
190
+ knee_angles = []
191
+ elbow_angles = []
192
+
193
+ for det in primary_detections:
194
+ kp = det.get("keypoints")
195
+ if kp and len(kp) >= 17:
196
+ # Extract key joint positions (COCO keypoint format)
197
+ # 0:nose, 5:left_shoulder, 6:right_shoulder, 7:left_elbow, 8:right_elbow
198
+ # 9:left_wrist, 10:right_wrist, 11:left_hip, 12:right_hip
199
+ # 13:left_knee, 14:right_knee, 15:left_ankle, 16:right_ankle
200
+
201
+ # Calculate knee angle (for shot form)
202
+ left_knee_angle = calculate_angle(kp[11], kp[13], kp[15]) # hip-knee-ankle
203
+ right_knee_angle = calculate_angle(kp[12], kp[14], kp[16])
204
+
205
+ if left_knee_angle:
206
+ knee_angles.append(left_knee_angle)
207
+ if right_knee_angle:
208
+ knee_angles.append(right_knee_angle)
209
+
210
+ # Calculate elbow angle (for shooting form)
211
+ left_elbow_angle = calculate_angle(kp[5], kp[7], kp[9]) # shoulder-elbow-wrist
212
+ right_elbow_angle = calculate_angle(kp[6], kp[8], kp[10])
213
+
214
+ if left_elbow_angle:
215
+ elbow_angles.append(left_elbow_angle)
216
+ if right_elbow_angle:
217
+ elbow_angles.append(right_elbow_angle)
218
+
219
+ # Track wrist position for dribble detection
220
+ left_wrist = kp[9] if len(kp) > 9 else None
221
+ right_wrist = kp[10] if len(kp) > 10 else None
222
+
223
+ # Store position (hip center) for movement tracking
224
+ if len(kp) > 12:
225
+ hip_center = [
226
+ (kp[11][0] + kp[12][0]) / 2,
227
+ (kp[11][1] + kp[12][1]) / 2
228
+ ]
229
+ positions.append({
230
+ "frame": det["frame"],
231
+ "position": hip_center
232
+ })
233
+
234
+ # Detect shot attempts (arm raise events)
235
+ shot_attempts = detect_shot_attempts(primary_detections)
236
+
237
+ # Detect dribbles (rapid vertical wrist movements)
238
+ dribble_count = detect_dribbles(primary_detections)
239
+
240
+ # Calculate movement metrics
241
+ total_distance = 0
242
+ speeds = []
243
+
244
+ for i in range(1, len(positions)):
245
+ prev_pos = positions[i-1]["position"]
246
+ curr_pos = positions[i]["position"]
247
+ frame_diff = positions[i]["frame"] - positions[i-1]["frame"]
248
+
249
+ # Calculate pixel distance
250
+ dist = math.sqrt((curr_pos[0] - prev_pos[0])**2 + (curr_pos[1] - prev_pos[1])**2)
251
+
252
+ # Convert to approximate meters (assuming standard court proportions)
253
+ # This is a rough estimate - 1 pixel ≈ 0.01 meters for normalized view
254
+ dist_meters = dist * 0.01
255
+ total_distance += dist_meters
256
+
257
+ # Calculate speed (m/s)
258
+ if frame_diff > 0:
259
+ time_diff = frame_diff / fps
260
+ speed = dist_meters / time_diff
261
+ speeds.append(speed)
262
+
263
+ # Calculate averages
264
+ avg_speed = sum(speeds) / len(speeds) if speeds else 0
265
+ max_speed = max(speeds) if speeds else 0
266
+
267
+ # Convert to km/h
268
+ avg_speed_kmh = avg_speed * 3.6
269
+ max_speed_kmh = max_speed * 3.6
270
+
271
+ # Calculate form consistency (standard deviation of angles)
272
+ form_consistency = 100 - min(100, calculate_consistency(elbow_angles) * 2)
273
+
274
+ # Calculate averages
275
+ avg_knee_angle = sum(knee_angles) / len(knee_angles) if knee_angles else None
276
+ avg_elbow_angle = sum(elbow_angles) / len(elbow_angles) if elbow_angles else None
277
+
278
+ # Dribbles per minute
279
+ dribble_frequency = (dribble_count / duration_seconds) * 60 if duration_seconds > 0 else 0
280
+
281
+ # Acceleration events (significant speed changes)
282
+ acceleration_events = 0
283
+ for i in range(1, len(speeds)):
284
+ accel = abs(speeds[i] - speeds[i-1])
285
+ if accel > 2: # Threshold for significant acceleration
286
+ acceleration_events += 1
287
+
288
+ # Shot Success Detection
289
+ shot_stats = {
290
+ 'total_attempts': shot_attempts,
291
+ 'total_made': 0,
292
+ 'total_missed': 0,
293
+ 'overall_percentage': 0.0,
294
+ 'by_type': {},
295
+ 'shots': []
296
+ }
297
+
298
+ # Run shot detection if enabled (default: True)
299
+ detect_shots = options.get('detect_shots', True) if options else True
300
+
301
+ if detect_shots:
302
+ try:
303
+ # Check if ball detector model exists
304
+ model_path = PERSONAL_MODEL_PATH
305
+ if model_path is None or not os.path.exists(str(model_path)):
306
+ # Try relative path if absolute fails
307
+ if os.path.exists('models/nbl_v2_combined.pt'):
308
+ model_path = 'models/nbl_v2_combined.pt'
309
+ else:
310
+ model_path = None
311
+
312
+ if model_path is None:
313
+ print(f"Warning: Ball detector model not found, skipping shot detection")
314
+ else:
315
+ # Initialize ball tracker and shot detector
316
+ ball_tracker = BallTracker(model_path)
317
+ shot_detector = ShotDetector(
318
+ hoop_detection_model_path=model_path,
319
+ min_shot_arc_height=50,
320
+ hoop_proximity_threshold=100,
321
+ trajectory_window=30,
322
+ success_time_window=45
323
+ )
324
+
325
+ # Track ball
326
+ ball_tracks = ball_tracker.get_object_tracks(
327
+ video_frames,
328
+ read_from_stub=False
329
+ )
330
+ ball_tracks = ball_tracker.interpolate_ball_positions(ball_tracks)
331
+
332
+ # Detect hoop (if model available, otherwise use heuristics)
333
+ hoop_detections = shot_detector.detect_hoop_locations(
334
+ video_frames,
335
+ read_from_stub=False
336
+ )
337
+
338
+ # Detect and analyze shots
339
+ shots = shot_detector.detect_shots(
340
+ ball_tracks,
341
+ hoop_detections,
342
+ fps=fps
343
+ )
344
+
345
+ # Calculate shot statistics
346
+ shot_stats = shot_detector.calculate_shot_statistics(shots)
347
+
348
+ # Add ball and hoop detections to the main detections list for visualization
349
+ for f_idx, tracks in enumerate(ball_tracks):
350
+ for b_id, b_track in tracks.items():
351
+ if 'bbox' in b_track:
352
+ detections.append({
353
+ "frame": f_idx,
354
+ "object_type": "ball",
355
+ "track_id": b_id,
356
+ "bbox": b_track['bbox']
357
+ })
358
+ for f_idx, hoop in enumerate(hoop_detections):
359
+ if hoop and 'bbox' in hoop:
360
+ detections.append({
361
+ "frame": f_idx,
362
+ "object_type": "hoop",
363
+ "track_id": 0,
364
+ "bbox": hoop['bbox']
365
+ })
366
+ # --- Skill Diagnostic Logic Integration ---
367
+ try:
368
+ diagnostic_service = SkillDiagnosticService()
369
+ # Convert primary_detections to the format expected by the service
370
+ pose_tracks_formatted = [{} for _ in range(total_frames)]
371
+ for det in primary_detections:
372
+ f = det['frame']
373
+ tid = det['track_id']
374
+ if 0 <= f < total_frames:
375
+ pose_tracks_formatted[f][tid] = {'keypoints': det['keypoints']}
376
+
377
+ # Analyze each shot and attach feedback
378
+ coached_shots = []
379
+ for s in shots:
380
+ # Find matching entry angle from shot detector result
381
+ analysis = diagnostic_service.analyze_single_shot(s, pose_tracks_formatted)
382
+ coached_shots.append({
383
+ **s,
384
+ 'biometrics': analysis['biometrics'],
385
+ 'faults': analysis['faults'],
386
+ 'feedback': analysis['feedback']
387
+ })
388
+
389
+ # Update shot_stats to include coached details
390
+ shot_stats['shots'] = coached_shots
391
+
392
+ except Exception as diag_err:
393
+ print(f"Skill Diagnostic failed: {diag_err}")
394
+ # -------------------------------------------
395
+
396
+
397
+ except Exception as e:
398
+ print(f"Shot detection failed: {e}")
399
+ # Keep default shot_stats
400
+
401
+
402
+ # Training load score (composite metric)
403
+ training_load = min(100, (
404
+ (dribble_count * 0.5) +
405
+ (shot_stats['total_attempts'] * 5) +
406
+ (total_distance * 2) +
407
+ (acceleration_events * 1)
408
+ ))
409
+
410
+
411
+ return {
412
+ "total_frames": total_frames,
413
+ "duration_seconds": duration_seconds,
414
+ "primary_player_frames": len(primary_detections),
415
+
416
+ # Skill metrics
417
+ "shot_attempts": shot_stats['total_attempts'],
418
+ "shots_made": shot_stats['total_made'],
419
+ "shots_missed": shot_stats['total_missed'],
420
+ "shot_success_rate": shot_stats['overall_percentage'],
421
+ "shot_form_consistency": round(form_consistency, 1),
422
+ "shot_breakdown_by_type": shot_stats['by_type'],
423
+ "shot_details": shot_stats.get('shots', []),
424
+ "dribble_count": dribble_count,
425
+ "dribble_frequency_per_minute": round(dribble_frequency, 1),
426
+
427
+ # Movement metrics
428
+ "total_distance_meters": round(total_distance, 1),
429
+ "avg_speed_kmh": round(avg_speed_kmh, 1),
430
+ "max_speed_kmh": round(max_speed_kmh, 1),
431
+ "acceleration_events": acceleration_events,
432
+
433
+ # Pose analysis
434
+ "avg_knee_bend_angle": round(avg_knee_angle, 1) if avg_knee_angle else None,
435
+ "avg_elbow_angle_shooting": round(avg_elbow_angle, 1) if avg_elbow_angle else None,
436
+
437
+ # Training load
438
+ "training_load_score": round(training_load, 1),
439
+ "detections": detections,
440
+ }
441
+
442
+
443
+
444
+ def calculate_angle(p1: List[float], p2: List[float], p3: List[float]) -> Optional[float]:
445
+ """
446
+ Calculate angle at p2 given three points.
447
+
448
+ Args:
449
+ p1, p2, p3: Points as [x, y] coordinates
450
+
451
+ Returns:
452
+ Angle in degrees at p2, or None if invalid
453
+ """
454
+ if not all([p1, p2, p3]) or len(p1) < 2 or len(p2) < 2 or len(p3) < 2:
455
+ return None
456
+
457
+ # Check for valid coordinates (not 0,0)
458
+ if p1[0] == 0 and p1[1] == 0:
459
+ return None
460
+ if p2[0] == 0 and p2[1] == 0:
461
+ return None
462
+ if p3[0] == 0 and p3[1] == 0:
463
+ return None
464
+
465
+ try:
466
+ v1 = [p1[0] - p2[0], p1[1] - p2[1]]
467
+ v2 = [p3[0] - p2[0], p3[1] - p2[1]]
468
+
469
+ dot = v1[0] * v2[0] + v1[1] * v2[1]
470
+ mag1 = math.sqrt(v1[0]**2 + v1[1]**2)
471
+ mag2 = math.sqrt(v2[0]**2 + v2[1]**2)
472
+
473
+ if mag1 * mag2 == 0:
474
+ return None
475
+
476
+ cos_angle = dot / (mag1 * mag2)
477
+ cos_angle = max(-1, min(1, cos_angle)) # Clamp to valid range
478
+
479
+ angle = math.degrees(math.acos(cos_angle))
480
+ return angle
481
+ except:
482
+ return None
483
+
484
+
485
+ def calculate_consistency(values: List[float]) -> float:
486
+ """Calculate standard deviation as a measure of consistency."""
487
+ if not values or len(values) < 2:
488
+ return 0
489
+
490
+ mean = sum(values) / len(values)
491
+ variance = sum((x - mean) ** 2 for x in values) / len(values)
492
+ return math.sqrt(variance)
493
+
494
+
495
+ def detect_shot_attempts(detections: List[Dict]) -> int:
496
+ """
497
+ Detect shot attempts by analyzing arm raise patterns.
498
+
499
+ A shot attempt is detected when the wrist rises significantly above
500
+ the shoulder and then drops.
501
+ """
502
+ shots = 0
503
+ arm_raised = False
504
+
505
+ for det in detections:
506
+ kp = det.get("keypoints")
507
+ if not kp or len(kp) < 11:
508
+ continue
509
+
510
+ # Check right arm (more common for right-handed shooters)
511
+ shoulder_y = kp[6][1] if len(kp) > 6 else 0
512
+ wrist_y = kp[10][1] if len(kp) > 10 else 0
513
+
514
+ # Check if wrist is significantly above shoulder (negative Y is up)
515
+ if shoulder_y > 0 and wrist_y > 0:
516
+ if wrist_y < shoulder_y - 50: # Wrist 50+ pixels above shoulder
517
+ if not arm_raised:
518
+ arm_raised = True
519
+ elif wrist_y > shoulder_y:
520
+ if arm_raised:
521
+ shots += 1
522
+ arm_raised = False
523
+
524
+ return shots
525
+
526
+
527
+ def detect_dribbles(detections: List[Dict]) -> int:
528
+ """
529
+ Detect dribbles by analyzing rapid vertical wrist movements.
530
+ """
531
+ dribbles = 0
532
+ prev_wrist_y = None
533
+ direction = None # 'up' or 'down'
534
+
535
+ for det in detections:
536
+ kp = det.get("keypoints")
537
+ if not kp or len(kp) < 11:
538
+ continue
539
+
540
+ # Track dominant hand wrist
541
+ wrist_y = kp[10][1] if len(kp) > 10 and kp[10][1] > 0 else None
542
+
543
+ if wrist_y is None or prev_wrist_y is None:
544
+ prev_wrist_y = wrist_y
545
+ continue
546
+
547
+ diff = wrist_y - prev_wrist_y
548
+
549
+ # Detect direction change (dribble = down then up motion)
550
+ if diff > 10: # Moving down
551
+ if direction == 'up':
552
+ dribbles += 1
553
+ direction = 'down'
554
+ elif diff < -10: # Moving up
555
+ direction = 'up'
556
+
557
+ prev_wrist_y = wrist_y
558
+
559
+ return dribbles
analysis/skill_diagnostic.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import numpy as np
3
+ from typing import Dict, List, Any, Optional
4
+
5
+ class SkillDiagnosticService:
6
+ """
7
+ Analyzes basketball skills (shooting, dribbling) using pose data.
8
+ Implements the 4-phase shot diagnostic logic: DIP, SET, RELEASE, FINISH.
9
+ """
10
+
11
+ def __init__(self):
12
+ # Ideal thresholds for coaching feedback
13
+ self.THRESHOLDS = {
14
+ 'MIN_ELBOW_EXTENSION': 165.0, # Degrees
15
+ 'IDEAL_KNEE_DIP': 125.0, # Degrees
16
+ 'MIN_ARC_ANGLE': 40.0, # Degrees
17
+ 'MAX_ELBOW_FLARE': 15.0 # Degrees from shoulder-wrist line
18
+ }
19
+
20
+ def analyze_shooting_session(self, shots: List[Dict], pose_tracks: List[Dict]) -> List[Dict]:
21
+ """
22
+ Analyzes a full session of shots and provides diagnostic feedback.
23
+ """
24
+ diagnostics = []
25
+ for shot in shots:
26
+ diagnostic = self.analyze_single_shot(shot, pose_tracks)
27
+ diagnostics.append(diagnostic)
28
+ return diagnostics
29
+
30
+ def analyze_single_shot(self, shot: Dict, pose_tracks: List[Dict]) -> Dict:
31
+ """
32
+ Diagnoses a single shot attempt.
33
+ """
34
+ release_frame = shot.get('release_frame')
35
+ outcome = shot.get('outcome')
36
+
37
+ # 1. Identify Keyframes (Dip, Set, Release, Finish)
38
+ keyframes = self._extract_keyframes(shot, pose_tracks)
39
+
40
+ # 2. Calculate Biometrics
41
+ biometrics = self._calculate_biometrics(keyframes)
42
+
43
+ # 3. Detect Faults based on biometrics & outcome
44
+ faults = self._detect_faults(biometrics, outcome)
45
+
46
+ # 4. Synthesize AI Coaching Feedback
47
+ feedback = self._generate_feedback(faults, outcome)
48
+
49
+ return {
50
+ 'shot_id': shot.get('id'),
51
+ 'outcome': outcome,
52
+ 'keyframes': keyframes,
53
+ 'biometrics': biometrics,
54
+ 'faults': faults,
55
+ 'feedback': feedback
56
+ }
57
+
58
+ def _extract_keyframes(self, shot: Dict, pose_tracks: List[Dict]) -> Dict:
59
+ """Extracts the 4 critical frames for a shot."""
60
+ # Simple heuristic implementation - in production this would use AI classification
61
+ release_idx = shot.get('release_frame', 0)
62
+
63
+ # Heuristics for phases (assuming 30fps)
64
+ return {
65
+ 'dip': self._get_pose(pose_tracks, release_idx - 20),
66
+ 'set': self._get_pose(pose_tracks, release_idx - 5),
67
+ 'release': self._get_pose(pose_tracks, release_idx),
68
+ 'finish': self._get_pose(pose_tracks, release_idx + 15)
69
+ }
70
+
71
+ def _calculate_biometrics(self, keyframes: Dict) -> Dict:
72
+ """Calculates angles and positions for keyframes."""
73
+ metrics = {}
74
+
75
+ # Elbow Extension at Release
76
+ release_pose = keyframes.get('release')
77
+ if release_pose:
78
+ metrics['elbow_extension'] = self._calculate_angle(
79
+ release_pose[5], release_pose[7], release_pose[9]
80
+ ) # shoulder-elbow-wrist (left or right depends on player, using dummy for now)
81
+
82
+ # Knee Dip
83
+ dip_pose = keyframes.get('dip')
84
+ if dip_pose:
85
+ metrics['knee_dip'] = self._calculate_angle(
86
+ dip_pose[11], dip_pose[13], dip_pose[15]
87
+ ) # hip-knee-ankle
88
+
89
+ return metrics
90
+
91
+ def _detect_faults(self, biometrics: Dict, outcome: str) -> List[str]:
92
+ """Identifies technical errors."""
93
+ faults = []
94
+ if outcome == 'missed':
95
+ ext = biometrics.get('elbow_extension')
96
+ if ext and ext < self.THRESHOLDS['MIN_ELBOW_EXTENSION']:
97
+ faults.append('SHORT_ARM')
98
+
99
+ dip = biometrics.get('knee_dip')
100
+ if dip and dip > 140:
101
+ faults.append('STIFF_LEGS')
102
+
103
+ return faults
104
+
105
+ def _generate_feedback(self, faults: List[str], outcome: str) -> str:
106
+ """Creates human-readable coaching tips."""
107
+ if outcome == 'made' and not faults:
108
+ return "Great shot! Form is consistent. Keep holding that follow-through."
109
+
110
+ tips = []
111
+ if 'SHORT_ARM' in faults:
112
+ tips.append("Extend your shooting arm fully. You're losing arc by releasing too early.")
113
+ if 'STIFF_LEGS' in faults:
114
+ tips.append("Dip lower into your legs. Your power should come from the ground up.")
115
+
116
+ if not tips and outcome == 'missed':
117
+ return "Good form, just a bit off. Keep practicing the same motion."
118
+
119
+ return " ".join(tips)
120
+
121
+ def _get_pose(self, pose_tracks: List[Dict], frame_idx: int) -> Optional[List]:
122
+ if 0 <= frame_idx < len(pose_tracks):
123
+ # Return first player pose found in that frame for now
124
+ poses = pose_tracks[frame_idx]
125
+ if poses:
126
+ first_id = list(poses.keys())[0]
127
+ return poses[first_id].get('keypoints')
128
+ return None
129
+
130
+ @staticmethod
131
+ def _calculate_angle(p1, p2, p3):
132
+ if not p1 or not p2 or not p3: return None
133
+ try:
134
+ a = np.array(p1[:2])
135
+ b = np.array(p2[:2])
136
+ c = np.array(p3[:2])
137
+
138
+ ba = a - b
139
+ bc = c - b
140
+
141
+ cosine_angle = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc))
142
+ angle = np.arccos(cosine_angle)
143
+ return np.degrees(angle)
144
+ except:
145
+ return None
analysis/team_analysis.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Web API Wrapper for Team Analysis - Uses main.py as the core engine
3
+
4
+ This module provides the async/web interface to the core team analysis logic
5
+ defined in main.py. It handles:
6
+ - Progress updates via database
7
+ - Jersey color customization
8
+ - Stub management options
9
+ """
10
+ import os
11
+ import sys
12
+ import time
13
+ import threading
14
+ from typing import Dict, Any, Optional
15
+
16
+ # Add parent directory for imports
17
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
18
+
19
+ from main import run_team_analysis as core_run_team_analysis
20
+
21
+
22
+ async def run_team_analysis(
23
+ video_path: str,
24
+ options: Optional[Dict[str, Any]] = None,
25
+ video_id: Optional[str] = None
26
+ ) -> Dict[str, Any]:
27
+ """
28
+ Async wrapper for team analysis (Web API interface).
29
+
30
+ Uses the core analysis from main.py with web-specific features:
31
+ - Progress updates to database
32
+ - Jersey color customization
33
+ - Stub management options
34
+
35
+ Args:
36
+ video_path: Path to input video
37
+ options: Analysis options dict with keys:
38
+ - our_team_jersey: Team jersey description
39
+ - opponent_jersey: Opponent jersey description
40
+ - our_team_id: Team ID (1 or 2)
41
+ - read_from_stub: Use cached detections
42
+ - clear_stubs_after: Clear stubs after analysis
43
+ video_id: Video UUID for progress tracking in database
44
+
45
+ Returns:
46
+ Dictionary with analysis results
47
+ """
48
+ import anyio
49
+ from app.services.supabase_client import get_supabase_service
50
+
51
+ supabase = get_supabase_service()
52
+
53
+ # Shared state for progress updates between threads
54
+ progress_state = {"step": "Initializing", "percent": 0, "finished": False}
55
+ progress_lock = threading.Lock()
56
+
57
+ def sync_progress_callback(step: str, percent: int):
58
+ """
59
+ Synchronous callback for progress updates - stores in shared state.
60
+ The async updater will periodically read and push to database.
61
+ """
62
+ # Cap at 99% until the very end to avoid updater exiting early
63
+ db_percent = min(percent, 99)
64
+ with progress_lock:
65
+ progress_state["step"] = step
66
+ progress_state["percent"] = db_percent
67
+ print(f"[{percent}%] {step}")
68
+
69
+ async def background_progress_updater():
70
+ """
71
+ Periodically reads progress state and updates the database.
72
+ Runs in parallel with the analysis thread.
73
+ """
74
+ if not (video_id and supabase):
75
+ return
76
+
77
+ last_update = 0
78
+ while True:
79
+ await anyio.sleep(0.5) # Check every 500ms
80
+
81
+ with progress_lock:
82
+ current_step = progress_state["step"]
83
+ current_percent = progress_state["percent"]
84
+ is_finished = progress_state["finished"]
85
+
86
+ # Update database if progress changed
87
+ if current_percent > last_update:
88
+ try:
89
+ await supabase.update("videos", video_id, {
90
+ "current_step": current_step,
91
+ "progress_percent": current_percent
92
+ })
93
+ last_update = current_percent
94
+ except Exception as e:
95
+ print(f"⚠️ Error updating progress: {e}")
96
+
97
+ # Exit when signaled
98
+ if is_finished:
99
+ break
100
+
101
+ try:
102
+ # Parse options with defaults - MUST come from user input (web form)
103
+ options = options or {}
104
+
105
+ # Jersey colors MUST be provided by user
106
+ our_team_jersey = str(options.get("our_team_jersey") or "").strip()
107
+ opponent_jersey = str(options.get("opponent_jersey") or "").strip()
108
+
109
+ if not our_team_jersey or not opponent_jersey:
110
+ raise ValueError("Jersey colors are required - user must select team colors in the web form")
111
+
112
+ try:
113
+ our_team_id = int(options.get("our_team_id") or 1)
114
+ except Exception:
115
+ our_team_id = 1
116
+ our_team_id = 1 if our_team_id not in (1, 2) else our_team_id
117
+
118
+ read_from_stub = bool(options.get("read_from_stub", False))
119
+ clear_stubs_after = bool(options.get("clear_stubs_after", True))
120
+ save_annotated_video = bool(options.get("save_annotated_video", True))
121
+
122
+ # Detection parameters (from user selections)
123
+ player_confidence = float(options.get("player_confidence", 0.3))
124
+ ball_confidence = float(options.get("ball_confidence", 0.15))
125
+ detection_batch_size = int(options.get("detection_batch_size", 10))
126
+ image_size = int(options.get("image_size", 1080))
127
+ max_players_on_court = int(options.get("max_players_on_court", 10))
128
+
129
+ # Display parameters (from user preferences)
130
+ render_speed_text = bool(options.get("render_speed_text", True))
131
+ render_distance_text = bool(options.get("render_distance_text", True))
132
+ render_tactical_view = bool(options.get("render_tactical_view", True))
133
+ render_court_keypoints = bool(options.get("render_court_keypoints", True))
134
+
135
+ # Output paths (use absolute paths to avoid working directory issues)
136
+ from configs import STUBS_DEFAULT_PATH
137
+ stub_root = os.path.join(STUBS_DEFAULT_PATH, "api", str(video_id or "no-id"))
138
+
139
+ # Create absolute path for output video
140
+ backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
141
+ annotated_dir = os.path.join(backend_dir, "output_videos", "annotated")
142
+ os.makedirs(annotated_dir, exist_ok=True)
143
+ output_path = os.path.join(annotated_dir, f"{video_id or 'latest'}.mp4")
144
+
145
+ # Start progress updater task and analysis in parallel
146
+ async with anyio.create_task_group() as tg:
147
+ # Start the background progress updater
148
+ tg.start_soon(background_progress_updater)
149
+
150
+ # Run the analysis in a thread
151
+ def run_sync():
152
+ """Synchronous wrapper for core analysis."""
153
+ return core_run_team_analysis(
154
+ video_path=video_path,
155
+ output_path=output_path,
156
+ stub_path=stub_root,
157
+ our_team_jersey=our_team_jersey,
158
+ opponent_jersey=opponent_jersey,
159
+ our_team_id=our_team_id,
160
+ read_from_stub=read_from_stub,
161
+ clear_stubs_after=clear_stubs_after,
162
+ save_annotated_video=save_annotated_video,
163
+ progress_callback=sync_progress_callback,
164
+ player_confidence=player_confidence,
165
+ ball_confidence=ball_confidence,
166
+ detection_batch_size=detection_batch_size,
167
+ image_size=image_size,
168
+ max_players_on_court=max_players_on_court,
169
+ render_speed_text=render_speed_text,
170
+ render_distance_text=render_distance_text,
171
+ render_tactical_view=render_tactical_view,
172
+ render_court_keypoints=render_court_keypoints,
173
+ )
174
+
175
+ # Wait for analysis to complete
176
+ result = await anyio.to_thread.run_sync(run_sync)
177
+
178
+ # Run advanced analytics if requested
179
+ if result.get("status") == "completed" and options.get("enable_advanced_analytics", False):
180
+ try:
181
+ sync_progress_callback("Running advanced analytics", 95)
182
+ from analytics_engine import AnalyticsCoordinator
183
+ from utils import read_video
184
+
185
+ # Re-read frames for advanced analytics
186
+ video_frames = await anyio.to_thread.run_sync(read_video, video_path)
187
+
188
+ coordinator = AnalyticsCoordinator()
189
+ advanced_results = await anyio.to_thread.run_sync(
190
+ coordinator.process_all,
191
+ video_frames,
192
+ [], # player_tracks
193
+ [], # ball_tracks
194
+ [], # tactical_positions
195
+ [], # player_assignment
196
+ [], # ball_possession
197
+ result.get("events", []),
198
+ [], # shots
199
+ [], # court_keypoints
200
+ [], # speeds
201
+ video_path,
202
+ result.get("fps", 30.0)
203
+ )
204
+
205
+ result["advanced_analytics"] = advanced_results
206
+ sync_progress_callback("Advanced analytics complete", 98)
207
+ except Exception as e:
208
+ print(f"⚠️ Advanced analytics failed: {e}")
209
+
210
+ # Signal background updater to finish
211
+ with progress_lock:
212
+ progress_state["finished"] = True
213
+
214
+
215
+ # Final progress update — upload annotated video to Supabase Storage first
216
+ annotated_url = None
217
+ if result.get("status") == "completed" and os.path.exists(output_path):
218
+ try:
219
+ sync_progress_callback("Uploading annotated video to cloud", 99)
220
+ BUCKET = "annotated-videos"
221
+ storage_key = f"{video_id}/annotated.mp4"
222
+
223
+ # Ensure the bucket exists (public so frontend can stream directly)
224
+ await supabase.ensure_bucket(BUCKET, public=True)
225
+
226
+ # Stream the file to Supabase Storage
227
+ await supabase.upload_file_from_path(
228
+ bucket=BUCKET,
229
+ storage_path=storage_key,
230
+ local_path=output_path,
231
+ content_type="video/mp4",
232
+ )
233
+
234
+ # Build a permanent public URL (no expiry)
235
+ annotated_url = supabase.get_public_url(BUCKET, storage_key)
236
+ print(f"✅ Annotated video uploaded to Supabase Storage: {annotated_url}")
237
+
238
+ # Remove the local copy to save disk space
239
+ try:
240
+ os.remove(output_path)
241
+ except Exception:
242
+ pass
243
+
244
+ except Exception as upload_err:
245
+ print(f"⚠️ Failed to upload annotated video to Supabase Storage: {upload_err}")
246
+ # Still mark has_annotated True — frontend will fall back to local disk endpoint
247
+
248
+ if video_id and supabase:
249
+ update_payload = {
250
+ "current_step": "Analysis complete",
251
+ "progress_percent": 100,
252
+ "status": result.get("status", "completed"),
253
+ "has_annotated": result.get("status") == "completed",
254
+ }
255
+ if annotated_url:
256
+ update_payload["annotated_url"] = annotated_url
257
+
258
+ await supabase.update("videos", video_id, update_payload)
259
+
260
+ # Ensure all required fields are present (never null)
261
+ if result.get("status") == "completed":
262
+ required_fields = {
263
+ "total_frames": lambda r: r.get("total_frames") or 0,
264
+ "duration_seconds": lambda r: r.get("duration_seconds") or 0.0,
265
+ "players_detected": lambda r: r.get("players_detected") or 0,
266
+ "team_1_possession_percent": lambda r: r.get("team_1_possession_percent") or 50.0,
267
+ "team_2_possession_percent": lambda r: r.get("team_2_possession_percent") or 50.0,
268
+ "total_passes": lambda r: r.get("total_passes") or 0,
269
+ "team_1_passes": lambda r: r.get("team_1_passes") or 0,
270
+ "team_2_passes": lambda r: r.get("team_2_passes") or 0,
271
+ "total_interceptions": lambda r: r.get("total_interceptions") or 0,
272
+ "team_1_interceptions": lambda r: r.get("team_1_interceptions") or 0,
273
+ "team_2_interceptions": lambda r: r.get("team_2_interceptions") or 0,
274
+ "defensive_actions": lambda r: r.get("defensive_actions") or 0,
275
+ "overall_shooting_percentage": lambda r: r.get("overall_shooting_percentage") or 0.0,
276
+ "total_distance_meters": lambda r: r.get("total_distance_meters") or 0.0,
277
+ "avg_speed_kmh": lambda r: r.get("avg_speed_kmh") or 0.0,
278
+ "max_speed_kmh": lambda r: r.get("max_speed_kmh") or 0.0,
279
+ "processing_time_seconds": lambda r: r.get("processing_time_seconds") or 0.0,
280
+ "annotated_video_path": lambda r: r.get("annotated_video_path") or "",
281
+ }
282
+ for field, getter in required_fields.items():
283
+ if field not in result or result[field] is None:
284
+ result[field] = getter(result)
285
+
286
+ return result
287
+
288
+ except Exception as e:
289
+ print(f"❌ Team analysis failed: {e}")
290
+ import traceback
291
+ traceback.print_exc()
292
+
293
+ # Update status to failed
294
+ if video_id and supabase:
295
+ try:
296
+ await supabase.update("videos", video_id, {
297
+ "status": "failed",
298
+ "error": str(e)
299
+ })
300
+ except:
301
+ pass
302
+
303
+ return {
304
+ "status": "failed",
305
+ "error": str(e)
306
+ }
307
+
analysis/team_analysis_old.py ADDED
@@ -0,0 +1,545 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Team Analysis Pipeline - Wraps template components for team video analysis.
3
+
4
+ This module uses the existing template components (PlayerTracker, BallTracker, etc.)
5
+ to analyze multi-player basketball footage for team-level insights.
6
+ """
7
+ import os
8
+ import sys
9
+ import shutil
10
+ from typing import Dict, Any, List, Optional
11
+
12
+ # Add parent directory for template imports
13
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
14
+
15
+
16
+ def clear_video_stubs(stub_root: str) -> bool:
17
+ """
18
+ Clear all stub files for a video to prepare for fresh analysis.
19
+
20
+ Args:
21
+ stub_root: Root directory containing stubs for this video
22
+
23
+ Returns:
24
+ True if successful, False otherwise
25
+ """
26
+ try:
27
+ if os.path.exists(stub_root):
28
+ shutil.rmtree(stub_root)
29
+ os.makedirs(stub_root, exist_ok=True)
30
+ return True
31
+ except Exception as e:
32
+ print(f"⚠️ Warning: Could not clear video stubs at {stub_root}: {e}")
33
+ return False
34
+
35
+ return True
36
+
37
+
38
+ async def run_team_analysis(video_path: str, options: Optional[Dict[str, Any]] = None, video_id: Optional[str] = None) -> Dict[str, Any]:
39
+ """
40
+ Run team analysis pipeline on a video.
41
+ """
42
+ # Import template components (keep aligned with back-end/main.py)
43
+ from utils import read_video, save_video
44
+ from trackers import PlayerTracker, BallTracker
45
+ from team_assigner import TeamAssigner
46
+ from court_keypoint_detector import CourtKeypointDetector
47
+ from ball_aquisition.ball_aquisition_detector import BallAquisitionDetector
48
+ from pass_and_interception_detector import PassAndInterceptionDetector
49
+ from tactical_view_converter import TacticalViewConverter
50
+ from speed_and_distance_calculator import SpeedAndDistanceCalculator
51
+ from shot_detector import ShotDetector
52
+ from drawers import (
53
+ PlayerTracksDrawer,
54
+ BallTracksDrawer,
55
+ FrameNumberDrawer,
56
+ TeamBallControlDrawer,
57
+ PassInterceptionDrawer,
58
+ TacticalViewDrawer,
59
+ SpeedAndDistanceDrawer,
60
+ ShotDrawer,
61
+ )
62
+ from configs import (
63
+ STUBS_DEFAULT_PATH,
64
+ PLAYER_DETECTOR_PATH,
65
+ BALL_DETECTOR_PATH,
66
+ TEAM_MODEL_PATH,
67
+ COURT_KEYPOINT_DETECTOR_PATH,
68
+ )
69
+ from app.services.supabase_client import get_supabase_service
70
+ supabase = get_supabase_service()
71
+
72
+ async def update_progress(step: str, percent: int):
73
+ if video_id and supabase:
74
+ try:
75
+ await supabase.update("videos", video_id, {
76
+ "current_step": step,
77
+ "progress_percent": percent
78
+ })
79
+ except Exception as e:
80
+ print(f"Error updating progress: {e}")
81
+
82
+ await update_progress("Reading video", 5)
83
+ video_frames = read_video(video_path)
84
+ total_frames = len(video_frames)
85
+
86
+ if total_frames == 0:
87
+ return {"error": "Could not read video frames", "total_frames": 0}
88
+
89
+ # Options (align with CLI args in back-end/main.py)
90
+ options = options or {}
91
+ our_team_jersey = str(options.get("our_team_jersey") or "white jersey")
92
+ opponent_jersey = str(options.get("opponent_jersey") or "dark blue jersey")
93
+ try:
94
+ our_team_id = int(options.get("our_team_id") or 1)
95
+ except Exception:
96
+ our_team_id = 1
97
+ our_team_id = 1 if our_team_id not in (1, 2) else our_team_id
98
+ max_players = 10
99
+ try:
100
+ max_players = int(options.get("max_players_on_court") or max_players)
101
+ except Exception:
102
+ pass
103
+ max_players = max(1, min(20, max_players))
104
+
105
+ # Stub management options
106
+ clear_stubs_after = bool(options.get("clear_stubs_after", True))
107
+ read_from_stub = bool(options.get("read_from_stub", False))
108
+
109
+ # Use a per-video stub folder to avoid cross-video contamination.
110
+ stub_root = os.path.join(STUBS_DEFAULT_PATH, "api", str(video_id or "no-id"))
111
+ os.makedirs(stub_root, exist_ok=True)
112
+
113
+ # Initialize trackers/detectors
114
+ await update_progress("Initializing tracking models", 10)
115
+ player_tracker = PlayerTracker(PLAYER_DETECTOR_PATH)
116
+ ball_tracker = BallTracker(BALL_DETECTOR_PATH)
117
+ court_keypoint_detector = CourtKeypointDetector(COURT_KEYPOINT_DETECTOR_PATH)
118
+
119
+ # Detection & tracking (this path already enforces a max player limit inside PlayerTracker)
120
+ await update_progress("Tracking players and referees", 20)
121
+ player_tracks = player_tracker.get_object_tracks(
122
+ video_frames,
123
+ read_from_stub=read_from_stub,
124
+ stub_path=os.path.join(stub_root, "player_track_stubs.pkl"),
125
+ )
126
+
127
+ # Optional: tighten per-frame player cap if caller requests a smaller number than 10
128
+ if max_players < 10:
129
+ capped_tracks: List[Dict[int, Dict[str, Any]]] = []
130
+ for frame_tracks in player_tracks:
131
+ players = [(tid, t) for tid, t in (frame_tracks or {}).items() if str(t.get("class", "")).lower() == "player"]
132
+ refs = [(tid, t) for tid, t in (frame_tracks or {}).items() if str(t.get("class", "")).lower() == "referee"]
133
+ players.sort(key=lambda x: float(x[1].get("confidence", 0.0)), reverse=True)
134
+ keep = {tid: t for tid, t in players[:max_players]}
135
+ for tid, t in refs:
136
+ keep[tid] = t
137
+ capped_tracks.append(keep)
138
+ player_tracks = capped_tracks
139
+
140
+ await update_progress("Detecting and tracking ball", 35)
141
+ ball_tracks = ball_tracker.get_object_tracks(
142
+ video_frames,
143
+ read_from_stub=read_from_stub,
144
+ stub_path=os.path.join(stub_root, "ball_track_stubs.pkl"),
145
+ )
146
+
147
+ # Ball cleaning (improves continuity & reduces false positives)
148
+ ball_tracks = ball_tracker.remove_wrong_detections(ball_tracks)
149
+ ball_tracks = ball_tracker.interpolate_ball_positions(ball_tracks)
150
+
151
+ # Court keypoints
152
+ await update_progress("Detecting court layout", 50)
153
+ court_keypoints = court_keypoint_detector.get_court_keypoints(
154
+ video_frames,
155
+ read_from_stub=read_from_stub,
156
+ stub_path=os.path.join(stub_root, "court_key_points_stub.pkl"),
157
+ )
158
+
159
+ # Team assignment (jersey descriptions are critical for team accounts)
160
+ await update_progress("Assigning players to teams", 65)
161
+ team_assigner = TeamAssigner(team_1_class_name=our_team_jersey, team_2_class_name=opponent_jersey)
162
+ player_assignment = team_assigner.get_player_teams_across_frames(
163
+ video_frames,
164
+ player_tracks,
165
+ read_from_stub=read_from_stub,
166
+ stub_path=os.path.join(stub_root, "player_assignment_stub.pkl"),
167
+ )
168
+
169
+ # Ball possession + events
170
+ await update_progress("Analyzing ball possession", 75)
171
+ ball_aquisition_detector = BallAquisitionDetector()
172
+ ball_possession = ball_aquisition_detector.detect_ball_possession(player_tracks, ball_tracks)
173
+
174
+ await update_progress("Detecting passes and interceptions", 82)
175
+ pass_detector = PassAndInterceptionDetector()
176
+ passes = pass_detector.detect_passes(ball_possession, player_assignment)
177
+ interceptions = pass_detector.detect_interceptions(ball_possession, player_assignment)
178
+
179
+ # Tactical view and speed calculations
180
+ tactical_converter = TacticalViewConverter(court_image_path="./images/basketball_court.png")
181
+ court_keypoints = tactical_converter.validate_keypoints(court_keypoints)
182
+ tactical_positions = tactical_converter.transform_players_to_tactical_view(court_keypoints, player_tracks)
183
+
184
+ # Transform ball to tactical view
185
+ ball_xy_frames = []
186
+ for f_tracks in ball_tracks:
187
+ f_ball = {}
188
+ for b_id, b_data in (f_tracks or {}).items():
189
+ bbox = (b_data or {}).get("bbox")
190
+ if not bbox:
191
+ continue
192
+ f_ball[b_id] = [(bbox[0] + bbox[2]) / 2, bbox[3]] # bottom-center
193
+ ball_xy_frames.append(f_ball)
194
+ tactical_ball_positions = tactical_converter.transform_points_to_tactical(court_keypoints, ball_xy_frames)
195
+
196
+ speed_calculator = SpeedAndDistanceCalculator(
197
+ tactical_converter.width,
198
+ tactical_converter.height,
199
+ tactical_converter.actual_width_in_meters,
200
+ tactical_converter.actual_height_in_meters
201
+ )
202
+ distances = speed_calculator.calculate_distance(tactical_positions)
203
+ speeds = speed_calculator.calculate_speed(distances)
204
+
205
+ # Calculate team possession percentages
206
+ team_1_possession = 0
207
+ team_2_possession = 0
208
+
209
+ for frame_idx, (possession, assignment) in enumerate(zip(ball_possession, player_assignment)):
210
+ if possession != -1 and possession in assignment:
211
+ team = assignment[possession]
212
+ if team == 1:
213
+ team_1_possession += 1
214
+ elif team == 2:
215
+ team_2_possession += 1
216
+
217
+ total_possession = team_1_possession + team_2_possession
218
+ team_1_pct = (team_1_possession / total_possession * 100) if total_possession > 0 else 50
219
+ team_2_pct = (team_2_possession / total_possession * 100) if total_possession > 0 else 50
220
+
221
+ # Count unique tracked entities (players + referees)
222
+ unique_players = set()
223
+ for frame_tracks in player_tracks:
224
+ unique_players.update((frame_tracks or {}).keys())
225
+
226
+ # Shot Detection and Analysis (also gives hoop locations for overlays)
227
+ await update_progress("Detecting hoop and shots", 88)
228
+ try:
229
+ shot_detector = ShotDetector(
230
+ hoop_detection_model_path=TEAM_MODEL_PATH,
231
+ min_shot_arc_height=50,
232
+ hoop_proximity_threshold=100,
233
+ trajectory_window=30,
234
+ success_time_window=15
235
+ )
236
+
237
+ hoop_detections = shot_detector.detect_hoop_locations(
238
+ video_frames,
239
+ read_from_stub=read_from_stub,
240
+ stub_path=os.path.join(stub_root, "hoop_detections_stub.pkl"),
241
+ )
242
+
243
+ # Get video FPS
244
+ fps = 30 # Default
245
+ try:
246
+ import cv2
247
+ cap = cv2.VideoCapture(video_path)
248
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30
249
+ cap.release()
250
+ except:
251
+ pass
252
+
253
+ # Detect shots
254
+ shots = shot_detector.detect_shots(
255
+ ball_tracks,
256
+ hoop_detections,
257
+ player_tracks=player_tracks,
258
+ player_assignment=player_assignment,
259
+ ball_possession=ball_possession,
260
+ fps=fps,
261
+ court_keypoints=court_keypoints
262
+ )
263
+
264
+ # Calculate overall shot statistics
265
+ shot_stats = shot_detector.calculate_shot_statistics(shots)
266
+
267
+ # Break down shots by team
268
+ team_1_shots = []
269
+ team_2_shots = []
270
+
271
+ for shot in shots:
272
+ start_frame = int(shot['start_frame'])
273
+ if start_frame < len(ball_possession) and start_frame < len(player_assignment):
274
+ player_with_ball = ball_possession[start_frame]
275
+ if player_with_ball != -1 and player_with_ball in player_assignment[start_frame]:
276
+ team = player_assignment[start_frame][player_with_ball]
277
+ # Update the shot in the main list so it's attribution stays for events
278
+ shot['team_id'] = int(team)
279
+ shot['player_id'] = int(player_with_ball)
280
+
281
+ if team == 1:
282
+ team_1_shots.append(shot)
283
+ elif team == 2:
284
+ team_2_shots.append(shot)
285
+
286
+ team_1_shot_stats = shot_detector.calculate_shot_statistics(team_1_shots)
287
+ team_2_shot_stats = shot_detector.calculate_shot_statistics(team_2_shots)
288
+
289
+ except Exception as e:
290
+ print(f"Shot detection failed: {e}")
291
+ hoop_detections = [None for _ in range(total_frames)]
292
+ shot_stats = {
293
+ 'total_attempts': 0,
294
+ 'total_made': 0,
295
+ 'total_missed': 0,
296
+ 'overall_percentage': 0.0,
297
+ 'by_type': {},
298
+ 'shots': []
299
+ }
300
+ team_1_shot_stats = shot_stats.copy()
301
+ team_2_shot_stats = shot_stats.copy()
302
+
303
+ # Render annotated output video (clean, consistent overlays)
304
+ await update_progress("Rendering annotated video", 92)
305
+ try:
306
+ if our_team_id == 1:
307
+ our_color = [0, 120, 255]
308
+ opp_color = [0, 0, 200]
309
+ else:
310
+ our_color = [0, 0, 200]
311
+ opp_color = [0, 120, 255]
312
+
313
+ player_tracks_drawer = PlayerTracksDrawer(team_1_color=our_color, team_2_color=opp_color)
314
+ ball_tracks_drawer = BallTracksDrawer()
315
+ frame_number_drawer = FrameNumberDrawer()
316
+ team_ball_control_drawer = TeamBallControlDrawer()
317
+ pass_and_interceptions_drawer = PassInterceptionDrawer()
318
+ tactical_view_drawer = TacticalViewDrawer()
319
+ speed_and_distance_drawer = SpeedAndDistanceDrawer()
320
+ shot_drawer = ShotDrawer()
321
+
322
+ output_video_frames = player_tracks_drawer.draw(video_frames, player_tracks, player_assignment, ball_possession)
323
+ output_video_frames = ball_tracks_drawer.draw(output_video_frames, ball_tracks)
324
+ output_video_frames = frame_number_drawer.draw(output_video_frames)
325
+ output_video_frames = team_ball_control_drawer.draw(output_video_frames, player_assignment, ball_possession)
326
+ output_video_frames = pass_and_interceptions_drawer.draw(output_video_frames, passes, interceptions)
327
+ output_video_frames = shot_drawer.draw(output_video_frames, shots, hoop_detections=hoop_detections)
328
+ output_video_frames = speed_and_distance_drawer.draw(output_video_frames, player_tracks, distances, speeds)
329
+ output_video_frames = tactical_view_drawer.draw(
330
+ output_video_frames,
331
+ tactical_converter.court_image_path,
332
+ tactical_converter.width,
333
+ tactical_converter.height,
334
+ tactical_converter.key_points,
335
+ tactical_positions,
336
+ player_assignment,
337
+ ball_possession,
338
+ )
339
+
340
+ annotated_path = os.path.join("output_videos", "annotated", f"{video_id or 'latest'}.mp4")
341
+ save_video(output_video_frames, annotated_path)
342
+ except Exception as e:
343
+ print(f"Annotated video rendering failed: {e}")
344
+
345
+ # Build per-frame detections for overlays (optional, can be large)
346
+ detections_stride = 1
347
+ max_detections = 200_000
348
+ if options:
349
+ try:
350
+ detections_stride = int(options.get("detections_stride", detections_stride))
351
+ except Exception:
352
+ pass
353
+ try:
354
+ max_detections = int(options.get("max_detections", max_detections))
355
+ except Exception:
356
+ pass
357
+
358
+ detections_stride = max(1, min(30, detections_stride))
359
+ max_detections = max(1_000, max_detections)
360
+
361
+ detections: List[Dict[str, Any]] = []
362
+ for frame_idx in range(0, total_frames, detections_stride):
363
+ # Players
364
+ assignment = player_assignment[frame_idx] if frame_idx < len(player_assignment) else {}
365
+ possession_player = ball_possession[frame_idx] if frame_idx < len(ball_possession) else -1
366
+ current_player_tracks = player_tracks[frame_idx] if frame_idx < len(player_tracks) else []
367
+ for track_id, track in (current_player_tracks or {}).items():
368
+ bbox = track.get("bbox")
369
+ if not bbox:
370
+ continue
371
+ # Get tactical position for this player in this frame
372
+ current_tactical = tactical_positions[frame_idx] if frame_idx < len(tactical_positions) else {}
373
+ player_tactical = current_tactical.get(track_id)
374
+
375
+ detections.append({
376
+ "frame": int(frame_idx),
377
+ "object_type": str(track.get("class", "player")),
378
+ "track_id": int(track_id),
379
+ "bbox": [float(b) for b in bbox] if bbox else None,
380
+ "confidence": float(track.get("confidence", 1.0)),
381
+ "team_id": int(assignment.get(track_id)) if assignment.get(track_id) is not None else None,
382
+ "has_ball": bool(possession_player == track_id),
383
+ "tactical_x": float(player_tactical[0]) if player_tactical else None,
384
+ "tactical_y": float(player_tactical[1]) if player_tactical else None,
385
+ "keypoints": None,
386
+ })
387
+ if len(detections) >= max_detections:
388
+ break
389
+
390
+ if len(detections) >= max_detections:
391
+ break
392
+
393
+ # Ball
394
+ current_ball_tracks = ball_tracks[frame_idx] if frame_idx < len(ball_tracks) else []
395
+ ball = (current_ball_tracks or {}).get(1)
396
+ if ball and ball.get("bbox"):
397
+ # Get tactical ball position
398
+ current_ball_tactical = tactical_ball_positions[frame_idx] if frame_idx < len(tactical_ball_positions) else {}
399
+ ball_tactical = current_ball_tactical.get(1)
400
+
401
+ detections.append({
402
+ "frame": int(frame_idx),
403
+ "object_type": "ball",
404
+ "track_id": 1,
405
+ "bbox": [float(b) for b in ball.get("bbox")] if ball.get("bbox") else None,
406
+ "confidence": float(ball.get("confidence", 1.0)),
407
+ "team_id": None,
408
+ "has_ball": False,
409
+ "tactical_x": float(ball_tactical[0]) if ball_tactical else None,
410
+ "tactical_y": float(ball_tactical[1]) if ball_tactical else None,
411
+ "keypoints": None,
412
+ })
413
+ # Hoops
414
+ hoop = hoop_detections[frame_idx] if frame_idx < len(hoop_detections) else None
415
+ if hoop and hoop.get("bbox"):
416
+ detections.append({
417
+ "frame": int(frame_idx),
418
+ "object_type": "hoop",
419
+ "track_id": 0,
420
+ "bbox": [float(b) for b in hoop.get("bbox")] if hoop.get("bbox") else None,
421
+ "confidence": float(hoop.get("confidence", 1.0)),
422
+ "team_id": None,
423
+ "has_ball": False,
424
+ "keypoints": None,
425
+ })
426
+
427
+ # Build events list
428
+ events = []
429
+ for frame_num, team_id in enumerate(passes):
430
+ if team_id != -1:
431
+ events.append({
432
+ "event_type": "pass",
433
+ "frame": frame_num,
434
+ "timestamp_seconds": frame_num / fps,
435
+ "details": {"team": team_id}
436
+ })
437
+
438
+ for frame_num, team_id in enumerate(interceptions):
439
+ if team_id != -1:
440
+ events.append({
441
+ "event_type": "interception",
442
+ "frame": frame_num,
443
+ "timestamp_seconds": frame_num / fps,
444
+ "details": {"team": team_id}
445
+ })
446
+
447
+ # Add shots to events
448
+ for shot in shots:
449
+ events.append({
450
+ "event_type": "shot",
451
+ "frame": int(shot['start_frame']),
452
+ "timestamp_seconds": float(shot['start_frame'] / fps),
453
+ "details": {
454
+ "outcome": str(shot['outcome']),
455
+ "team": int(shot.get('team_id')) if shot.get('team_id') is not None else None,
456
+ "player": int(shot.get('player_id')) if shot.get('player_id') is not None else None,
457
+ "type": str(shot.get('shot_type', 'unknown'))
458
+ }
459
+ })
460
+
461
+ # Get video duration
462
+ fps = 30 # Default assumption
463
+ try:
464
+ import cv2
465
+ cap = cv2.VideoCapture(video_path)
466
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30
467
+ cap.release()
468
+ except:
469
+ pass
470
+
471
+ duration_seconds = total_frames / fps
472
+
473
+ # ============================================
474
+ # ADVANCED ANALYTICS (OPT-IN)
475
+ # ============================================
476
+ advanced_analytics = None
477
+ if options and options.get("enable_advanced_analytics", False):
478
+ try:
479
+ print("Running advanced analytics...")
480
+ from analytics_engine import AnalyticsCoordinator
481
+
482
+ coordinator = AnalyticsCoordinator()
483
+ advanced_analytics = coordinator.process_all(
484
+ video_frames=video_frames,
485
+ player_tracks=player_tracks,
486
+ ball_tracks=ball_tracks,
487
+ tactical_positions=tactical_positions,
488
+ player_assignment=player_assignment,
489
+ ball_possession=ball_possession,
490
+ events=events,
491
+ shots=shots,
492
+ court_keypoints=court_keypoints,
493
+ speeds=speeds,
494
+ video_path=video_path,
495
+ fps=fps
496
+ )
497
+ print(f"Advanced analytics complete: {len(advanced_analytics.get('modules_executed', []))} modules succeeded")
498
+ except Exception as e:
499
+ print(f"Advanced analytics failed: {e}")
500
+ advanced_analytics = {"error": str(e), "status": "failed"}
501
+
502
+ # Build result dictionary
503
+ result = {
504
+ "total_frames": int(total_frames),
505
+ "duration_seconds": float(duration_seconds),
506
+ "players_detected": int(len(unique_players)),
507
+ "team_1_possession_percent": float(round(team_1_pct, 1)),
508
+ "team_2_possession_percent": float(round(team_2_pct, 1)),
509
+ "total_passes": int(len([p for p in passes if p != -1])),
510
+ "total_interceptions": int(len([i for i in interceptions if i != -1])),
511
+
512
+ # Shot statistics
513
+ "shot_attempts": int(shot_stats['total_attempts']),
514
+ "shots_made": int(shot_stats['total_made']),
515
+ "shots_missed": int(shot_stats['total_missed']),
516
+ "shooting_percentage": float(shot_stats['overall_percentage']),
517
+ "shot_breakdown_by_type": shot_stats['by_type'],
518
+
519
+ # Team 1 shooting
520
+ "team_1_shot_attempts": int(team_1_shot_stats['total_attempts']),
521
+ "team_1_shots_made": int(team_1_shot_stats['total_made']),
522
+
523
+ # Team 2 shooting
524
+ "team_2_shot_attempts": int(team_2_shot_stats['total_attempts']),
525
+ "team_2_shots_made": int(team_2_shot_stats['total_made']),
526
+
527
+ "events": events,
528
+ "detections": detections,
529
+ }
530
+
531
+ # Add advanced analytics if available
532
+ if advanced_analytics:
533
+ result["advanced_analytics"] = advanced_analytics
534
+
535
+ # Clear stubs if requested (ensures fresh analysis on next run)
536
+ if clear_stubs_after:
537
+ await update_progress("Cleaning up cached data", 98)
538
+ try:
539
+ clear_video_stubs(stub_root)
540
+ print(f"✅ Cleared stubs for video {video_id or 'unknown'}")
541
+ except Exception as e:
542
+ print(f"⚠️ Could not clear stubs: {e}")
543
+
544
+ return result
545
+
analytics_engine/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Advanced Analytics Engine for Basketball Video Analysis.
3
+
4
+ This package provides 7 analytics modules that use geometric reasoning
5
+ and time-series analysis to extract advanced insights from tracking data.
6
+ """
7
+
8
+ from .coordinator import AnalyticsCoordinator
9
+ from .spacing_engine import SpacingEngine
10
+ from .defensive_reaction import DefensiveReactionEngine
11
+ from .transition_effort import TransitionEffortEngine
12
+ from .decision_quality import DecisionQualityEngine
13
+ from .lineup_impact import LineupImpactEngine
14
+ from .fatigue_tracker import FatigueTracker
15
+ from .clip_generator import ClipGenerator
16
+
17
+ __all__ = [
18
+ "AnalyticsCoordinator",
19
+ "SpacingEngine",
20
+ "DefensiveReactionEngine",
21
+ "TransitionEffortEngine",
22
+ "DecisionQualityEngine",
23
+ "LineupImpactEngine",
24
+ "FatigueTracker",
25
+ "ClipGenerator",
26
+ ]
analytics_engine/base.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Base class for all analytics modules.
3
+
4
+ Provides common interface and error handling for analytics processing.
5
+ """
6
+ from abc import ABC, abstractmethod
7
+ from typing import Dict, Any, List, Optional
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class BaseAnalyticsModule(ABC):
14
+ """Abstract base class for analytics modules."""
15
+
16
+ def __init__(self, module_name: str):
17
+ """
18
+ Initialize the analytics module.
19
+
20
+ Args:
21
+ module_name: Name of the module for logging
22
+ """
23
+ self.module_name = module_name
24
+ self.logger = logging.getLogger(f"analytics_engine.{module_name}")
25
+
26
+ @abstractmethod
27
+ def process(
28
+ self,
29
+ video_frames: List[Any],
30
+ player_tracks: List[Dict],
31
+ ball_tracks: List[Dict],
32
+ tactical_positions: List[Dict],
33
+ player_assignment: List[Dict],
34
+ ball_possession: List[int],
35
+ events: List[Dict],
36
+ shots: List[Dict],
37
+ court_keypoints: List[Dict],
38
+ speeds: List[Dict],
39
+ video_path: str,
40
+ fps: float,
41
+ **kwargs
42
+ ) -> Dict[str, Any]:
43
+ """
44
+ Process analytics for the given video data.
45
+
46
+ Args:
47
+ video_frames: List of video frames
48
+ player_tracks: Per-frame player tracking data
49
+ ball_tracks: Per-frame ball tracking data
50
+ tactical_positions: 2D court positions for players
51
+ player_assignment: Per-frame team assignments
52
+ ball_possession: Per-frame ball possession (track_id or -1)
53
+ events: List of detected events (passes, interceptions, etc.)
54
+ shots: List of detected shots with metadata
55
+ court_keypoints: Court boundary keypoints
56
+ speeds: Per-frame player speeds
57
+ video_path: Path to original video file
58
+ fps: Video frames per second
59
+ **kwargs: Additional module-specific parameters
60
+
61
+ Returns:
62
+ Dictionary containing module-specific analytics results
63
+ """
64
+ pass
65
+
66
+ def safe_process(self, *args, **kwargs) -> Dict[str, Any]:
67
+ """
68
+ Safely execute process() with error handling.
69
+
70
+ Returns partial results on error instead of crashing.
71
+ """
72
+ try:
73
+ return self.process(*args, **kwargs)
74
+ except Exception as e:
75
+ self.logger.error(f"{self.module_name} processing failed: {e}", exc_info=True)
76
+ return {
77
+ "error": str(e),
78
+ "module": self.module_name,
79
+ "status": "failed"
80
+ }
81
+
82
+ def _get_frame_time(self, frame_idx: int, fps: float) -> float:
83
+ """Convert frame index to timestamp in seconds."""
84
+ return frame_idx / fps if fps > 0 else 0.0
85
+
86
+ def _euclidean_distance(self, pos1: List[float], pos2: List[float]) -> float:
87
+ """Calculate Euclidean distance between two 2D positions."""
88
+ if not pos1 or not pos2 or len(pos1) < 2 or len(pos2) < 2:
89
+ return float('inf')
90
+ return ((pos1[0] - pos2[0]) ** 2 + (pos1[1] - pos2[1]) ** 2) ** 0.5
analytics_engine/clip_generator.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Clip Generator - Automatically extracts coaching highlight clips.
3
+
4
+ Creates short video clips (±5 seconds) around flagged events for coaching review.
5
+ """
6
+ from typing import Dict, Any, List
7
+ import os
8
+ import subprocess
9
+ from .base import BaseAnalyticsModule
10
+
11
+
12
+ class ClipGenerator(BaseAnalyticsModule):
13
+ """Generates coaching highlight clips from flagged events."""
14
+
15
+ def __init__(
16
+ self,
17
+ clip_duration_seconds: float = 10.0, # Total duration (5s before + 5s after)
18
+ output_base_dir: str = "output_videos/clips",
19
+ ):
20
+ """
21
+ Initialize clip generator.
22
+
23
+ Args:
24
+ clip_duration_seconds: Total duration of each clip
25
+ output_base_dir: Base directory for clip output
26
+ """
27
+ super().__init__("clip_generator")
28
+ self.clip_duration = clip_duration_seconds
29
+ self.output_base_dir = output_base_dir
30
+
31
+ def process(
32
+ self,
33
+ video_frames: List[Any],
34
+ player_tracks: List[Dict],
35
+ ball_tracks: List[Dict],
36
+ tactical_positions: List[Dict],
37
+ player_assignment: List[Dict],
38
+ ball_possession: List[int],
39
+ events: List[Dict],
40
+ shots: List[Dict],
41
+ court_keypoints: List[Dict],
42
+ speeds: List[Dict],
43
+ video_path: str,
44
+ fps: float,
45
+ **kwargs
46
+ ) -> Dict[str, Any]:
47
+ """
48
+ Generate coaching clips for flagged events.
49
+
50
+ Returns:
51
+ Dictionary with clip metadata
52
+ """
53
+ # Get analytics results from kwargs
54
+ spacing_metrics = kwargs.get("spacing_metrics", [])
55
+ defensive_reactions = kwargs.get("defensive_reactions", [])
56
+ transition_efforts = kwargs.get("transition_efforts", [])
57
+ decision_analyses = kwargs.get("decision_analyses", [])
58
+
59
+ # Identify events to clip
60
+ clip_events = []
61
+
62
+ # Poor spacing events
63
+ poor_spacing_frames = [
64
+ m for m in spacing_metrics
65
+ if m.get("spacing_quality") == "poor"
66
+ ]
67
+ # Sample every Nth poor spacing event to avoid too many clips
68
+ for i, m in enumerate(poor_spacing_frames):
69
+ if i % 10 == 0: # Every 10th poor spacing frame
70
+ clip_events.append({
71
+ "type": "poor_spacing",
72
+ "frame": m["frame"],
73
+ "timestamp": m["timestamp"],
74
+ "players_involved": list(m.get("player_positions", {}).keys()),
75
+ "metadata": {
76
+ "spacing_quality": m["spacing_quality"],
77
+ "avg_distance_m": m["avg_distance_m"],
78
+ "paint_players": m["paint_players"]
79
+ }
80
+ })
81
+
82
+ # Late rotation events
83
+ late_rotations = [
84
+ r for r in defensive_reactions
85
+ if r.get("late_closeout", False)
86
+ ]
87
+ for r in late_rotations[:20]: # Limit to 20 clips
88
+ clip_events.append({
89
+ "type": "late_rotation",
90
+ "frame": r["event_frame"],
91
+ "timestamp": r["event_frame"] / fps if fps > 0 else 0,
92
+ "players_involved": [r["defender_track_id"], r["offensive_player_track_id"]],
93
+ "metadata": {
94
+ "reaction_delay_ms": r.get("reaction_delay_ms"),
95
+ "closeout_speed_mps": r.get("closeout_speed_mps")
96
+ }
97
+ })
98
+
99
+ # Poor transition effort
100
+ poor_transitions = [
101
+ t for t in transition_efforts
102
+ if t.get("effort_type") == "walk"
103
+ ]
104
+ for t in poor_transitions[:15]: # Limit to 15 clips
105
+ clip_events.append({
106
+ "type": "poor_transition",
107
+ "frame": t["possession_change_frame"],
108
+ "timestamp": t["possession_change_frame"] / fps if fps > 0 else 0,
109
+ "players_involved": [t["player_track_id"]],
110
+ "metadata": {
111
+ "effort_type": t["effort_type"],
112
+ "max_speed_mps": t["max_speed_mps"],
113
+ "effort_score": t["effort_score"]
114
+ }
115
+ })
116
+
117
+ # Low decision quality shots
118
+ low_ev_shots = [
119
+ d for d in decision_analyses
120
+ if d.get("decision_quality") == "low_expected_value"
121
+ ]
122
+ for d in low_ev_shots[:20]: # Limit to 20 clips
123
+ clip_events.append({
124
+ "type": "low_decision_quality",
125
+ "frame": d["shot_frame"],
126
+ "timestamp": d["shot_frame"] / fps if fps > 0 else 0,
127
+ "players_involved": [d["shooter_track_id"]],
128
+ "metadata": {
129
+ "decision_quality": d["decision_quality"],
130
+ "open_teammates": d["open_teammates"],
131
+ "shooter_contested_distance": d["shooter_contested_distance"]
132
+ }
133
+ })
134
+
135
+ # Generate clips using ffmpeg
136
+ auto_clips = []
137
+ video_id = os.path.splitext(os.path.basename(video_path))[0]
138
+ clip_dir = os.path.join(self.output_base_dir, video_id)
139
+
140
+ # Create output directory
141
+ os.makedirs(clip_dir, exist_ok=True)
142
+
143
+ half_duration = self.clip_duration / 2
144
+
145
+ for i, event in enumerate(clip_events):
146
+ timestamp = event["timestamp"]
147
+ clip_type = event["type"]
148
+
149
+ # Calculate start and end times
150
+ start_time = max(0, timestamp - half_duration)
151
+ end_time = timestamp + half_duration
152
+
153
+ # Generate clip filename
154
+ clip_filename = f"{clip_type}_{int(timestamp)}.mp4"
155
+ clip_path = os.path.join(clip_dir, clip_filename)
156
+
157
+ # Extract clip using ffmpeg
158
+ success = self._extract_clip(
159
+ video_path,
160
+ clip_path,
161
+ start_time,
162
+ end_time
163
+ )
164
+
165
+ if success:
166
+ # Generate description
167
+ description = self._generate_description(event)
168
+
169
+ auto_clips.append({
170
+ "clip_type": clip_type,
171
+ "timestamp_start": float(start_time),
172
+ "timestamp_end": float(end_time),
173
+ "frame_start": int(start_time * fps) if fps > 0 else 0,
174
+ "frame_end": int(end_time * fps) if fps > 0 else 0,
175
+ "players_involved": [int(p) for p in event["players_involved"] if isinstance(p, (int, str))],
176
+ "file_path": clip_path,
177
+ "description": description,
178
+ "metadata": event["metadata"]
179
+ })
180
+
181
+ # Calculate summary
182
+ clip_type_counts = {}
183
+ for clip in auto_clips:
184
+ clip_type = clip["clip_type"]
185
+ clip_type_counts[clip_type] = clip_type_counts.get(clip_type, 0) + 1
186
+
187
+ summary = {
188
+ "total_clips_generated": len(auto_clips),
189
+ "clips_by_type": clip_type_counts,
190
+ "output_directory": clip_dir
191
+ }
192
+
193
+ return {
194
+ "auto_clips": auto_clips,
195
+ "summary": summary,
196
+ "status": "success"
197
+ }
198
+
199
+ def _extract_clip(
200
+ self,
201
+ video_path: str,
202
+ output_path: str,
203
+ start_time: float,
204
+ end_time: float
205
+ ) -> bool:
206
+ """
207
+ Extract a video clip using ffmpeg.
208
+
209
+ Args:
210
+ video_path: Path to source video
211
+ output_path: Path for output clip
212
+ start_time: Start time in seconds
213
+ end_time: End time in seconds
214
+
215
+ Returns:
216
+ True if successful, False otherwise
217
+ """
218
+ try:
219
+ duration = end_time - start_time
220
+
221
+ # ffmpeg command
222
+ cmd = [
223
+ "ffmpeg",
224
+ "-i", video_path,
225
+ "-ss", str(start_time),
226
+ "-t", str(duration),
227
+ "-c:v", "libx264",
228
+ "-c:a", "aac",
229
+ "-y", # Overwrite output file
230
+ output_path
231
+ ]
232
+
233
+ # Run ffmpeg (suppress output)
234
+ result = subprocess.run(
235
+ cmd,
236
+ stdout=subprocess.DEVNULL,
237
+ stderr=subprocess.DEVNULL,
238
+ timeout=30
239
+ )
240
+
241
+ return result.returncode == 0
242
+ except Exception as e:
243
+ self.logger.error(f"Failed to extract clip: {e}")
244
+ return False
245
+
246
+ def _generate_description(self, event: Dict) -> str:
247
+ """Generate human-readable description for clip."""
248
+ clip_type = event["type"]
249
+ metadata = event.get("metadata", {})
250
+
251
+ if clip_type == "poor_spacing":
252
+ return f"Poor offensive spacing: {metadata.get('paint_players', 0)} players in paint, avg distance {metadata.get('avg_distance_m', 0):.1f}m"
253
+ elif clip_type == "late_rotation":
254
+ return f"Late defensive rotation: {metadata.get('reaction_delay_ms', 0):.0f}ms delay, {metadata.get('closeout_speed_mps', 0):.1f}m/s closeout"
255
+ elif clip_type == "poor_transition":
256
+ return f"Low transition effort: {metadata.get('effort_type', 'unknown')} at {metadata.get('max_speed_mps', 0):.1f}m/s"
257
+ elif clip_type == "low_decision_quality":
258
+ return f"Questionable shot selection: {metadata.get('open_teammates', 0)} open teammates, shooter contested at {metadata.get('shooter_contested_distance', 0):.1f}m"
259
+ else:
260
+ return f"Flagged event: {clip_type}"
analytics_engine/coordinator.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Analytics Coordinator - Orchestrates all analytics modules.
3
+
4
+ Runs all 7 analytics modules in the correct order and aggregates results.
5
+ """
6
+ from typing import Dict, Any, List
7
+ import logging
8
+
9
+ from .spacing_engine import SpacingEngine
10
+ from .defensive_reaction import DefensiveReactionEngine
11
+ from .transition_effort import TransitionEffortEngine
12
+ from .decision_quality import DecisionQualityEngine
13
+ from .lineup_impact import LineupImpactEngine
14
+ from .fatigue_tracker import FatigueTracker
15
+ from .clip_generator import ClipGenerator
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class AnalyticsCoordinator:
21
+ """Coordinates execution of all advanced analytics modules."""
22
+
23
+ def __init__(self):
24
+ """Initialize all analytics modules."""
25
+ self.spacing_engine = SpacingEngine()
26
+ self.defensive_reaction = DefensiveReactionEngine()
27
+ self.transition_effort = TransitionEffortEngine()
28
+ self.decision_quality = DecisionQualityEngine()
29
+ self.lineup_impact = LineupImpactEngine()
30
+ self.fatigue_tracker = FatigueTracker()
31
+ self.clip_generator = ClipGenerator()
32
+
33
+ def process_all(
34
+ self,
35
+ video_frames: List[Any],
36
+ player_tracks: List[Dict],
37
+ ball_tracks: List[Dict],
38
+ tactical_positions: List[Dict],
39
+ player_assignment: List[Dict],
40
+ ball_possession: List[int],
41
+ events: List[Dict],
42
+ shots: List[Dict],
43
+ court_keypoints: List[Dict],
44
+ speeds: List[Dict],
45
+ video_path: str,
46
+ fps: float
47
+ ) -> Dict[str, Any]:
48
+ """
49
+ Run all analytics modules and aggregate results.
50
+
51
+ Modules are executed in dependency order:
52
+ 1. Spacing Engine (independent)
53
+ 2. Defensive Reaction Engine (independent)
54
+ 3. Transition Effort Engine (independent)
55
+ 4. Decision Quality Engine (independent)
56
+ 5. Lineup Impact Engine (depends on spacing + defensive reactions)
57
+ 6. Fatigue Tracker (depends on defensive reactions)
58
+ 7. Clip Generator (depends on all previous modules)
59
+
60
+ Args:
61
+ video_frames: List of video frames
62
+ player_tracks: Per-frame player tracking data
63
+ ball_tracks: Per-frame ball tracking data
64
+ tactical_positions: 2D court positions for players
65
+ player_assignment: Per-frame team assignments
66
+ ball_possession: Per-frame ball possession
67
+ events: List of detected events
68
+ shots: List of detected shots
69
+ court_keypoints: Court boundary keypoints
70
+ speeds: Per-frame player speeds
71
+ video_path: Path to original video
72
+ fps: Frames per second
73
+
74
+ Returns:
75
+ Aggregated analytics results from all modules
76
+ """
77
+ logger.info("Starting advanced analytics processing")
78
+
79
+ results = {
80
+ "modules_executed": [],
81
+ "modules_failed": [],
82
+ }
83
+
84
+ # Module 1: Spacing Engine
85
+ logger.info("Running Spacing Engine")
86
+ spacing_result = self.spacing_engine.safe_process(
87
+ video_frames=video_frames,
88
+ player_tracks=player_tracks,
89
+ ball_tracks=ball_tracks,
90
+ tactical_positions=tactical_positions,
91
+ player_assignment=player_assignment,
92
+ ball_possession=ball_possession,
93
+ events=events,
94
+ shots=shots,
95
+ court_keypoints=court_keypoints,
96
+ speeds=speeds,
97
+ video_path=video_path,
98
+ fps=fps
99
+ )
100
+
101
+ if spacing_result.get("status") == "success":
102
+ results["spacing"] = spacing_result
103
+ results["modules_executed"].append("spacing_engine")
104
+ else:
105
+ results["modules_failed"].append("spacing_engine")
106
+ logger.error(f"Spacing Engine failed: {spacing_result.get('error')}")
107
+
108
+ # Module 2: Defensive Reaction Engine
109
+ logger.info("Running Defensive Reaction Engine")
110
+ defensive_result = self.defensive_reaction.safe_process(
111
+ video_frames=video_frames,
112
+ player_tracks=player_tracks,
113
+ ball_tracks=ball_tracks,
114
+ tactical_positions=tactical_positions,
115
+ player_assignment=player_assignment,
116
+ ball_possession=ball_possession,
117
+ events=events,
118
+ shots=shots,
119
+ court_keypoints=court_keypoints,
120
+ speeds=speeds,
121
+ video_path=video_path,
122
+ fps=fps
123
+ )
124
+
125
+ if defensive_result.get("status") == "success":
126
+ results["defensive_reactions"] = defensive_result
127
+ results["modules_executed"].append("defensive_reaction")
128
+ else:
129
+ results["modules_failed"].append("defensive_reaction")
130
+ logger.error(f"Defensive Reaction Engine failed: {defensive_result.get('error')}")
131
+
132
+ # Module 3: Transition Effort Engine
133
+ logger.info("Running Transition Effort Engine")
134
+ transition_result = self.transition_effort.safe_process(
135
+ video_frames=video_frames,
136
+ player_tracks=player_tracks,
137
+ ball_tracks=ball_tracks,
138
+ tactical_positions=tactical_positions,
139
+ player_assignment=player_assignment,
140
+ ball_possession=ball_possession,
141
+ events=events,
142
+ shots=shots,
143
+ court_keypoints=court_keypoints,
144
+ speeds=speeds,
145
+ video_path=video_path,
146
+ fps=fps
147
+ )
148
+
149
+ if transition_result.get("status") == "success":
150
+ results["transition_effort"] = transition_result
151
+ results["modules_executed"].append("transition_effort")
152
+ else:
153
+ results["modules_failed"].append("transition_effort")
154
+ logger.error(f"Transition Effort Engine failed: {transition_result.get('error')}")
155
+
156
+ # Module 4: Decision Quality Engine
157
+ logger.info("Running Decision Quality Engine")
158
+ decision_result = self.decision_quality.safe_process(
159
+ video_frames=video_frames,
160
+ player_tracks=player_tracks,
161
+ ball_tracks=ball_tracks,
162
+ tactical_positions=tactical_positions,
163
+ player_assignment=player_assignment,
164
+ ball_possession=ball_possession,
165
+ events=events,
166
+ shots=shots,
167
+ court_keypoints=court_keypoints,
168
+ speeds=speeds,
169
+ video_path=video_path,
170
+ fps=fps
171
+ )
172
+
173
+ if decision_result.get("status") == "success":
174
+ results["decision_quality"] = decision_result
175
+ results["modules_executed"].append("decision_quality")
176
+ else:
177
+ results["modules_failed"].append("decision_quality")
178
+ logger.error(f"Decision Quality Engine failed: {decision_result.get('error')}")
179
+
180
+ # Module 5: Lineup Impact Engine (depends on spacing + defensive reactions)
181
+ logger.info("Running Lineup Impact Engine")
182
+ lineup_result = self.lineup_impact.safe_process(
183
+ video_frames=video_frames,
184
+ player_tracks=player_tracks,
185
+ ball_tracks=ball_tracks,
186
+ tactical_positions=tactical_positions,
187
+ player_assignment=player_assignment,
188
+ ball_possession=ball_possession,
189
+ events=events,
190
+ shots=shots,
191
+ court_keypoints=court_keypoints,
192
+ speeds=speeds,
193
+ video_path=video_path,
194
+ fps=fps,
195
+ spacing_metrics=spacing_result.get("spacing_metrics", []),
196
+ defensive_reactions=defensive_result.get("defensive_reactions", [])
197
+ )
198
+
199
+ if lineup_result.get("status") == "success":
200
+ results["lineup_impact"] = lineup_result
201
+ results["modules_executed"].append("lineup_impact")
202
+ else:
203
+ results["modules_failed"].append("lineup_impact")
204
+ logger.error(f"Lineup Impact Engine failed: {lineup_result.get('error')}")
205
+
206
+ # Module 6: Fatigue Tracker (depends on defensive reactions)
207
+ logger.info("Running Fatigue Tracker")
208
+ fatigue_result = self.fatigue_tracker.safe_process(
209
+ video_frames=video_frames,
210
+ player_tracks=player_tracks,
211
+ ball_tracks=ball_tracks,
212
+ tactical_positions=tactical_positions,
213
+ player_assignment=player_assignment,
214
+ ball_possession=ball_possession,
215
+ events=events,
216
+ shots=shots,
217
+ court_keypoints=court_keypoints,
218
+ speeds=speeds,
219
+ video_path=video_path,
220
+ fps=fps,
221
+ defensive_reactions=defensive_result.get("defensive_reactions", [])
222
+ )
223
+
224
+ if fatigue_result.get("status") == "success":
225
+ results["fatigue"] = fatigue_result
226
+ results["modules_executed"].append("fatigue_tracker")
227
+ else:
228
+ results["modules_failed"].append("fatigue_tracker")
229
+ logger.error(f"Fatigue Tracker failed: {fatigue_result.get('error')}")
230
+
231
+ # Module 7: Clip Generator (depends on all previous modules)
232
+ logger.info("Running Clip Generator")
233
+ clip_result = self.clip_generator.safe_process(
234
+ video_frames=video_frames,
235
+ player_tracks=player_tracks,
236
+ ball_tracks=ball_tracks,
237
+ tactical_positions=tactical_positions,
238
+ player_assignment=player_assignment,
239
+ ball_possession=ball_possession,
240
+ events=events,
241
+ shots=shots,
242
+ court_keypoints=court_keypoints,
243
+ speeds=speeds,
244
+ video_path=video_path,
245
+ fps=fps,
246
+ spacing_metrics=spacing_result.get("spacing_metrics", []),
247
+ defensive_reactions=defensive_result.get("defensive_reactions", []),
248
+ transition_efforts=transition_result.get("transition_efforts", []),
249
+ decision_analyses=decision_result.get("decision_analyses", [])
250
+ )
251
+
252
+ if clip_result.get("status") == "success":
253
+ results["clips"] = clip_result
254
+ results["modules_executed"].append("clip_generator")
255
+ else:
256
+ results["modules_failed"].append("clip_generator")
257
+ logger.error(f"Clip Generator failed: {clip_result.get('error')}")
258
+
259
+ logger.info(f"Advanced analytics complete. Executed: {len(results['modules_executed'])}, Failed: {len(results['modules_failed'])}")
260
+
261
+ return results
analytics_engine/decision_quality.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Decision Quality Engine - Analyzes shot decision quality.
3
+
4
+ Compares shooter's contested distance vs. teammate openness to evaluate
5
+ whether the shot was the best available option.
6
+ """
7
+ from typing import Dict, Any, List
8
+ import numpy as np
9
+ from .base import BaseAnalyticsModule
10
+
11
+
12
+ class DecisionQualityEngine(BaseAnalyticsModule):
13
+ """Analyzes shot decision quality based on player openness."""
14
+
15
+ def __init__(
16
+ self,
17
+ open_threshold_m: float = 2.5,
18
+ contested_threshold_m: float = 1.5,
19
+ ):
20
+ """
21
+ Initialize decision quality engine.
22
+
23
+ Args:
24
+ open_threshold_m: Distance to defender for "open" classification
25
+ contested_threshold_m: Distance to defender for "contested" classification
26
+ """
27
+ super().__init__("decision_quality")
28
+ self.open_threshold = open_threshold_m
29
+ self.contested_threshold = contested_threshold_m
30
+
31
+ def process(
32
+ self,
33
+ video_frames: List[Any],
34
+ player_tracks: List[Dict],
35
+ ball_tracks: List[Dict],
36
+ tactical_positions: List[Dict],
37
+ player_assignment: List[Dict],
38
+ ball_possession: List[int],
39
+ events: List[Dict],
40
+ shots: List[Dict],
41
+ court_keypoints: List[Dict],
42
+ speeds: List[Dict],
43
+ video_path: str,
44
+ fps: float,
45
+ **kwargs
46
+ ) -> Dict[str, Any]:
47
+ """
48
+ Analyze decision quality for all shots.
49
+
50
+ Returns:
51
+ Dictionary with decision analysis for each shot
52
+ """
53
+ decision_analyses = []
54
+
55
+ for shot in shots:
56
+ frame = shot.get("start_frame", shot.get("frame", 0))
57
+ if frame >= len(player_assignment) or frame >= len(tactical_positions):
58
+ continue
59
+
60
+ assignment = player_assignment[frame]
61
+ tactical_pos = tactical_positions[frame]
62
+
63
+ shooter_id = shot.get("player_id")
64
+ if not shooter_id or shooter_id not in assignment:
65
+ continue
66
+
67
+ offense_team = assignment[shooter_id]
68
+ defense_team = 1 if offense_team == 2 else 2
69
+
70
+ shooter_pos = tactical_pos.get(shooter_id)
71
+ if not shooter_pos:
72
+ continue
73
+
74
+ # Find nearest defender to shooter
75
+ shooter_contested_distance = float('inf')
76
+ for player_id, team_id in assignment.items():
77
+ if team_id == defense_team and player_id in tactical_pos:
78
+ defender_pos = tactical_pos[player_id]
79
+ dist = self._euclidean_distance(shooter_pos, defender_pos)
80
+ if dist < shooter_contested_distance:
81
+ shooter_contested_distance = dist
82
+
83
+ # Analyze teammates' openness
84
+ open_teammates = 0
85
+ best_teammate_openness = 0.0
86
+ teammate_data = []
87
+
88
+ for player_id, team_id in assignment.items():
89
+ if team_id == offense_team and player_id != shooter_id and player_id in tactical_pos:
90
+ teammate_pos = tactical_pos[player_id]
91
+
92
+ # Find nearest defender to this teammate
93
+ min_defender_dist = float('inf')
94
+ for def_id, def_team in assignment.items():
95
+ if def_team == defense_team and def_id in tactical_pos:
96
+ def_pos = tactical_pos[def_id]
97
+ dist = self._euclidean_distance(teammate_pos, def_pos)
98
+ if dist < min_defender_dist:
99
+ min_defender_dist = dist
100
+
101
+ teammate_data.append({
102
+ "player_id": player_id,
103
+ "defender_distance": float(min_defender_dist)
104
+ })
105
+
106
+ if min_defender_dist > self.open_threshold:
107
+ open_teammates += 1
108
+ if min_defender_dist > best_teammate_openness:
109
+ best_teammate_openness = min_defender_dist
110
+
111
+ # Determine decision quality
112
+ shooter_is_contested = shooter_contested_distance < self.contested_threshold
113
+ has_open_teammate = open_teammates > 0
114
+
115
+ if shooter_is_contested and has_open_teammate:
116
+ # Bad decision: shooter contested but open teammate available
117
+ decision_quality = "low_expected_value"
118
+ elif not shooter_is_contested:
119
+ # Good decision: shooter is open
120
+ decision_quality = "high_expected_value"
121
+ else:
122
+ # Acceptable: shooter contested but no better options
123
+ decision_quality = "acceptable"
124
+
125
+ decision_analyses.append({
126
+ "event_id": f"shot_{frame}",
127
+ "shot_frame": frame,
128
+ "shooter_track_id": shooter_id,
129
+ "shooter_contested_distance": float(shooter_contested_distance),
130
+ "open_teammates": open_teammates,
131
+ "best_teammate_openness": float(best_teammate_openness) if best_teammate_openness > 0 else None,
132
+ "decision_quality": decision_quality,
133
+ "teammate_positions": teammate_data,
134
+ "shot_outcome": shot.get("outcome", "unknown")
135
+ })
136
+
137
+ # Calculate summary statistics
138
+ if decision_analyses:
139
+ quality_counts = {
140
+ "high_expected_value": 0,
141
+ "acceptable": 0,
142
+ "low_expected_value": 0
143
+ }
144
+ for analysis in decision_analyses:
145
+ quality_counts[analysis["decision_quality"]] += 1
146
+
147
+ total = len(decision_analyses)
148
+
149
+ summary = {
150
+ "total_shots_analyzed": total,
151
+ "high_ev_shots": quality_counts["high_expected_value"],
152
+ "acceptable_shots": quality_counts["acceptable"],
153
+ "low_ev_shots": quality_counts["low_expected_value"],
154
+ "low_ev_rate": (quality_counts["low_expected_value"] / total * 100) if total > 0 else 0,
155
+ "avg_shooter_contested_distance": float(np.mean([
156
+ a["shooter_contested_distance"] for a in decision_analyses
157
+ ])),
158
+ }
159
+ else:
160
+ summary = {
161
+ "total_shots_analyzed": 0,
162
+ "high_ev_shots": 0,
163
+ "acceptable_shots": 0,
164
+ "low_ev_shots": 0,
165
+ "low_ev_rate": 0,
166
+ "avg_shooter_contested_distance": 0,
167
+ }
168
+
169
+ return {
170
+ "decision_analyses": decision_analyses,
171
+ "summary": summary,
172
+ "status": "success"
173
+ }
analytics_engine/defensive_reaction.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Defensive Reaction Engine - Analyzes defensive reaction times and closeout speeds.
3
+
4
+ For every offensive event (shot, pass, drive), measures how quickly the nearest
5
+ defender reacts and closes out.
6
+ """
7
+ from typing import Dict, Any, List
8
+ import numpy as np
9
+ from .base import BaseAnalyticsModule
10
+
11
+
12
+ class DefensiveReactionEngine(BaseAnalyticsModule):
13
+ """Analyzes defensive reaction times and closeout quality."""
14
+
15
+ def __init__(
16
+ self,
17
+ late_closeout_delay_ms: float = 500,
18
+ late_closeout_speed_mps: float = 4.0,
19
+ reaction_window_frames: int = 30, # ~1 second at 30fps
20
+ ):
21
+ """
22
+ Initialize defensive reaction engine.
23
+
24
+ Args:
25
+ late_closeout_delay_ms: Reaction delay threshold for "late" classification
26
+ late_closeout_speed_mps: Speed threshold for "late" classification
27
+ reaction_window_frames: Number of frames to analyze after event
28
+ """
29
+ super().__init__("defensive_reaction")
30
+ self.late_delay_threshold = late_closeout_delay_ms
31
+ self.late_speed_threshold = late_closeout_speed_mps
32
+ self.reaction_window = reaction_window_frames
33
+
34
+ def process(
35
+ self,
36
+ video_frames: List[Any],
37
+ player_tracks: List[Dict],
38
+ ball_tracks: List[Dict],
39
+ tactical_positions: List[Dict],
40
+ player_assignment: List[Dict],
41
+ ball_possession: List[int],
42
+ events: List[Dict],
43
+ shots: List[Dict],
44
+ court_keypoints: List[Dict],
45
+ speeds: List[Dict],
46
+ video_path: str,
47
+ fps: float,
48
+ **kwargs
49
+ ) -> Dict[str, Any]:
50
+ """
51
+ Analyze defensive reactions for all offensive events.
52
+
53
+ Returns:
54
+ Dictionary with defensive reaction metrics for each event
55
+ """
56
+ defensive_reactions = []
57
+
58
+ # Analyze shots (primary offensive events)
59
+ for shot in shots:
60
+ frame = shot.get("start_frame", shot.get("frame", 0))
61
+ if frame >= len(player_assignment) or frame >= len(tactical_positions):
62
+ continue
63
+
64
+ assignment = player_assignment[frame]
65
+ tactical_pos = tactical_positions[frame]
66
+
67
+ # Get shooter info
68
+ shooter_id = shot.get("player_id")
69
+ if not shooter_id or shooter_id not in assignment:
70
+ continue
71
+
72
+ offense_team = assignment[shooter_id]
73
+ defense_team = 1 if offense_team == 2 else 2
74
+
75
+ # Find nearest defender
76
+ shooter_pos = tactical_pos.get(shooter_id)
77
+ if not shooter_pos:
78
+ continue
79
+
80
+ nearest_defender = None
81
+ min_distance = float('inf')
82
+
83
+ for player_id, team_id in assignment.items():
84
+ if team_id == defense_team and player_id in tactical_pos:
85
+ defender_pos = tactical_pos[player_id]
86
+ dist = self._euclidean_distance(shooter_pos, defender_pos)
87
+ if dist < min_distance:
88
+ min_distance = dist
89
+ nearest_defender = player_id
90
+
91
+ if not nearest_defender:
92
+ continue
93
+
94
+ # Measure reaction (simplified: check if defender moved toward shooter)
95
+ reaction_metrics = self._measure_reaction(
96
+ frame,
97
+ nearest_defender,
98
+ shooter_id,
99
+ tactical_positions,
100
+ speeds,
101
+ fps
102
+ )
103
+
104
+ defensive_reactions.append({
105
+ "event_id": f"shot_{frame}",
106
+ "event_type": "shot",
107
+ "event_frame": frame,
108
+ "defender_track_id": nearest_defender,
109
+ "offensive_player_track_id": shooter_id,
110
+ "distance_at_event": float(min_distance),
111
+ **reaction_metrics
112
+ })
113
+
114
+ # Analyze passes
115
+ for event in events:
116
+ if event.get("event_type") != "pass":
117
+ continue
118
+
119
+ frame = event.get("frame", 0)
120
+ if frame >= len(player_assignment) or frame >= len(tactical_positions):
121
+ continue
122
+
123
+ assignment = player_assignment[frame]
124
+ tactical_pos = tactical_positions[frame]
125
+
126
+ passer_id = event.get("player_id")
127
+ if not passer_id or passer_id not in assignment:
128
+ continue
129
+
130
+ offense_team = assignment[passer_id]
131
+ defense_team = 1 if offense_team == 2 else 2
132
+
133
+ passer_pos = tactical_pos.get(passer_id)
134
+ if not passer_pos:
135
+ continue
136
+
137
+ # Find nearest defender
138
+ nearest_defender = None
139
+ min_distance = float('inf')
140
+
141
+ for player_id, team_id in assignment.items():
142
+ if team_id == defense_team and player_id in tactical_pos:
143
+ defender_pos = tactical_pos[player_id]
144
+ dist = self._euclidean_distance(passer_pos, defender_pos)
145
+ if dist < min_distance:
146
+ min_distance = dist
147
+ nearest_defender = player_id
148
+
149
+ if not nearest_defender:
150
+ continue
151
+
152
+ reaction_metrics = self._measure_reaction(
153
+ frame,
154
+ nearest_defender,
155
+ passer_id,
156
+ tactical_positions,
157
+ speeds,
158
+ fps
159
+ )
160
+
161
+ defensive_reactions.append({
162
+ "event_id": f"pass_{frame}",
163
+ "event_type": "pass",
164
+ "event_frame": frame,
165
+ "defender_track_id": nearest_defender,
166
+ "offensive_player_track_id": passer_id,
167
+ "distance_at_event": float(min_distance),
168
+ **reaction_metrics
169
+ })
170
+
171
+ # Calculate summary statistics
172
+ if defensive_reactions:
173
+ late_closeouts = sum(1 for r in defensive_reactions if r.get("late_closeout", False))
174
+ avg_reaction_delay = np.mean([
175
+ r["reaction_delay_ms"] for r in defensive_reactions
176
+ if r.get("reaction_delay_ms") is not None
177
+ ])
178
+ avg_closeout_speed = np.mean([
179
+ r["closeout_speed_mps"] for r in defensive_reactions
180
+ if r.get("closeout_speed_mps") is not None
181
+ ])
182
+
183
+ summary = {
184
+ "total_defensive_events": len(defensive_reactions),
185
+ "late_closeouts": late_closeouts,
186
+ "late_closeout_rate": (late_closeouts / len(defensive_reactions) * 100),
187
+ "avg_reaction_delay_ms": float(avg_reaction_delay) if not np.isnan(avg_reaction_delay) else None,
188
+ "avg_closeout_speed_mps": float(avg_closeout_speed) if not np.isnan(avg_closeout_speed) else None,
189
+ }
190
+ else:
191
+ summary = {
192
+ "total_defensive_events": 0,
193
+ "late_closeouts": 0,
194
+ "late_closeout_rate": 0,
195
+ "avg_reaction_delay_ms": None,
196
+ "avg_closeout_speed_mps": None,
197
+ }
198
+
199
+ return {
200
+ "defensive_reactions": defensive_reactions,
201
+ "summary": summary,
202
+ "status": "success"
203
+ }
204
+
205
+ def _measure_reaction(
206
+ self,
207
+ event_frame: int,
208
+ defender_id: int,
209
+ offensive_player_id: int,
210
+ tactical_positions: List[Dict],
211
+ speeds: List[Dict],
212
+ fps: float
213
+ ) -> Dict[str, Any]:
214
+ """
215
+ Measure defender's reaction to an offensive event.
216
+
217
+ Args:
218
+ event_frame: Frame when event occurred
219
+ defender_id: Track ID of defender
220
+ offensive_player_id: Track ID of offensive player
221
+ tactical_positions: All tactical positions
222
+ speeds: All speed data
223
+ fps: Frames per second
224
+
225
+ Returns:
226
+ Dictionary with reaction metrics
227
+ """
228
+ # Simplified reaction measurement
229
+ # In full implementation, would track defender movement vector toward offensive player
230
+
231
+ reaction_start_frame = None
232
+ max_speed = 0.0
233
+
234
+ # Look at next N frames after event
235
+ for offset in range(1, min(self.reaction_window, len(tactical_positions) - event_frame)):
236
+ frame_idx = event_frame + offset
237
+
238
+ if frame_idx >= len(speeds):
239
+ break
240
+
241
+ # Get defender speed in this frame
242
+ frame_speeds = speeds[frame_idx]
243
+ if defender_id in frame_speeds:
244
+ speed = frame_speeds[defender_id]
245
+ if speed > max_speed:
246
+ max_speed = speed
247
+
248
+ # Detect reaction start (speed increase)
249
+ if speed > 3.0 and reaction_start_frame is None: # 3 m/s threshold
250
+ reaction_start_frame = frame_idx
251
+
252
+ # Calculate metrics
253
+ if reaction_start_frame:
254
+ reaction_delay_frames = reaction_start_frame - event_frame
255
+ reaction_delay_ms = (reaction_delay_frames / fps) * 1000 if fps > 0 else 0
256
+ else:
257
+ reaction_delay_ms = None
258
+
259
+ closeout_speed_mps = max_speed if max_speed > 0 else None
260
+
261
+ # Determine if late closeout
262
+ late_closeout = False
263
+ if reaction_delay_ms and reaction_delay_ms > self.late_delay_threshold:
264
+ late_closeout = True
265
+ if closeout_speed_mps and closeout_speed_mps < self.late_speed_threshold:
266
+ late_closeout = True
267
+
268
+ return {
269
+ "reaction_start_frame": reaction_start_frame,
270
+ "reaction_delay_ms": reaction_delay_ms,
271
+ "closeout_speed_mps": closeout_speed_mps,
272
+ "late_closeout": late_closeout
273
+ }
analytics_engine/fatigue_tracker.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fatigue Tracker - Monitors player fatigue indicators over time.
3
+
4
+ Tracks speed decline and reaction delay increase to identify fatigue patterns.
5
+ """
6
+ from typing import Dict, Any, List
7
+ import numpy as np
8
+ from .base import BaseAnalyticsModule
9
+
10
+
11
+ class FatigueTracker(BaseAnalyticsModule):
12
+ """Tracks player fatigue indicators using speed and reaction metrics."""
13
+
14
+ def __init__(
15
+ self,
16
+ time_window_minutes: float = 2.0,
17
+ baseline_window_minutes: float = 3.0,
18
+ low_fatigue_threshold: float = 5.0,
19
+ medium_fatigue_threshold: float = 15.0,
20
+ ):
21
+ """
22
+ Initialize fatigue tracker.
23
+
24
+ Args:
25
+ time_window_minutes: Size of rolling time window for analysis
26
+ baseline_window_minutes: Duration of early-game baseline period
27
+ low_fatigue_threshold: Percentage decline for low fatigue
28
+ medium_fatigue_threshold: Percentage decline for medium fatigue
29
+ """
30
+ super().__init__("fatigue_tracker")
31
+ self.time_window_minutes = time_window_minutes
32
+ self.baseline_window_minutes = baseline_window_minutes
33
+ self.low_threshold = low_fatigue_threshold
34
+ self.medium_threshold = medium_fatigue_threshold
35
+
36
+ def process(
37
+ self,
38
+ video_frames: List[Any],
39
+ player_tracks: List[Dict],
40
+ ball_tracks: List[Dict],
41
+ tactical_positions: List[Dict],
42
+ player_assignment: List[Dict],
43
+ ball_possession: List[int],
44
+ events: List[Dict],
45
+ shots: List[Dict],
46
+ court_keypoints: List[Dict],
47
+ speeds: List[Dict],
48
+ video_path: str,
49
+ fps: float,
50
+ **kwargs
51
+ ) -> Dict[str, Any]:
52
+ """
53
+ Track fatigue indicators for all players over time.
54
+
55
+ Returns:
56
+ Dictionary with fatigue metrics per player per time window
57
+ """
58
+ defensive_reactions = kwargs.get("defensive_reactions", [])
59
+
60
+ fatigue_indices = []
61
+
62
+ # Get all unique players
63
+ all_players = set()
64
+ for assignment in player_assignment:
65
+ all_players.update(assignment.keys())
66
+
67
+ # Calculate baseline metrics (first N minutes)
68
+ baseline_frames = int(self.baseline_window_minutes * 60 * fps)
69
+ baseline_metrics = self._calculate_baseline_metrics(
70
+ all_players,
71
+ speeds[:baseline_frames],
72
+ defensive_reactions,
73
+ baseline_frames
74
+ )
75
+
76
+ # Analyze in time windows
77
+ window_frames = int(self.time_window_minutes * 60 * fps)
78
+ total_frames = len(speeds)
79
+
80
+ for window_start in range(0, total_frames, window_frames):
81
+ window_end = min(window_start + window_frames, total_frames)
82
+ window_minute = int((window_start / fps) / 60)
83
+
84
+ # Skip if this is the baseline window
85
+ if window_start < baseline_frames:
86
+ continue
87
+
88
+ window_speeds = speeds[window_start:window_end]
89
+
90
+ for player_id in all_players:
91
+ # Calculate current window metrics
92
+ player_speeds = []
93
+ for frame_speeds in window_speeds:
94
+ if player_id in frame_speeds:
95
+ player_speeds.append(frame_speeds[player_id])
96
+
97
+ if not player_speeds:
98
+ continue
99
+
100
+ current_avg_speed = np.mean(player_speeds)
101
+
102
+ # Get baseline for this player
103
+ baseline_speed = baseline_metrics.get(player_id, {}).get("avg_speed", 0)
104
+ baseline_reaction = baseline_metrics.get(player_id, {}).get("avg_reaction_ms")
105
+
106
+ if baseline_speed == 0:
107
+ continue
108
+
109
+ # Calculate speed decline
110
+ speed_drop_pct = ((baseline_speed - current_avg_speed) / baseline_speed) * 100
111
+
112
+ # Calculate reaction delay increase (if available)
113
+ window_reactions = [
114
+ r for r in defensive_reactions
115
+ if r.get("defender_track_id") == player_id
116
+ and window_start <= r.get("event_frame", 0) < window_end
117
+ and r.get("reaction_delay_ms") is not None
118
+ ]
119
+
120
+ if window_reactions and baseline_reaction:
121
+ current_reaction = np.mean([r["reaction_delay_ms"] for r in window_reactions])
122
+ reaction_increase_pct = ((current_reaction - baseline_reaction) / baseline_reaction) * 100
123
+ else:
124
+ current_reaction = None
125
+ reaction_increase_pct = None
126
+
127
+ # Classify fatigue level
128
+ if speed_drop_pct < self.low_threshold:
129
+ fatigue_level = "low"
130
+ elif speed_drop_pct < self.medium_threshold:
131
+ fatigue_level = "medium"
132
+ else:
133
+ fatigue_level = "high"
134
+
135
+ fatigue_indices.append({
136
+ "player_track_id": player_id,
137
+ "time_window_start": window_start / fps if fps > 0 else 0,
138
+ "time_window_end": window_end / fps if fps > 0 else 0,
139
+ "minute": window_minute,
140
+ "baseline_speed_mps": float(baseline_speed),
141
+ "current_speed_mps": float(current_avg_speed),
142
+ "speed_drop_percentage": float(speed_drop_pct),
143
+ "baseline_reaction_ms": float(baseline_reaction) if baseline_reaction else None,
144
+ "current_reaction_ms": float(current_reaction) if current_reaction else None,
145
+ "reaction_delay_increase_percentage": float(reaction_increase_pct) if reaction_increase_pct else None,
146
+ "fatigue_level": fatigue_level
147
+ })
148
+
149
+ # Calculate summary statistics
150
+ if fatigue_indices:
151
+ high_fatigue_count = sum(1 for f in fatigue_indices if f["fatigue_level"] == "high")
152
+ medium_fatigue_count = sum(1 for f in fatigue_indices if f["fatigue_level"] == "medium")
153
+
154
+ summary = {
155
+ "total_measurements": len(fatigue_indices),
156
+ "high_fatigue_instances": high_fatigue_count,
157
+ "medium_fatigue_instances": medium_fatigue_count,
158
+ "avg_speed_drop_pct": float(np.mean([f["speed_drop_percentage"] for f in fatigue_indices])),
159
+ "max_speed_drop_pct": float(max(f["speed_drop_percentage"] for f in fatigue_indices)),
160
+ }
161
+ else:
162
+ summary = {
163
+ "total_measurements": 0,
164
+ "high_fatigue_instances": 0,
165
+ "medium_fatigue_instances": 0,
166
+ "avg_speed_drop_pct": 0,
167
+ "max_speed_drop_pct": 0,
168
+ }
169
+
170
+ return {
171
+ "fatigue_indices": fatigue_indices,
172
+ "summary": summary,
173
+ "status": "success"
174
+ }
175
+
176
+ def _calculate_baseline_metrics(
177
+ self,
178
+ players: set,
179
+ baseline_speeds: List[Dict],
180
+ defensive_reactions: List[Dict],
181
+ baseline_frames: int
182
+ ) -> Dict[int, Dict[str, float]]:
183
+ """
184
+ Calculate baseline metrics for each player from early game.
185
+
186
+ Args:
187
+ players: Set of player IDs
188
+ baseline_speeds: Speed data from baseline period
189
+ defensive_reactions: All defensive reaction data
190
+ baseline_frames: Number of frames in baseline
191
+
192
+ Returns:
193
+ Dictionary mapping player_id to baseline metrics
194
+ """
195
+ baseline_metrics = {}
196
+
197
+ for player_id in players:
198
+ # Collect speeds
199
+ player_speeds = []
200
+ for frame_speeds in baseline_speeds:
201
+ if player_id in frame_speeds:
202
+ player_speeds.append(frame_speeds[player_id])
203
+
204
+ if player_speeds:
205
+ avg_speed = np.mean(player_speeds)
206
+ else:
207
+ avg_speed = 0
208
+
209
+ # Collect reaction times
210
+ player_reactions = [
211
+ r["reaction_delay_ms"]
212
+ for r in defensive_reactions
213
+ if r.get("defender_track_id") == player_id
214
+ and r.get("event_frame", 0) < baseline_frames
215
+ and r.get("reaction_delay_ms") is not None
216
+ ]
217
+
218
+ if player_reactions:
219
+ avg_reaction = np.mean(player_reactions)
220
+ else:
221
+ avg_reaction = None
222
+
223
+ baseline_metrics[player_id] = {
224
+ "avg_speed": avg_speed,
225
+ "avg_reaction_ms": avg_reaction
226
+ }
227
+
228
+ return baseline_metrics
analytics_engine/lineup_impact.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Lineup Impact Engine - Analyzes performance of specific 5-player combinations.
3
+
4
+ Segments game by unique lineups and calculates offensive/defensive ratings,
5
+ spacing quality, and error rates for each combination.
6
+ """
7
+ from typing import Dict, Any, List
8
+ import numpy as np
9
+ from .base import BaseAnalyticsModule
10
+
11
+
12
+ class LineupImpactEngine(BaseAnalyticsModule):
13
+ """Analyzes performance metrics for specific player combinations."""
14
+
15
+ def __init__(self):
16
+ """Initialize lineup impact engine."""
17
+ super().__init__("lineup_impact")
18
+
19
+ def process(
20
+ self,
21
+ video_frames: List[Any],
22
+ player_tracks: List[Dict],
23
+ ball_tracks: List[Dict],
24
+ tactical_positions: List[Dict],
25
+ player_assignment: List[Dict],
26
+ ball_possession: List[int],
27
+ events: List[Dict],
28
+ shots: List[Dict],
29
+ court_keypoints: List[Dict],
30
+ speeds: List[Dict],
31
+ video_path: str,
32
+ fps: float,
33
+ **kwargs
34
+ ) -> Dict[str, Any]:
35
+ """
36
+ Analyze lineup performance across the game.
37
+
38
+ Returns:
39
+ Dictionary with lineup metrics
40
+ """
41
+ # Get spacing metrics and defensive reactions from kwargs if available
42
+ spacing_metrics = kwargs.get("spacing_metrics", [])
43
+ defensive_reactions = kwargs.get("defensive_reactions", [])
44
+
45
+ # Detect lineup segments
46
+ lineup_segments = self._detect_lineup_segments(
47
+ player_assignment,
48
+ ball_possession,
49
+ fps
50
+ )
51
+
52
+ lineup_metrics = []
53
+
54
+ for segment in lineup_segments:
55
+ team_id = segment["team_id"]
56
+ lineup_hash = segment["lineup_hash"]
57
+ player_ids = segment["player_ids"]
58
+ start_frame = segment["start_frame"]
59
+ end_frame = segment["end_frame"]
60
+
61
+ # Calculate metrics for this lineup segment
62
+ segment_shots = [
63
+ s for s in shots
64
+ if start_frame <= s.get("start_frame", s.get("frame", 0)) < end_frame
65
+ and s.get("team") == team_id
66
+ ]
67
+
68
+ segment_shots_allowed = [
69
+ s for s in shots
70
+ if start_frame <= s.get("start_frame", s.get("frame", 0)) < end_frame
71
+ and s.get("team") != team_id
72
+ ]
73
+
74
+ # Points scored (simplified: 2 points per made shot)
75
+ points_scored = sum(2 for s in segment_shots if s.get("outcome") == "made")
76
+ points_allowed = sum(2 for s in segment_shots_allowed if s.get("outcome") == "made")
77
+
78
+ # Possessions (estimate from shots + turnovers)
79
+ possessions = len(segment_shots) # Simplified
80
+
81
+ # Offensive/defensive ratings (per 100 possessions)
82
+ if possessions > 0:
83
+ offensive_rating = (points_scored / possessions) * 100
84
+ defensive_rating = (points_allowed / possessions) * 100
85
+ net_rating = offensive_rating - defensive_rating
86
+ else:
87
+ offensive_rating = 0
88
+ defensive_rating = 0
89
+ net_rating = 0
90
+
91
+ # Average spacing score for this lineup
92
+ segment_spacing = [
93
+ m for m in spacing_metrics
94
+ if start_frame <= m.get("frame", 0) < end_frame
95
+ ]
96
+
97
+ if segment_spacing:
98
+ # Convert quality to numeric score
99
+ quality_scores = []
100
+ for m in segment_spacing:
101
+ if m["spacing_quality"] == "good":
102
+ quality_scores.append(3.0)
103
+ elif m["spacing_quality"] == "average":
104
+ quality_scores.append(2.0)
105
+ else:
106
+ quality_scores.append(1.0)
107
+ avg_spacing_score = float(np.mean(quality_scores))
108
+ else:
109
+ avg_spacing_score = 0.0
110
+
111
+ # Defensive error rate (late closeouts / total defensive events)
112
+ segment_def_reactions = [
113
+ r for r in defensive_reactions
114
+ if start_frame <= r.get("event_frame", 0) < end_frame
115
+ ]
116
+
117
+ if segment_def_reactions:
118
+ late_closeouts = sum(1 for r in segment_def_reactions if r.get("late_closeout", False))
119
+ defensive_error_rate = late_closeouts / len(segment_def_reactions)
120
+ else:
121
+ defensive_error_rate = 0.0
122
+
123
+ # Turnovers (from interceptions)
124
+ segment_interceptions = [
125
+ e for e in events
126
+ if e.get("event_type") == "interception"
127
+ and start_frame <= e.get("frame", 0) < end_frame
128
+ ]
129
+ turnovers = len(segment_interceptions)
130
+
131
+ # Duration
132
+ duration_seconds = (end_frame - start_frame) / fps if fps > 0 else 0
133
+ duration_minutes = duration_seconds / 60
134
+
135
+ lineup_metrics.append({
136
+ "team_id": team_id,
137
+ "lineup_hash": lineup_hash,
138
+ "player_track_ids": player_ids,
139
+ "possessions_count": possessions,
140
+ "points_scored": points_scored,
141
+ "points_allowed": points_allowed,
142
+ "offensive_rating": float(offensive_rating),
143
+ "defensive_rating": float(defensive_rating),
144
+ "net_rating": float(net_rating),
145
+ "avg_spacing_score": avg_spacing_score,
146
+ "turnovers": turnovers,
147
+ "defensive_error_rate": float(defensive_error_rate),
148
+ "total_minutes": float(duration_minutes),
149
+ "start_frame": start_frame,
150
+ "end_frame": end_frame
151
+ })
152
+
153
+ # Calculate summary statistics
154
+ if lineup_metrics:
155
+ best_lineup = max(lineup_metrics, key=lambda x: x["net_rating"])
156
+ worst_lineup = min(lineup_metrics, key=lambda x: x["net_rating"])
157
+
158
+ summary = {
159
+ "total_lineups": len(lineup_metrics),
160
+ "best_lineup_hash": best_lineup["lineup_hash"],
161
+ "best_lineup_net_rating": best_lineup["net_rating"],
162
+ "worst_lineup_hash": worst_lineup["lineup_hash"],
163
+ "worst_lineup_net_rating": worst_lineup["net_rating"],
164
+ "avg_net_rating": float(np.mean([m["net_rating"] for m in lineup_metrics])),
165
+ }
166
+ else:
167
+ summary = {
168
+ "total_lineups": 0,
169
+ "best_lineup_hash": None,
170
+ "best_lineup_net_rating": 0,
171
+ "worst_lineup_hash": None,
172
+ "worst_lineup_net_rating": 0,
173
+ "avg_net_rating": 0,
174
+ }
175
+
176
+ return {
177
+ "lineup_metrics": lineup_metrics,
178
+ "summary": summary,
179
+ "status": "success"
180
+ }
181
+
182
+ def _detect_lineup_segments(
183
+ self,
184
+ player_assignment: List[Dict],
185
+ ball_possession: List[int],
186
+ fps: float,
187
+ min_duration_seconds: float = 30.0
188
+ ) -> List[Dict]:
189
+ """
190
+ Detect continuous segments with the same 5-player lineup.
191
+
192
+ Args:
193
+ player_assignment: Per-frame team assignments
194
+ ball_possession: Per-frame possession
195
+ fps: Frames per second
196
+ min_duration_seconds: Minimum segment duration to track
197
+
198
+ Returns:
199
+ List of lineup segments
200
+ """
201
+ segments = []
202
+ current_lineups = {1: None, 2: None} # Track lineup for each team
203
+ segment_starts = {1: 0, 2: 0}
204
+
205
+ min_frames = int(min_duration_seconds * fps)
206
+
207
+ for frame_idx in range(len(player_assignment)):
208
+ assignment = player_assignment[frame_idx]
209
+
210
+ # Get players for each team
211
+ team_1_players = sorted([pid for pid, team in assignment.items() if team == 1])
212
+ team_2_players = sorted([pid for pid, team in assignment.items() if team == 2])
213
+
214
+ # Create lineup hashes
215
+ lineup_1_hash = "_".join(map(str, team_1_players)) if len(team_1_players) >= 3 else None
216
+ lineup_2_hash = "_".join(map(str, team_2_players)) if len(team_2_players) >= 3 else None
217
+
218
+ # Check for lineup changes
219
+ for team_id, lineup_hash, players in [
220
+ (1, lineup_1_hash, team_1_players),
221
+ (2, lineup_2_hash, team_2_players)
222
+ ]:
223
+ if lineup_hash is None:
224
+ continue
225
+
226
+ if current_lineups[team_id] != lineup_hash:
227
+ # Lineup changed
228
+ if current_lineups[team_id] is not None:
229
+ # Save previous segment if long enough
230
+ duration = frame_idx - segment_starts[team_id]
231
+ if duration >= min_frames:
232
+ segments.append({
233
+ "team_id": team_id,
234
+ "lineup_hash": current_lineups[team_id],
235
+ "player_ids": self._parse_lineup_hash(current_lineups[team_id]),
236
+ "start_frame": segment_starts[team_id],
237
+ "end_frame": frame_idx
238
+ })
239
+
240
+ # Start new segment
241
+ current_lineups[team_id] = lineup_hash
242
+ segment_starts[team_id] = frame_idx
243
+
244
+ # Close final segments
245
+ for team_id in [1, 2]:
246
+ if current_lineups[team_id] is not None:
247
+ duration = len(player_assignment) - segment_starts[team_id]
248
+ if duration >= min_frames:
249
+ segments.append({
250
+ "team_id": team_id,
251
+ "lineup_hash": current_lineups[team_id],
252
+ "player_ids": self._parse_lineup_hash(current_lineups[team_id]),
253
+ "start_frame": segment_starts[team_id],
254
+ "end_frame": len(player_assignment)
255
+ })
256
+
257
+ return segments
258
+
259
+ def _parse_lineup_hash(self, lineup_hash: str) -> List[int]:
260
+ """Parse lineup hash back to list of player IDs."""
261
+ return [int(x) for x in lineup_hash.split("_")]
analytics_engine/spacing_engine.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Spacing Engine - Analyzes offensive spacing quality.
3
+
4
+ Computes pairwise distances, paint density, and clustering to evaluate
5
+ how well offensive players are spaced on the court.
6
+ """
7
+ from typing import Dict, Any, List
8
+ import numpy as np
9
+ from .base import BaseAnalyticsModule
10
+
11
+
12
+ class SpacingEngine(BaseAnalyticsModule):
13
+ """Analyzes offensive spacing quality using geometric analysis."""
14
+
15
+ def __init__(
16
+ self,
17
+ clustering_threshold_m: float = 1.5,
18
+ good_spacing_threshold_m: float = 3.0,
19
+ average_spacing_threshold_m: float = 2.0,
20
+ paint_width_m: float = 4.9, # Standard NBA paint width
21
+ paint_length_m: float = 5.8, # Standard NBA paint length
22
+ ):
23
+ """
24
+ Initialize spacing engine.
25
+
26
+ Args:
27
+ clustering_threshold_m: Distance below which players are considered clustered
28
+ good_spacing_threshold_m: Average distance for "good" spacing
29
+ average_spacing_threshold_m: Average distance for "average" spacing
30
+ paint_width_m: Width of the paint area in meters
31
+ paint_length_m: Length of the paint area in meters
32
+ """
33
+ super().__init__("spacing_engine")
34
+ self.clustering_threshold = clustering_threshold_m
35
+ self.good_threshold = good_spacing_threshold_m
36
+ self.average_threshold = average_spacing_threshold_m
37
+ self.paint_width = paint_width_m
38
+ self.paint_length = paint_length_m
39
+
40
+ def process(
41
+ self,
42
+ video_frames: List[Any],
43
+ player_tracks: List[Dict],
44
+ ball_tracks: List[Dict],
45
+ tactical_positions: List[Dict],
46
+ player_assignment: List[Dict],
47
+ ball_possession: List[int],
48
+ events: List[Dict],
49
+ shots: List[Dict],
50
+ court_keypoints: List[Dict],
51
+ speeds: List[Dict],
52
+ video_path: str,
53
+ fps: float,
54
+ **kwargs
55
+ ) -> Dict[str, Any]:
56
+ """
57
+ Analyze spacing quality across all frames.
58
+
59
+ Returns:
60
+ Dictionary with spacing metrics for each frame with possession
61
+ """
62
+ spacing_metrics = []
63
+
64
+ for frame_idx in range(len(player_tracks)):
65
+ if frame_idx >= len(tactical_positions) or frame_idx >= len(player_assignment):
66
+ continue
67
+
68
+ if frame_idx >= len(ball_possession):
69
+ continue
70
+
71
+ possession_player = ball_possession[frame_idx]
72
+ if possession_player == -1:
73
+ continue # No possession, skip
74
+
75
+ assignment = player_assignment[frame_idx]
76
+ if possession_player not in assignment:
77
+ continue
78
+
79
+ offense_team = assignment[possession_player]
80
+
81
+ # Get offensive players' tactical positions
82
+ tactical_pos = tactical_positions[frame_idx]
83
+ offensive_positions = []
84
+ offensive_player_ids = []
85
+
86
+ for player_id, team_id in assignment.items():
87
+ if team_id == offense_team and player_id in tactical_pos:
88
+ pos = tactical_pos[player_id]
89
+ if isinstance(pos, (list, tuple)) and len(pos) >= 2:
90
+ offensive_positions.append(pos)
91
+ offensive_player_ids.append(player_id)
92
+
93
+ if len(offensive_positions) < 2:
94
+ continue # Need at least 2 players for spacing analysis
95
+
96
+ # Compute pairwise distances
97
+ distances = []
98
+ for i in range(len(offensive_positions)):
99
+ for j in range(i + 1, len(offensive_positions)):
100
+ dist = self._euclidean_distance(
101
+ offensive_positions[i],
102
+ offensive_positions[j]
103
+ )
104
+ if dist != float('inf'):
105
+ distances.append(dist)
106
+
107
+ if not distances:
108
+ continue
109
+
110
+ avg_distance = np.mean(distances)
111
+
112
+ # Count players in paint (simplified: check if near hoop)
113
+ # Assuming tactical view has hoop at specific location
114
+ # For now, use a heuristic based on y-coordinate
115
+ paint_players = self._count_paint_players(offensive_positions)
116
+
117
+ # Detect overlaps (clustering)
118
+ overlap_count = sum(1 for d in distances if d < self.clustering_threshold)
119
+
120
+ # Classify spacing quality
121
+ if avg_distance >= self.good_threshold:
122
+ quality = "good"
123
+ elif avg_distance >= self.average_threshold:
124
+ quality = "average"
125
+ else:
126
+ quality = "poor"
127
+
128
+ spacing_metrics.append({
129
+ "frame": frame_idx,
130
+ "timestamp": self._get_frame_time(frame_idx, fps),
131
+ "spacing_quality": quality,
132
+ "avg_distance_m": float(avg_distance),
133
+ "paint_players": paint_players,
134
+ "overlap_count": overlap_count,
135
+ "player_positions": {
136
+ str(pid): pos for pid, pos in zip(offensive_player_ids, offensive_positions)
137
+ },
138
+ "offense_team": offense_team,
139
+ })
140
+
141
+ # Aggregate statistics
142
+ if spacing_metrics:
143
+ quality_counts = {"good": 0, "average": 0, "poor": 0}
144
+ for metric in spacing_metrics:
145
+ quality_counts[metric["spacing_quality"]] += 1
146
+
147
+ total = len(spacing_metrics)
148
+ summary = {
149
+ "total_frames_analyzed": total,
150
+ "good_spacing_pct": (quality_counts["good"] / total * 100) if total > 0 else 0,
151
+ "average_spacing_pct": (quality_counts["average"] / total * 100) if total > 0 else 0,
152
+ "poor_spacing_pct": (quality_counts["poor"] / total * 100) if total > 0 else 0,
153
+ "avg_distance_overall": float(np.mean([m["avg_distance_m"] for m in spacing_metrics])),
154
+ }
155
+ else:
156
+ summary = {
157
+ "total_frames_analyzed": 0,
158
+ "good_spacing_pct": 0,
159
+ "average_spacing_pct": 0,
160
+ "poor_spacing_pct": 0,
161
+ "avg_distance_overall": 0,
162
+ }
163
+
164
+ return {
165
+ "spacing_metrics": spacing_metrics,
166
+ "summary": summary,
167
+ "status": "success"
168
+ }
169
+
170
+ def _count_paint_players(self, positions: List[List[float]]) -> int:
171
+ """
172
+ Count how many players are in the paint area.
173
+
174
+ This is a simplified heuristic. In a full implementation, you would
175
+ use court keypoints to define the exact paint boundaries.
176
+
177
+ Args:
178
+ positions: List of [x, y] positions in tactical view
179
+
180
+ Returns:
181
+ Number of players in paint
182
+ """
183
+ # Simplified: assume tactical view has hoop at top (y=0) and paint extends downward
184
+ # This is a placeholder - real implementation should use court_keypoints
185
+ paint_count = 0
186
+ for pos in positions:
187
+ if len(pos) >= 2:
188
+ # Heuristic: if y-coordinate is in top portion of court
189
+ # (This assumes tactical view normalization)
190
+ if pos[1] < 6.0: # Within ~6m of hoop
191
+ paint_count += 1
192
+ return paint_count
analytics_engine/transition_effort.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Transition Effort Engine - Analyzes player effort during transition phases.
3
+
4
+ Tracks player speeds during the 3 seconds after possession changes and
5
+ classifies effort as sprint, jog, or walk.
6
+ """
7
+ from typing import Dict, Any, List
8
+ import numpy as np
9
+ from .base import BaseAnalyticsModule
10
+
11
+
12
+ class TransitionEffortEngine(BaseAnalyticsModule):
13
+ """Analyzes player effort during offensive/defensive transitions."""
14
+
15
+ def __init__(
16
+ self,
17
+ sprint_threshold_mps: float = 5.5,
18
+ jog_threshold_mps: float = 3.0,
19
+ transition_duration_seconds: float = 3.0,
20
+ ):
21
+ """
22
+ Initialize transition effort engine.
23
+
24
+ Args:
25
+ sprint_threshold_mps: Speed threshold for sprint classification
26
+ jog_threshold_mps: Speed threshold for jog classification
27
+ transition_duration_seconds: How long to track after possession change
28
+ """
29
+ super().__init__("transition_effort")
30
+ self.sprint_threshold = sprint_threshold_mps
31
+ self.jog_threshold = jog_threshold_mps
32
+ self.transition_duration = transition_duration_seconds
33
+
34
+ def process(
35
+ self,
36
+ video_frames: List[Any],
37
+ player_tracks: List[Dict],
38
+ ball_tracks: List[Dict],
39
+ tactical_positions: List[Dict],
40
+ player_assignment: List[Dict],
41
+ ball_possession: List[int],
42
+ events: List[Dict],
43
+ shots: List[Dict],
44
+ court_keypoints: List[Dict],
45
+ speeds: List[Dict],
46
+ video_path: str,
47
+ fps: float,
48
+ **kwargs
49
+ ) -> Dict[str, Any]:
50
+ """
51
+ Analyze transition effort for all possession changes.
52
+
53
+ Returns:
54
+ Dictionary with transition effort metrics
55
+ """
56
+ transition_efforts = []
57
+
58
+ # Detect possession changes
59
+ possession_changes = self._detect_possession_changes(
60
+ ball_possession,
61
+ player_assignment
62
+ )
63
+
64
+ transition_window_frames = int(self.transition_duration * fps)
65
+
66
+ for change in possession_changes:
67
+ frame = change["frame"]
68
+ old_team = change["old_team"]
69
+ new_team = change["new_team"]
70
+
71
+ # Analyze effort for both teams
72
+ # Team gaining possession: offense transition
73
+ # Team losing possession: defense transition
74
+
75
+ end_frame = min(frame + transition_window_frames, len(speeds))
76
+
77
+ for analyze_frame in range(frame, end_frame):
78
+ if analyze_frame >= len(player_assignment) or analyze_frame >= len(speeds):
79
+ break
80
+
81
+ assignment = player_assignment[analyze_frame]
82
+ frame_speeds = speeds[analyze_frame]
83
+
84
+ for player_id, team_id in assignment.items():
85
+ if player_id not in frame_speeds:
86
+ continue
87
+
88
+ speed = frame_speeds[player_id]
89
+
90
+ # Classify effort
91
+ if speed >= self.sprint_threshold:
92
+ effort_type = "sprint"
93
+ effort_score = 100
94
+ elif speed >= self.jog_threshold:
95
+ effort_type = "jog"
96
+ effort_score = 60
97
+ else:
98
+ effort_type = "walk"
99
+ effort_score = 20
100
+
101
+ # Determine transition type
102
+ if team_id == new_team:
103
+ transition_type = "defense_to_offense"
104
+ else:
105
+ transition_type = "offense_to_defense"
106
+
107
+ transition_efforts.append({
108
+ "possession_change_frame": frame,
109
+ "player_track_id": player_id,
110
+ "team_id": team_id,
111
+ "transition_type": transition_type,
112
+ "effort_type": effort_type,
113
+ "max_speed_mps": float(speed),
114
+ "effort_score": effort_score,
115
+ "frame": analyze_frame,
116
+ "timestamp": self._get_frame_time(analyze_frame, fps)
117
+ })
118
+
119
+ # Aggregate by possession change
120
+ aggregated_efforts = []
121
+ for change in possession_changes:
122
+ frame = change["frame"]
123
+ change_efforts = [e for e in transition_efforts if e["possession_change_frame"] == frame]
124
+
125
+ if change_efforts:
126
+ # Group by player
127
+ player_efforts = {}
128
+ for effort in change_efforts:
129
+ pid = effort["player_track_id"]
130
+ if pid not in player_efforts:
131
+ player_efforts[pid] = []
132
+ player_efforts[pid].append(effort)
133
+
134
+ # Calculate max speed and avg effort for each player
135
+ for pid, efforts in player_efforts.items():
136
+ max_speed = max(e["max_speed_mps"] for e in efforts)
137
+ avg_speed = np.mean([e["max_speed_mps"] for e in efforts])
138
+ avg_effort_score = np.mean([e["effort_score"] for e in efforts])
139
+
140
+ # Determine overall effort type based on max speed
141
+ if max_speed >= self.sprint_threshold:
142
+ overall_effort = "sprint"
143
+ elif max_speed >= self.jog_threshold:
144
+ overall_effort = "jog"
145
+ else:
146
+ overall_effort = "walk"
147
+
148
+ aggregated_efforts.append({
149
+ "possession_change_frame": frame,
150
+ "player_track_id": pid,
151
+ "team_id": efforts[0]["team_id"],
152
+ "transition_type": efforts[0]["transition_type"],
153
+ "effort_type": overall_effort,
154
+ "max_speed_mps": float(max_speed),
155
+ "avg_speed_mps": float(avg_speed),
156
+ "effort_score": float(avg_effort_score),
157
+ "duration_seconds": self.transition_duration
158
+ })
159
+
160
+ # Calculate summary statistics
161
+ if aggregated_efforts:
162
+ sprint_count = sum(1 for e in aggregated_efforts if e["effort_type"] == "sprint")
163
+ jog_count = sum(1 for e in aggregated_efforts if e["effort_type"] == "jog")
164
+ walk_count = sum(1 for e in aggregated_efforts if e["effort_type"] == "walk")
165
+ total = len(aggregated_efforts)
166
+
167
+ summary = {
168
+ "total_transition_events": total,
169
+ "sprint_count": sprint_count,
170
+ "jog_count": jog_count,
171
+ "walk_count": walk_count,
172
+ "sprint_rate": (sprint_count / total * 100) if total > 0 else 0,
173
+ "avg_effort_score": float(np.mean([e["effort_score"] for e in aggregated_efforts])),
174
+ "avg_max_speed_mps": float(np.mean([e["max_speed_mps"] for e in aggregated_efforts])),
175
+ }
176
+ else:
177
+ summary = {
178
+ "total_transition_events": 0,
179
+ "sprint_count": 0,
180
+ "jog_count": 0,
181
+ "walk_count": 0,
182
+ "sprint_rate": 0,
183
+ "avg_effort_score": 0,
184
+ "avg_max_speed_mps": 0,
185
+ }
186
+
187
+ return {
188
+ "transition_efforts": aggregated_efforts,
189
+ "summary": summary,
190
+ "status": "success"
191
+ }
192
+
193
+ def _detect_possession_changes(
194
+ self,
195
+ ball_possession: List[int],
196
+ player_assignment: List[Dict]
197
+ ) -> List[Dict]:
198
+ """
199
+ Detect frames where possession changes between teams.
200
+
201
+ Args:
202
+ ball_possession: Per-frame possession (track_id or -1)
203
+ player_assignment: Per-frame team assignments
204
+
205
+ Returns:
206
+ List of possession change events
207
+ """
208
+ changes = []
209
+ prev_team = None
210
+
211
+ for frame_idx in range(len(ball_possession)):
212
+ if frame_idx >= len(player_assignment):
213
+ break
214
+
215
+ possession_player = ball_possession[frame_idx]
216
+ if possession_player == -1:
217
+ continue
218
+
219
+ assignment = player_assignment[frame_idx]
220
+ if possession_player not in assignment:
221
+ continue
222
+
223
+ current_team = assignment[possession_player]
224
+
225
+ if prev_team is not None and current_team != prev_team:
226
+ changes.append({
227
+ "frame": frame_idx,
228
+ "old_team": prev_team,
229
+ "new_team": current_team
230
+ })
231
+
232
+ prev_team = current_team
233
+
234
+ return changes
app/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # AI Basketball Performance Analysis Backend API
2
+ """Backend application for basketball performance analysis."""
3
+
4
+ __version__ = "1.0.0"
app/api/admin.py ADDED
@@ -0,0 +1,1158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin API endpoints (Team/Organization Management).
3
+ """
4
+ from uuid import uuid4
5
+ from datetime import datetime
6
+ from typing import Optional, List
7
+ from fastapi import APIRouter, Depends, HTTPException, Query, status, UploadFile, File, BackgroundTasks
8
+
9
+ from app.dependencies import (
10
+ get_supabase,
11
+ get_current_user,
12
+ require_team_account,
13
+ require_organization_admin,
14
+ require_staff_member,
15
+ require_linked_account
16
+ )
17
+ from app.services.supabase_client import SupabaseService
18
+ from app.models.user import User, AccountType
19
+ from app.models.player import Player, PlayerCreate, PlayerUpdate
20
+ from app.models.schedule import Schedule, ScheduleCreate, ScheduleUpdate
21
+ from app.models.match import Match, MatchCreate, MatchUpdate
22
+ from app.models.notification import Notification, NotificationCreate, NotificationListResponse
23
+ from app.models.team import Organization, OrganizationUpdate
24
+ from app.models.stats import MatchStatUploadResponse, StatsConfirmRequest
25
+ from app.services.stats_extraction_service import extract_stats_from_file_background
26
+ from app.services.player_linking_service import auto_link_players_to_roster
27
+
28
+ router = APIRouter()
29
+
30
+ @router.put("/organization", response_model=Organization)
31
+ async def update_organization(
32
+ org_data: OrganizationUpdate,
33
+ current_user: dict = Depends(require_organization_admin),
34
+ supabase: SupabaseService = Depends(get_supabase),
35
+ ):
36
+ """
37
+ Update details of the current user's organization.
38
+ """
39
+ # Find organization owned by current user
40
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
41
+ if not orgs:
42
+ raise HTTPException(status_code=404, detail="Organization not found")
43
+
44
+ org_id = orgs[0]["id"]
45
+ update_dict = org_data.model_dump(exclude_unset=True)
46
+
47
+ if not update_dict:
48
+ raise HTTPException(status_code=400, detail="No fields to update")
49
+
50
+ updated = await supabase.update("organizations", org_id, update_dict)
51
+ return updated
52
+
53
+ # ============================================
54
+ # USERS & PLAYERS MANAGEMENT
55
+ # ============================================
56
+
57
+ @router.put("/users/{user_id}/role")
58
+ async def update_user_role(
59
+ user_id: str,
60
+ role_data: dict,
61
+ current_user: dict = Depends(require_team_account),
62
+ supabase: SupabaseService = Depends(get_supabase),
63
+ ):
64
+ """
65
+ Update a user's role (e.g. within the team context, or maybe account_type).
66
+ """
67
+ # Verify ownership/permission
68
+ # Assuming this updates metadata or account_type?
69
+ # User roles in this system seem to be 'account_type'.
70
+ # If frontend means 'player role' (Forward, Guard), that's usually on player profile.
71
+ # adminAPI.js calls `updateUserRole`.
72
+
73
+ # If it's account_type (team/personal), that's critical.
74
+ # If it's a team role (Captain, Starter), that's not in Users table.
75
+
76
+ # Assuming it updates 'role' metadata for now.
77
+ user_update = {"role": role_data.get("role")}
78
+ updated = await supabase.update("users", user_id, user_update)
79
+ return updated
80
+
81
+
82
+ @router.get("/users")
83
+ async def get_users(
84
+ role: Optional[str] = None,
85
+ current_user: dict = Depends(require_team_account),
86
+ supabase: SupabaseService = Depends(get_supabase),
87
+ ):
88
+ """
89
+ Get users (players) managed by the admin/team owner.
90
+ """
91
+ # Determine organization_id
92
+ org_id = current_user.get("organization_id")
93
+ if not org_id and current_user.get("account_type") == AccountType.TEAM.value:
94
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
95
+ if not orgs:
96
+ return []
97
+ org_id = orgs[0]["id"]
98
+
99
+ if not org_id:
100
+ return []
101
+
102
+ # Get players in the org
103
+ players = await supabase.select("players", filters={"organization_id": str(org_id)})
104
+
105
+ # Identify linked users to fetch their personal profiles for data fallback
106
+ linked_user_ids = [str(p["user_id"]) for p in players if p.get("user_id")]
107
+ personal_profiles = {}
108
+
109
+ if linked_user_ids:
110
+ try:
111
+ # Batch fetch all potential personal profiles for these users
112
+ all_profiles = await supabase.select_in("players", "user_id", linked_user_ids)
113
+ # Filter for true personal profiles (no organization_id)
114
+ for prof in all_profiles:
115
+ if not prof.get("organization_id"):
116
+ personal_profiles[str(prof["user_id"])] = prof
117
+ except Exception as e:
118
+ print(f"Warning: Failed to fetch personal profiles for roster fallback: {e}")
119
+
120
+ # Ensure all fields for the roster are present, falling back to personal data if team data is blank
121
+ for p in players:
122
+ user_id_str = str(p.get("user_id")) if p.get("user_id") else None
123
+ personal = personal_profiles.get(user_id_str) if user_id_str else None
124
+
125
+ # Helper to get value with fallback
126
+ def get_val(field, default=None):
127
+ v = p.get(field)
128
+ # If team-specific value exists and isn't empty, use it
129
+ if v is not None and v != "":
130
+ return v
131
+ # Otherwise, try to use the player's personal profile value
132
+ if personal and personal.get(field) is not None and personal.get(field) != "":
133
+ return personal[field]
134
+ return default
135
+
136
+ p["jersey_number"] = get_val("jersey_number", None)
137
+ p["position"] = get_val("position", "Not set")
138
+ p["status"] = get_val("status", "active")
139
+ # PPG is typically calculated per organization/team context
140
+ p_ppg = p.get("ppg")
141
+ p["ppg"] = float(p_ppg) if p_ppg is not None and p_ppg != "" else 0.0
142
+
143
+ return players
144
+
145
+ @router.post("/players")
146
+ async def create_player(
147
+ player_data: PlayerCreate,
148
+ current_user: dict = Depends(require_team_account),
149
+ supabase: SupabaseService = Depends(get_supabase),
150
+ ):
151
+ """
152
+ Add a new player to the team roster.
153
+ """
154
+ org_id = current_user.get("organization_id")
155
+ if not org_id and current_user.get("account_type") == AccountType.TEAM.value:
156
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
157
+ if not orgs:
158
+ raise HTTPException(status_code=400, detail="No organization found for this account")
159
+ org_id = orgs[0]["id"]
160
+
161
+ if not org_id:
162
+ raise HTTPException(status_code=403, detail="Account not linked to an organization")
163
+ player_dict = player_data.model_dump()
164
+ player_dict["organization_id"] = str(org_id)
165
+ player_dict["id"] = str(uuid4())
166
+ player_dict["created_at"] = datetime.now().isoformat()
167
+
168
+ # Handle date_of_birth if present
169
+ if player_dict.get("date_of_birth"):
170
+ player_dict["date_of_birth"] = player_dict["date_of_birth"].isoformat()
171
+
172
+ saved = await supabase.insert("players", player_dict)
173
+ return saved
174
+
175
+ @router.put("/players/{player_id}")
176
+ async def update_player(
177
+ player_id: str,
178
+ player_data: PlayerUpdate,
179
+ current_user: dict = Depends(require_team_account),
180
+ supabase: SupabaseService = Depends(get_supabase),
181
+ ):
182
+ """
183
+ Update a player's profile.
184
+ """
185
+ # Verify access
186
+ player = await supabase.select_one("players", player_id)
187
+ if not player:
188
+ raise HTTPException(status_code=404, detail="Player not found")
189
+
190
+ has_access = False
191
+ if current_user.get("account_type") == AccountType.TEAM.value:
192
+ if player.get("organization_id"):
193
+ org = await supabase.select_one("organizations", str(player["organization_id"]))
194
+ has_access = org and str(org.get("owner_id")) == str(current_user["id"])
195
+ elif current_user.get("account_type") == AccountType.COACH.value:
196
+ coach_org = current_user.get("organization_id")
197
+ if coach_org and str(player.get("organization_id")) == str(coach_org):
198
+ has_access = True
199
+
200
+ if not has_access:
201
+ raise HTTPException(status_code=403, detail="Access denied")
202
+
203
+ update_dict = player_data.model_dump(exclude_unset=True)
204
+ if "date_of_birth" in update_dict and update_dict["date_of_birth"]:
205
+ update_dict["date_of_birth"] = update_dict["date_of_birth"].isoformat()
206
+
207
+ updated = await supabase.update("players", player_id, update_dict)
208
+ return updated
209
+
210
+ @router.get("/players")
211
+ async def get_roster(
212
+ current_user: dict = Depends(require_team_account),
213
+ supabase: SupabaseService = Depends(get_supabase),
214
+ ):
215
+ """
216
+ Get team roster (alias for players).
217
+ """
218
+ return await get_users(role="player", current_user=current_user, supabase=supabase)
219
+
220
+ @router.patch("/players/{player_id}/status")
221
+ async def update_player_status(
222
+ player_id: str,
223
+ status_data: dict,
224
+ current_user: dict = Depends(require_team_account),
225
+ supabase: SupabaseService = Depends(get_supabase),
226
+ ):
227
+ """
228
+ Update player status (active/injured/etc).
229
+ Note: status field might need to be added to players table or handled via metadata.
230
+ """
231
+ # Check if player belongs to owner's org
232
+ player = await supabase.select_one("players", player_id)
233
+ if not player:
234
+ raise HTTPException(status_code=404, detail="Player not found")
235
+
236
+ org_id = current_user.get("organization_id")
237
+ if not org_id:
238
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
239
+ if orgs:
240
+ org_id = orgs[0]["id"]
241
+
242
+ if not org_id or str(player.get("organization_id")) != str(org_id):
243
+ raise HTTPException(status_code=403, detail="Access denied")
244
+
245
+ try:
246
+ updated = await supabase.update("players", player_id, {"status": status_data.get("status")})
247
+ return updated
248
+ except Exception:
249
+ return player
250
+
251
+
252
+ @router.delete("/players/{player_id}")
253
+ async def delete_player(
254
+ player_id: str,
255
+ current_user: dict = Depends(require_team_account),
256
+ supabase: SupabaseService = Depends(get_supabase),
257
+ ):
258
+ """
259
+ Remove a player from the organization's roster.
260
+ This deletes the roster entry and unlinks the user account if linked.
261
+ """
262
+ # 1. Get player
263
+ player = await supabase.select_one("players", player_id)
264
+ if not player:
265
+ raise HTTPException(status_code=404, detail="Player not found")
266
+
267
+ # 2. Get organization id
268
+ org_id = current_user.get("organization_id")
269
+ if not org_id:
270
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
271
+ if not orgs:
272
+ raise HTTPException(status_code=404, detail="Organization not found")
273
+ org_id = orgs[0]["id"]
274
+
275
+ # 3. Verify ownership
276
+ if player.get("organization_id") != org_id:
277
+ raise HTTPException(status_code=403, detail="Access denied")
278
+
279
+ # 4. Unlink user if associated
280
+ linked_user_id = player.get("user_id")
281
+ if linked_user_id:
282
+ try:
283
+ await supabase.update("users", str(linked_user_id), {"organization_id": None})
284
+ except Exception as e:
285
+ print(f"Warning: Failed to unlink user {linked_user_id}: {e}")
286
+
287
+ # 5. Delete roster entry
288
+ await supabase.delete("players", player_id)
289
+
290
+ return {"message": "Player removed from roster and unlinked successfully"}
291
+
292
+ @router.post("/players/{player_id}/link")
293
+ async def link_player_account(
294
+ player_id: str,
295
+ link_data: dict,
296
+ current_user: dict = Depends(require_team_account),
297
+ supabase: SupabaseService = Depends(get_supabase),
298
+ ):
299
+ """
300
+ Link a roster player to an actual user account by email.
301
+ If player_id is "new", it will search for the user and create a roster entry.
302
+ """
303
+ email = link_data.get("email")
304
+ if not email:
305
+ raise HTTPException(status_code=400, detail="Email is required")
306
+
307
+ # 1. Get organization
308
+ org_id = current_user.get("organization_id")
309
+ if not org_id:
310
+ try:
311
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
312
+ if not orgs:
313
+ raise HTTPException(status_code=404, detail="Organization not found. You must create an organization profile first.")
314
+ org_id = orgs[0]["id"]
315
+ except HTTPException:
316
+ raise
317
+ except Exception as e:
318
+ print(f"Error fetching organization for linking: {e}")
319
+ raise HTTPException(status_code=500, detail="Error retrieving organization data")
320
+
321
+ # 2. Search for user by email
322
+ try:
323
+ users = await supabase.select("users", filters={"email": email})
324
+ if not users:
325
+ raise HTTPException(status_code=404, detail=f"No account found with email {email}. The player must sign up first.")
326
+ target_user = users[0]
327
+ except HTTPException:
328
+ raise
329
+ except Exception as e:
330
+ print(f"Error searching for user by email {email}: {e}")
331
+ raise HTTPException(status_code=500, detail="Error searching for player account")
332
+
333
+ # Check if already linked to another org
334
+ current_linked_org = target_user.get("organization_id")
335
+ if current_linked_org and str(current_linked_org) != str(org_id):
336
+ raise HTTPException(status_code=400, detail="This user is already linked to another organization")
337
+
338
+ # 3. Handle player roster entry
339
+ org_name = "your team"
340
+ if org_id:
341
+ try:
342
+ org_record = await supabase.select_one("organizations", str(org_id))
343
+ if org_record:
344
+ org_name = org_record.get("name", "your team")
345
+ except Exception:
346
+ pass
347
+
348
+ try:
349
+ if player_id == "new":
350
+ # Check if already in roster
351
+ existing_roster = await supabase.select("players", filters={
352
+ "organization_id": str(org_id),
353
+ "user_id": str(target_user["id"])
354
+ })
355
+ if existing_roster:
356
+ raise HTTPException(status_code=400, detail="This player is already in your roster")
357
+
358
+ # Search for an existing personal player profile for this user
359
+ personal_profiles = await supabase.select("players", filters={"user_id": str(target_user["id"])})
360
+ # Filter for the one without an org (personal)
361
+ personal_profile = next((p for p in personal_profiles if not p.get("organization_id")), None)
362
+
363
+ # Create new roster entry
364
+ new_player_id = str(uuid4())
365
+ new_player_record = {
366
+ "id": new_player_id,
367
+ "organization_id": str(org_id),
368
+ "user_id": str(target_user["id"]),
369
+ "name": target_user.get("full_name") or target_user.get("email") or "New Player",
370
+ "created_at": datetime.now().isoformat()
371
+ }
372
+
373
+ # If they have a personal profile, copy the stats
374
+ if personal_profile:
375
+ fields_to_copy = [
376
+ "jersey_number", "position", "height_cm", "weight_kg",
377
+ "date_of_birth", "avatar_url", "phone", "address",
378
+ "experience_years", "bio", "status"
379
+ ]
380
+ for field in fields_to_copy:
381
+ if personal_profile.get(field) is not None:
382
+ new_player_record[field] = personal_profile[field]
383
+
384
+ await supabase.insert("players", new_player_record)
385
+ player_id = new_player_id
386
+ else:
387
+ # Verify existing player belongs to owner's org
388
+ player = await supabase.select_one("players", player_id)
389
+ if not player:
390
+ raise HTTPException(status_code=404, detail="Player not found")
391
+ if str(player.get("organization_id")) != str(org_id):
392
+ raise HTTPException(status_code=403, detail="Access denied")
393
+
394
+ # Update player profile with user_id
395
+ await supabase.update("players", player_id, {"user_id": target_user["id"]})
396
+ except HTTPException:
397
+ raise
398
+ except Exception as e:
399
+ print(f"Error updating/creating roster entry: {e}")
400
+ raise HTTPException(status_code=500, detail=f"Failed to update team roster: {e}")
401
+
402
+ # 4. Update user profile with organization_id
403
+ try:
404
+ await supabase.update("users", target_user["id"], {"organization_id": str(org_id)})
405
+ except Exception as e:
406
+ print(f"Error updating user organization link: {e}")
407
+ # Not a fatal error if roster was linked, but still problematic
408
+
409
+ # 5. Create a notification
410
+ try:
411
+ await supabase.insert("notifications", {
412
+ "id": str(uuid4()),
413
+ "recipient_id": target_user["id"],
414
+ "title": "Team Link",
415
+ "message": f"You have been added to the team roster of {org_name}.",
416
+ "type": "team_invite",
417
+ "read": False,
418
+ "created_at": datetime.now().isoformat()
419
+ })
420
+ except Exception as e:
421
+ print(f"Failed to create link notification: {e}")
422
+
423
+ return {"message": "Account linked successfully", "user": {"id": target_user["id"], "name": target_user.get("full_name") or target_user.get("email")}}
424
+
425
+ @router.get("/staff")
426
+ async def get_staff(
427
+ current_user: dict = Depends(require_team_account),
428
+ supabase: SupabaseService = Depends(get_supabase),
429
+ ):
430
+ """
431
+ Get coaching staff linked to the organization.
432
+ """
433
+ org_id = current_user.get("organization_id")
434
+ if not org_id:
435
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
436
+ if not orgs:
437
+ return []
438
+ org_id = orgs[0]["id"]
439
+
440
+ staff_all = await supabase.select("users", filters={
441
+ "organization_id": org_id
442
+ })
443
+ staff = [u for u in staff_all if u.get("account_type") in ("coach", "team")]
444
+ return staff
445
+
446
+
447
+
448
+ @router.post("/staff/link")
449
+ async def link_staff_member(
450
+ link_data: dict, # email, role
451
+ current_user: dict = Depends(require_organization_admin),
452
+ supabase: SupabaseService = Depends(get_supabase),
453
+ ):
454
+ """
455
+ Link a coach account to the organization and assign a role.
456
+ """
457
+ email = link_data.get("email")
458
+ role = link_data.get("role", "Coach")
459
+ if not email:
460
+ raise HTTPException(status_code=400, detail="Email is required")
461
+
462
+ # 1. Get organization
463
+ org_id = current_user.get("organization_id")
464
+ if not org_id:
465
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
466
+ if not orgs:
467
+ raise HTTPException(status_code=404, detail="Organization not found")
468
+ org_id = orgs[0]["id"]
469
+
470
+ # 2. Search for user by email
471
+ users = await supabase.select("users", filters={"email": email})
472
+ if not users:
473
+ raise HTTPException(status_code=404, detail=f"No account found with email {email}. The coach must sign up first.")
474
+
475
+ target_user = users[0]
476
+
477
+ if target_user["account_type"] != "coach":
478
+ raise HTTPException(status_code=400, detail="This account is not a Coach account.")
479
+
480
+ if target_user.get("organization_id") and target_user.get("organization_id") != org_id:
481
+ raise HTTPException(status_code=400, detail="This coach is already linked to another organization")
482
+
483
+ # 3. Update user profile
484
+ await supabase.update("users", target_user["id"], {
485
+ "organization_id": org_id,
486
+ "staff_role": role
487
+ })
488
+
489
+ # 4. Create notification
490
+ try:
491
+ await supabase.insert("notifications", {
492
+ "id": str(uuid4()),
493
+ "recipient_id": target_user["id"],
494
+ "title": "Staff Invitation",
495
+ "message": f"You have been added as a {role} to the organization.",
496
+ "type": "team_invite",
497
+ "read": False,
498
+ "created_at": datetime.now().isoformat()
499
+ })
500
+ except Exception:
501
+ pass
502
+
503
+ return {"message": "Staff member linked successfully", "user": {"id": target_user["id"], "role": role}}
504
+
505
+ @router.delete("/staff/{user_id}")
506
+ async def remove_staff_member(
507
+ user_id: str,
508
+ current_user: dict = Depends(require_organization_admin),
509
+ supabase: SupabaseService = Depends(get_supabase),
510
+ ):
511
+ """
512
+ Remove a staff member from the organization.
513
+ """
514
+ org_id = current_user.get("organization_id")
515
+ if not org_id:
516
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
517
+ if not orgs:
518
+ raise HTTPException(status_code=404, detail="Organization not found")
519
+ org_id = orgs[0]["id"]
520
+
521
+ # Verify user belongs to org
522
+ target_user = await supabase.select_one("users", user_id)
523
+ if not target_user or target_user.get("organization_id") != org_id:
524
+ raise HTTPException(status_code=403, detail="Access denied or user not found")
525
+
526
+ # Unlink
527
+ await supabase.update("users", user_id, {
528
+ "organization_id": None,
529
+ "staff_role": None
530
+ })
531
+
532
+ return {"message": "Staff member removed successfully"}
533
+
534
+ # ============================================
535
+ # SCHEDULE MANAGEMENT
536
+ # ============================================
537
+
538
+ @router.get("/schedule")
539
+ async def get_schedule(
540
+ current_user: dict = Depends(require_team_account),
541
+ supabase: SupabaseService = Depends(get_supabase),
542
+ ):
543
+ """Get team schedule."""
544
+ org_id = current_user.get("organization_id")
545
+ if not org_id:
546
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
547
+ if not orgs:
548
+ return []
549
+ org_id = orgs[0]["id"]
550
+
551
+ schedules = await supabase.select("schedules", filters={"organization_id": org_id})
552
+ return schedules
553
+
554
+ @router.post("/schedule")
555
+ async def create_schedule_event(
556
+ event_data: ScheduleCreate,
557
+ current_user: dict = Depends(require_staff_member),
558
+ supabase: SupabaseService = Depends(get_supabase),
559
+ ):
560
+ """Create a schedule event (Coach only)."""
561
+ org_id = current_user.get("organization_id")
562
+ if not org_id:
563
+ raise HTTPException(status_code=403, detail="You must be linked to an organization")
564
+
565
+ event_dict = event_data.model_dump()
566
+ event_dict["organization_id"] = str(org_id)
567
+ event_dict["created_by"] = current_user["id"]
568
+ event_dict["id"] = str(uuid4())
569
+
570
+ event_dict["start_time"] = event_dict["start_time"].isoformat()
571
+ event_dict["end_time"] = event_dict["end_time"].isoformat()
572
+
573
+ # Extract extra fields
574
+ player_ids = event_dict.pop("playerIds", None)
575
+ event_date = event_dict.pop("date", "the scheduled date")
576
+ event_dict.pop("coachId", None)
577
+
578
+ saved = await supabase.insert("schedules", event_dict)
579
+
580
+ # Notify assigned players
581
+ if player_ids:
582
+ try:
583
+ for pid in player_ids:
584
+ # Look up the player to get their user_id
585
+ player = await supabase.select_one("players", pid)
586
+ if player and player.get("user_id"):
587
+ await supabase.insert("notifications", {
588
+ "id": str(uuid4()),
589
+ "recipient_id": player["user_id"],
590
+ "title": f"New Event: {event_dict['title']}",
591
+ "message": f"You have been assigned to {event_dict['title']} on {event_date}.",
592
+ "type": "training",
593
+ "read": False,
594
+ "created_at": datetime.now().isoformat()
595
+ })
596
+ except Exception as e:
597
+ print(f"Warning: Failed to send schedule notifications: {e}")
598
+
599
+ return saved
600
+
601
+ @router.put("/schedule/{event_id}")
602
+ async def update_schedule_event(
603
+ event_id: str,
604
+ event_data: ScheduleUpdate,
605
+ current_user: dict = Depends(require_staff_member),
606
+ supabase: SupabaseService = Depends(get_supabase),
607
+ ):
608
+ """Update a schedule event (Coach only)."""
609
+ # Verify owner's org match
610
+ event = await supabase.select_one("schedules", event_id)
611
+ if not event or event.get("organization_id") != current_user.get("organization_id"):
612
+ raise HTTPException(status_code=403, detail="Access denied")
613
+
614
+ update_dict = event_data.model_dump(exclude_unset=True)
615
+ if not update_dict:
616
+ raise HTTPException(status_code=400, detail="No fields to update")
617
+
618
+ if "start_time" in update_dict:
619
+ update_dict["start_time"] = update_dict["start_time"].isoformat()
620
+ if "end_time" in update_dict:
621
+ update_dict["end_time"] = update_dict["end_time"].isoformat()
622
+
623
+ updated = await supabase.update("schedules", event_id, update_dict)
624
+ return updated
625
+
626
+ @router.delete("/schedule/{event_id}")
627
+ async def delete_schedule_event(
628
+ event_id: str,
629
+ current_user: dict = Depends(require_staff_member),
630
+ supabase: SupabaseService = Depends(get_supabase),
631
+ ):
632
+ """Delete a schedule event (Coach only)."""
633
+ event = await supabase.select_one("schedules", event_id)
634
+ if not event or event.get("organization_id") != current_user.get("organization_id"):
635
+ raise HTTPException(status_code=403, detail="Access denied")
636
+
637
+ await supabase.delete("schedules", event_id)
638
+ return {"message": "Event deleted"}
639
+ # ============================================
640
+ # MATCH MANAGEMENT
641
+ # ============================================
642
+
643
+ @router.get("/matches")
644
+ async def get_matches(
645
+ current_user: dict = Depends(require_team_account),
646
+ supabase: SupabaseService = Depends(get_supabase),
647
+ ):
648
+ """Get matches for the current user's organization (Owner or Staff)."""
649
+ org_id = current_user.get("organization_id")
650
+ if not org_id:
651
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
652
+ if not orgs:
653
+ return []
654
+ org_id = orgs[0]["id"]
655
+
656
+ matches = await supabase.select("matches", filters={"organization_id": org_id})
657
+ return matches
658
+
659
+ @router.post("/matches")
660
+ async def create_match(
661
+ match_data: MatchCreate,
662
+ current_user: dict = Depends(require_staff_member),
663
+ supabase: SupabaseService = Depends(get_supabase),
664
+ ):
665
+ """Create a match (Coach only)."""
666
+ org_id = current_user.get("organization_id")
667
+ if not org_id:
668
+ raise HTTPException(status_code=403, detail="You must be linked to an organization")
669
+
670
+ match_dict = match_data.model_dump()
671
+ match_dict["id"] = str(uuid4())
672
+ match_dict["organization_id"] = str(org_id)
673
+ match_dict["date"] = match_dict["date"].isoformat()
674
+
675
+ saved = await supabase.insert("matches", match_dict)
676
+ return saved
677
+
678
+ @router.put("/matches/{match_id}")
679
+ async def update_match(
680
+ match_id: str,
681
+ match_data: MatchUpdate,
682
+ current_user: dict = Depends(require_staff_member),
683
+ supabase: SupabaseService = Depends(get_supabase),
684
+ ):
685
+ """Update a match (Coach only)."""
686
+ match = await supabase.select_one("matches", match_id)
687
+ if not match or match.get("organization_id") != current_user.get("organization_id"):
688
+ raise HTTPException(status_code=403, detail="Access denied")
689
+
690
+ update_dict = match_data.model_dump(exclude_unset=True)
691
+ if "date" in update_dict:
692
+ update_dict["date"] = update_dict["date"].isoformat()
693
+
694
+ updated = await supabase.update("matches", match_id, update_dict)
695
+ return updated
696
+
697
+ @router.delete("/matches/{match_id}")
698
+ async def delete_match(
699
+ match_id: str,
700
+ current_user: dict = Depends(require_staff_member),
701
+ supabase: SupabaseService = Depends(get_supabase),
702
+ ):
703
+ """Delete a match (Coach only)."""
704
+ match = await supabase.select_one("matches", match_id)
705
+ if not match or match.get("organization_id") != current_user.get("organization_id"):
706
+ raise HTTPException(status_code=403, detail="Access denied")
707
+
708
+ await supabase.delete("matches", match_id)
709
+ return {"message": "Match deleted"}
710
+
711
+
712
+ @router.get("/matches/{match_id}/player-stats")
713
+ async def get_match_player_stats(
714
+ match_id: str,
715
+ current_user: dict = Depends(require_team_account),
716
+ supabase: SupabaseService = Depends(get_supabase),
717
+ ):
718
+ """Get all player stats for a specific match, enriched with player name/jersey info."""
719
+ # Verify org access
720
+ org_id = current_user.get("organization_id")
721
+ if not org_id:
722
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
723
+ if not orgs:
724
+ return []
725
+ org_id = orgs[0]["id"]
726
+
727
+ stats = await supabase.select("match_player_stats", filters={
728
+ "match_id": match_id,
729
+ "organization_id": org_id
730
+ })
731
+
732
+ # Enrich each stat row with player name/jersey from the players table
733
+ player_ids = list({s["player_profile_id"] for s in stats if s.get("player_profile_id")})
734
+ players = []
735
+ if player_ids:
736
+ players = await supabase.select_in("players", "id", player_ids)
737
+ player_map = {p["id"]: p for p in players}
738
+
739
+ for s in stats:
740
+ pid = s.get("player_profile_id")
741
+ p = player_map.get(pid, {})
742
+ s["player_name"] = p.get("name", "Unknown")
743
+ s["player_jersey"] = p.get("jersey_number")
744
+ s["player_position"] = p.get("position")
745
+
746
+ match = await supabase.select_one("matches", match_id)
747
+ return {"match": match, "stats": stats}
748
+
749
+ # ============================================
750
+ # NOTIFICATIONS & STATS
751
+ # ============================================
752
+
753
+ @router.get("/notifications")
754
+ async def get_notifications(
755
+ current_user: dict = Depends(get_current_user),
756
+ supabase: SupabaseService = Depends(get_supabase),
757
+ ):
758
+ try:
759
+ user_id = current_user["id"]
760
+ # Basic validation for UUID if using Postgres
761
+ import uuid
762
+ try:
763
+ uuid.UUID(str(user_id))
764
+ except ValueError:
765
+ # If not a UUID (like dev-id-...), return empty list instead of crashing Postgres
766
+ return []
767
+
768
+ notifs = await supabase.select("notifications", filters={"recipient_id": str(user_id)}, order_by="created_at")
769
+ return notifs
770
+ except Exception as e:
771
+ print(f"Error in get_notifications: {e}")
772
+ return [] # Return empty list on error to keep UI stable
773
+
774
+ @router.post("/notifications")
775
+ async def create_notification(
776
+ notif_data: NotificationCreate,
777
+ current_user: dict = Depends(require_team_account),
778
+ supabase: SupabaseService = Depends(get_supabase),
779
+ ):
780
+ notif_dict = notif_data.model_dump()
781
+ notif_dict["id"] = str(uuid4())
782
+ saved = await supabase.insert("notifications", notif_dict)
783
+ return saved
784
+
785
+ @router.put("/notifications/{notification_id}/mark-as-read")
786
+ @router.put("/notifications/{notification_id}/read")
787
+ async def mark_notification_read(
788
+ notification_id: str,
789
+ current_user: dict = Depends(get_current_user),
790
+ supabase: SupabaseService = Depends(get_supabase),
791
+ ):
792
+ updated = await supabase.update("notifications", notification_id, {"read": True})
793
+ return updated
794
+
795
+ @router.put("/notifications/read-all")
796
+ async def mark_all_notifications_read(
797
+ current_user: dict = Depends(get_current_user),
798
+ supabase: SupabaseService = Depends(get_supabase),
799
+ ):
800
+ # Mark all unread notifications for the current user as read
801
+ notifs = await supabase.select("notifications", filters={"recipient_id": current_user["id"], "read": False})
802
+ for n in notifs:
803
+ await supabase.update("notifications", n["id"], {"read": True})
804
+ return {"message": "All notifications marked as read"}
805
+
806
+ @router.put("/notifications/{notification_id}")
807
+ async def update_notification(
808
+ notification_id: str,
809
+ notification_data: NotificationCreate,
810
+ current_user: dict = Depends(require_team_account),
811
+ supabase: SupabaseService = Depends(get_supabase),
812
+ ):
813
+ update_dict = notification_data.model_dump(exclude_unset=True)
814
+ updated = await supabase.update("notifications", notification_id, update_dict)
815
+ return updated
816
+
817
+ @router.delete("/notifications/{notification_id}")
818
+ async def delete_notification(
819
+ notification_id: str,
820
+ current_user: dict = Depends(get_current_user),
821
+ supabase: SupabaseService = Depends(get_supabase),
822
+ ):
823
+ await supabase.delete("notifications", notification_id)
824
+ return {"message": "Notification deleted"}
825
+
826
+
827
+ @router.get("/stats")
828
+ async def get_stats(
829
+ current_user: dict = Depends(require_team_account),
830
+ supabase: SupabaseService = Depends(get_supabase),
831
+ ):
832
+ # Mock aggregation or real counts
833
+ org_id = current_user.get("organization_id")
834
+ if not org_id:
835
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
836
+ if not orgs:
837
+ return {"players": 0, "matches": 0, "wins": 0}
838
+ org_id = orgs[0]["id"]
839
+ players = await supabase.select("players", filters={"organization_id": org_id})
840
+ matches = await supabase.select("matches", filters={"organization_id": org_id})
841
+ videos = await supabase.select("videos", filters={"organization_id": org_id})
842
+
843
+ return {
844
+ "total_players": len(players),
845
+ "total_matches": len(matches),
846
+ "videos_analyzed": len(videos),
847
+ "upcoming_matches": len([m for m in matches if datetime.fromisoformat(m["date"].replace("Z", "+00:00")).replace(tzinfo=None) > datetime.utcnow()]),
848
+ }
849
+
850
+ # ============================================
851
+ # SECURITY & PROFILE
852
+ # ============================================
853
+
854
+ @router.get("/profile")
855
+ async def get_profile(
856
+ current_user: dict = Depends(require_team_account),
857
+ supabase: SupabaseService = Depends(get_supabase),
858
+ ):
859
+ # Just return user info plus org info
860
+ user_info = current_user
861
+ org_id = current_user.get("organization_id")
862
+ org = None
863
+
864
+ if org_id:
865
+ org = await supabase.select_one("organizations", str(org_id))
866
+ elif current_user.get("account_type") == "team":
867
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
868
+ if orgs:
869
+ org = orgs[0]
870
+
871
+ return {"user": user_info, "organization": org}
872
+
873
+ @router.put("/profile")
874
+ async def update_profile(
875
+ profile_data: dict,
876
+ current_user: dict = Depends(require_team_account),
877
+ supabase: SupabaseService = Depends(get_supabase),
878
+ ):
879
+ # Update user or org
880
+ if "user" in profile_data:
881
+ user_payload = {}
882
+ for k, v in profile_data["user"].items():
883
+ if k in ["id", "created_at", "updated_at", "email", "account_type"]:
884
+ continue
885
+ user_payload[k] = v
886
+ if user_payload:
887
+ await supabase.update("users", current_user["id"], user_payload)
888
+ if "organization" in profile_data:
889
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
890
+ if orgs:
891
+ # Avoid sending nested objects (which might not map to DB columns)
892
+ org_payload = {}
893
+ for k, v in (profile_data["organization"] or {}).items():
894
+ # Do not try to update primary key or generated fields
895
+ if k in ["id", "owner_id", "created_at", "updated_at"]:
896
+ continue
897
+ org_payload[k] = v
898
+
899
+ if org_payload:
900
+ print(f"DEBUG update_profile: updating org {orgs[0]['id']} with payload: {org_payload}")
901
+ try:
902
+ await supabase.update("organizations", orgs[0]["id"], org_payload)
903
+ print("DEBUG update_profile: Supabase update successful")
904
+ except Exception as e:
905
+ print(f"DEBUG update_profile: Supabase update failed: {e}")
906
+
907
+ return {"message": "Profile updated"}
908
+
909
+ @router.get("/security/settings")
910
+ async def get_security_settings(current_user: dict = Depends(get_current_user)):
911
+ # Mock settings
912
+ return {"two_factor_enabled": False, "login_alerts": True}
913
+
914
+ @router.put("/security/settings")
915
+ async def update_security_settings(settings: dict, current_user: dict = Depends(get_current_user)):
916
+ # Mock update
917
+ return settings
918
+
919
+ @router.get("/security/logs")
920
+ async def get_security_logs(current_user: dict = Depends(get_current_user)):
921
+ # Mock logs
922
+ return [
923
+ {"id": 1, "action": "Login", "ip": "127.0.0.1", "timestamp": datetime.now().isoformat()},
924
+ ]
925
+
926
+ # ============================================
927
+ # OFFICIAL STATS IMPORT
928
+ # ============================================
929
+
930
+ @router.post("/matches/{match_id}/stats-upload")
931
+ async def upload_match_stats(
932
+ match_id: str,
933
+ background_tasks: BackgroundTasks,
934
+ file: UploadFile = File(...),
935
+ current_user: dict = Depends(require_staff_member),
936
+ supabase: SupabaseService = Depends(get_supabase),
937
+ ):
938
+ """Upload a box score PDF or image, save to storage, schedule extraction."""
939
+ # 1. Verify match ownership
940
+ match = await supabase.select_one("matches", match_id)
941
+ org_id = current_user.get("organization_id")
942
+ if not org_id:
943
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
944
+ if orgs:
945
+ org_id = orgs[0]["id"]
946
+
947
+ if not match or match.get("organization_id") != org_id:
948
+ raise HTTPException(status_code=403, detail="Access denied")
949
+
950
+ org_id = match["organization_id"]
951
+ upload_id = str(uuid4())
952
+
953
+ # 2. Upload file to storage
954
+ file_bytes = await file.read()
955
+ is_pdf = file.content_type == "application/pdf"
956
+ file_ext = "pdf" if is_pdf else "jpg"
957
+ storage_path = f"org_{org_id}/match_{match_id}/{upload_id}.{file_ext}"
958
+
959
+ try:
960
+ url = await supabase.upload_file("match-stats", storage_path, file_bytes, file.content_type)
961
+ except Exception as e:
962
+ print(f"Warning: file upload skip or error: {e}")
963
+ url = f"/local/{storage_path}"
964
+
965
+ # 3. Create upload record
966
+ upload_data = {
967
+ "id": upload_id,
968
+ "match_id": match_id,
969
+ "organization_id": org_id,
970
+ "uploaded_by": current_user["id"],
971
+ "storage_path": storage_path,
972
+ "file_type": "pdf" if is_pdf else "image",
973
+ "extract_status": "queued",
974
+ "created_at": datetime.now().isoformat(),
975
+ "updated_at": datetime.now().isoformat()
976
+ }
977
+ await supabase.insert("match_stat_uploads", upload_data)
978
+
979
+ # 4. Trigger extraction job
980
+ background_tasks.add_task(extract_stats_from_file_background, upload_id, org_id)
981
+
982
+ return {"upload_id": upload_id, "status": "queued"}
983
+
984
+ @router.get("/stats-upload/{upload_id}", response_model=MatchStatUploadResponse)
985
+ async def get_stats_upload(
986
+ upload_id: str,
987
+ current_user: dict = Depends(require_staff_member),
988
+ supabase: SupabaseService = Depends(get_supabase),
989
+ ):
990
+ """Poll for extraction status and JSON preview."""
991
+ org_id = current_user.get("organization_id")
992
+ if not org_id:
993
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
994
+ if orgs:
995
+ org_id = orgs[0]["id"]
996
+
997
+ upload = await supabase.select_one("match_stat_uploads", upload_id)
998
+ if not upload or upload.get("organization_id") != org_id:
999
+ raise HTTPException(status_code=404, detail="Upload not found")
1000
+
1001
+ return upload
1002
+
1003
+ @router.post("/stats-upload/{upload_id}/confirm")
1004
+ async def confirm_stats_upload(
1005
+ upload_id: str,
1006
+ confirm_data: StatsConfirmRequest,
1007
+ current_user: dict = Depends(require_staff_member),
1008
+ supabase: SupabaseService = Depends(get_supabase),
1009
+ ):
1010
+ """Confirm mapping and persist stats."""
1011
+ org_id = current_user.get("organization_id")
1012
+ if not org_id:
1013
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
1014
+ if orgs:
1015
+ org_id = orgs[0]["id"]
1016
+
1017
+ upload = await supabase.select_one("match_stat_uploads", upload_id)
1018
+ if not upload or upload.get("organization_id") != org_id:
1019
+ raise HTTPException(status_code=404, detail="Upload not found")
1020
+
1021
+ org_id = upload["organization_id"]
1022
+ match_id = upload["match_id"]
1023
+
1024
+ confirmed_json = confirm_data.extracted_json
1025
+
1026
+ # Update match score
1027
+ if confirmed_json.final_score_for is not None:
1028
+ await supabase.update("matches", match_id, {
1029
+ "score_us": confirmed_json.final_score_for,
1030
+ "score_them": confirmed_json.final_score_against
1031
+ })
1032
+
1033
+ # Persist the rows
1034
+ stats_to_insert = []
1035
+
1036
+ import re
1037
+ from app.services.stats_utils import parse_made_attempted
1038
+
1039
+ # 1. Collect all players (starters + bench)
1040
+ all_players_to_save = []
1041
+ if confirmed_json.starters:
1042
+ for p in confirmed_json.starters:
1043
+ p.is_starter = True
1044
+ all_players_to_save.append(p)
1045
+ if confirmed_json.bench:
1046
+ for p in confirmed_json.bench:
1047
+ p.is_starter = False
1048
+ all_players_to_save.append(p)
1049
+
1050
+ # Fallback if starters/bench were not populated but players was
1051
+ if not all_players_to_save and confirmed_json.players:
1052
+ all_players_to_save = confirmed_json.players
1053
+
1054
+ # 2. Prepare stat rows for database
1055
+ stats_to_insert = []
1056
+ for player_row in all_players_to_save:
1057
+ if not player_row.linked_player_profile_id:
1058
+ continue
1059
+
1060
+ # Use already parsed values if available, otherwise try to parse from the ratio string
1061
+ fg_m, fg_a = (player_row.fg_m, player_row.fg_a) if (player_row.fg_m or player_row.fg_a) else parse_made_attempted(player_row.fg)
1062
+ tp_m, tp_a = (player_row.tp_m, player_row.tp_a) if (player_row.tp_m or player_row.tp_a) else parse_made_attempted(player_row.tp)
1063
+ thp_m, thp_a = (player_row.thp_m, player_row.thp_a) if (player_row.thp_m or player_row.thp_a) else parse_made_attempted(player_row.thp)
1064
+ ft_m, ft_a = (player_row.ft_m, player_row.ft_a) if (player_row.ft_m or player_row.ft_a) else parse_made_attempted(player_row.ft)
1065
+
1066
+ stats_to_insert.append({
1067
+ "id": str(uuid4()),
1068
+ "match_id": match_id,
1069
+ "organization_id": org_id,
1070
+ "player_profile_id": str(player_row.linked_player_profile_id),
1071
+ "source": "official_upload",
1072
+ "mins": player_row.mins,
1073
+ "pts": player_row.pts,
1074
+ "fgm": fg_m,
1075
+ "fga": fg_a,
1076
+ "tp_m": tp_m,
1077
+ "tp_a": tp_a,
1078
+ "thp_m": thp_m,
1079
+ "thp_a": thp_a,
1080
+ "ft_m": ft_m,
1081
+ "ft_a": ft_a,
1082
+ "off_reb": player_row.off,
1083
+ "def_reb": player_row.def_reb,
1084
+ "reb": player_row.reb,
1085
+ "ast": player_row.ast,
1086
+ "to_cnt": player_row.to_cnt,
1087
+ "stl": player_row.stl,
1088
+ "blk": player_row.blk,
1089
+ "blkr": player_row.blkr,
1090
+ "pf": player_row.pf,
1091
+ "fls_on": player_row.fls_on,
1092
+ "plus_minus": player_row.plus_minus or 0,
1093
+ "index_rating": player_row.index or 0,
1094
+ "row_confidence": player_row.row_confidence or 1.0,
1095
+ "is_starter": player_row.is_starter,
1096
+ "created_at": datetime.now().isoformat()
1097
+ })
1098
+
1099
+ # 3. Bulk insert/update player stats
1100
+ for stat in stats_to_insert:
1101
+ try:
1102
+ # Check for existing stat row for this player in this match
1103
+ existing = await supabase.select("match_player_stats", filters={
1104
+ "match_id": stat["match_id"],
1105
+ "player_profile_id": stat["player_profile_id"]
1106
+ })
1107
+ if existing:
1108
+ await supabase.update("match_player_stats", existing[0]["id"], stat)
1109
+ else:
1110
+ await supabase.insert("match_player_stats", stat)
1111
+ except Exception as e:
1112
+ print(f"Error persisting player stat row for {stat['player_profile_id']}: {e}")
1113
+
1114
+ # 4. Persist team statistics
1115
+ if confirmed_json.team_statistics:
1116
+ try:
1117
+ team_stats = confirmed_json.team_statistics.model_dump()
1118
+ team_stats["match_id"] = match_id
1119
+ team_stats["organization_id"] = org_id
1120
+
1121
+ existing_team = await supabase.select("match_team_stats", filters={"match_id": match_id})
1122
+ if existing_team:
1123
+ await supabase.update("match_team_stats", existing_team[0]["id"], team_stats)
1124
+ else:
1125
+ await supabase.insert("match_team_stats", team_stats)
1126
+ except Exception as e:
1127
+ print(f"Error persisting team stats: {e}")
1128
+
1129
+ # 5. Mark upload as confirmed
1130
+ await supabase.update("match_stat_uploads", upload_id, {
1131
+ "extract_status": "confirmed",
1132
+ "extracted_json": confirmed_json.model_dump(by_alias=True)
1133
+ })
1134
+
1135
+ return {"message": "Stats confirmed and saved"}
1136
+
1137
+ @router.post("/stats-upload/{upload_id}/retry")
1138
+ async def retry_stats_upload(
1139
+ upload_id: str,
1140
+ background_tasks: BackgroundTasks,
1141
+ current_user: dict = Depends(require_staff_member),
1142
+ supabase: SupabaseService = Depends(get_supabase),
1143
+ ):
1144
+ """Re-run extraction."""
1145
+ org_id = current_user.get("organization_id")
1146
+ if not org_id:
1147
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
1148
+ if orgs:
1149
+ org_id = orgs[0]["id"]
1150
+
1151
+ upload = await supabase.select_one("match_stat_uploads", upload_id)
1152
+ if not upload or upload.get("organization_id") != org_id:
1153
+ raise HTTPException(status_code=404, detail="Upload not found")
1154
+
1155
+ await supabase.update("match_stat_uploads", upload_id, {"extract_status": "queued"})
1156
+ background_tasks.add_task(extract_stats_from_file_background, upload_id, upload["organization_id"])
1157
+
1158
+ return {"message": "Retry queued"}
app/api/advanced_analytics.py ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Advanced Analytics API endpoints.
3
+
4
+ Provides access to advanced basketball analytics including spacing, defensive reactions,
5
+ transition effort, decision quality, lineup impact, fatigue tracking, and auto-generated clips.
6
+ """
7
+ from typing import List
8
+ from fastapi import APIRouter, Depends, HTTPException, status
9
+ from uuid import UUID
10
+
11
+ from app.dependencies import get_current_user, get_supabase
12
+ from app.models.advanced_analytics import (
13
+ TeamAdvancedSummary,
14
+ PlayerAdvancedAnalysis,
15
+ LineupComparison,
16
+ ClipCatalog,
17
+ SpacingSummary,
18
+ DefensiveReactionSummary,
19
+ TransitionEffortSummary,
20
+ DecisionQualitySummary,
21
+ LineupImpactSummary,
22
+ FatigueSummary,
23
+ ClipSummary,
24
+ LineupMetric,
25
+ AutoClip,
26
+ DefensiveReaction,
27
+ TransitionEffort,
28
+ DecisionAnalysis,
29
+ FatigueIndex,
30
+ )
31
+ from app.services.supabase_client import SupabaseService
32
+
33
+
34
+ router = APIRouter()
35
+
36
+
37
+ def _hydrate_analysis_result(result: dict) -> dict:
38
+ """Extract summary stats from events JSONB if they exist and merge into top-level."""
39
+ if not result or "events" not in result:
40
+ return result
41
+
42
+ events = result.get("events", [])
43
+ if not isinstance(events, list):
44
+ return result
45
+
46
+ for event in events:
47
+ if isinstance(event, dict) and event.get("event_type") == "summary_stats":
48
+ details = event.get("details", {})
49
+ if isinstance(details, dict):
50
+ # Only fill if the main result field is missing or None
51
+ for key, value in details.items():
52
+ if key not in result or result[key] is None or key == "advanced_analytics":
53
+ result[key] = value
54
+ break
55
+ return result
56
+
57
+
58
+
59
+ @router.get("/team-summary/{video_id}", response_model=TeamAdvancedSummary)
60
+ async def get_team_advanced_summary(
61
+ video_id: str,
62
+ current_user: dict = Depends(get_current_user),
63
+ supabase: SupabaseService = Depends(get_supabase),
64
+ ):
65
+ """
66
+ Get aggregated advanced analytics summary for a team game.
67
+
68
+ Returns high-level statistics from all 7 analytics modules.
69
+ """
70
+ # Verify video access
71
+ video = await supabase.select_one("videos", video_id)
72
+ if not video:
73
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Video not found")
74
+
75
+ if video["uploader_id"] != current_user["id"]:
76
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
77
+
78
+ # Get analysis results
79
+ analysis_results = await supabase.select(
80
+ "analysis_results",
81
+ filters={"video_id": video_id},
82
+ order_by="created_at",
83
+ ascending=False,
84
+ limit=1
85
+ )
86
+
87
+ if not analysis_results:
88
+ raise HTTPException(
89
+ status_code=status.HTTP_404_NOT_FOUND,
90
+ detail="No analysis results found for this video"
91
+ )
92
+
93
+ analysis = analysis_results[0]
94
+ analysis = _hydrate_analysis_result(analysis)
95
+ advanced_analytics = analysis.get("advanced_analytics")
96
+
97
+ if not advanced_analytics:
98
+ return TeamAdvancedSummary(
99
+ video_id=UUID(video_id),
100
+ spacing_summary=None,
101
+ defensive_summary=None,
102
+ transition_summary=None,
103
+ decision_summary=None,
104
+ lineup_summary=None,
105
+ fatigue_summary=None,
106
+ clip_summary=None,
107
+ modules_executed=[],
108
+ modules_failed=[]
109
+ )
110
+
111
+ # Extract summaries from each module
112
+ spacing_summary = None
113
+ if "spacing" in advanced_analytics and advanced_analytics["spacing"].get("status") == "success":
114
+ spacing_summary = SpacingSummary(**advanced_analytics["spacing"]["summary"])
115
+
116
+ defensive_summary = None
117
+ if "defensive_reactions" in advanced_analytics and advanced_analytics["defensive_reactions"].get("status") == "success":
118
+ defensive_summary = DefensiveReactionSummary(**advanced_analytics["defensive_reactions"]["summary"])
119
+
120
+ transition_summary = None
121
+ if "transition_effort" in advanced_analytics and advanced_analytics["transition_effort"].get("status") == "success":
122
+ transition_summary = TransitionEffortSummary(**advanced_analytics["transition_effort"]["summary"])
123
+
124
+ decision_summary = None
125
+ if "decision_quality" in advanced_analytics and advanced_analytics["decision_quality"].get("status") == "success":
126
+ decision_summary = DecisionQualitySummary(**advanced_analytics["decision_quality"]["summary"])
127
+
128
+ lineup_summary = None
129
+ if "lineup_impact" in advanced_analytics and advanced_analytics["lineup_impact"].get("status") == "success":
130
+ lineup_summary = LineupImpactSummary(**advanced_analytics["lineup_impact"]["summary"])
131
+
132
+ fatigue_summary = None
133
+ if "fatigue" in advanced_analytics and advanced_analytics["fatigue"].get("status") == "success":
134
+ fatigue_summary = FatigueSummary(**advanced_analytics["fatigue"]["summary"])
135
+
136
+ clip_summary = None
137
+ if "clips" in advanced_analytics and advanced_analytics["clips"].get("status") == "success":
138
+ clip_summary = ClipSummary(**advanced_analytics["clips"]["summary"])
139
+
140
+ return TeamAdvancedSummary(
141
+ video_id=UUID(video_id),
142
+ spacing_summary=spacing_summary,
143
+ defensive_summary=defensive_summary,
144
+ transition_summary=transition_summary,
145
+ decision_summary=decision_summary,
146
+ lineup_summary=lineup_summary,
147
+ fatigue_summary=fatigue_summary,
148
+ clip_summary=clip_summary,
149
+ modules_executed=advanced_analytics.get("modules_executed", []),
150
+ modules_failed=advanced_analytics.get("modules_failed", [])
151
+ )
152
+
153
+
154
+ @router.get("/player/{video_id}/{player_track_id}", response_model=PlayerAdvancedAnalysis)
155
+ async def get_player_advanced_analysis(
156
+ video_id: str,
157
+ player_track_id: int,
158
+ current_user: dict = Depends(get_current_user),
159
+ supabase: SupabaseService = Depends(get_supabase),
160
+ ):
161
+ """
162
+ Get advanced analytics for a specific player in a game.
163
+
164
+ Returns player-specific metrics from all applicable modules.
165
+ """
166
+ # Verify video access
167
+ video = await supabase.select_one("videos", video_id)
168
+ if not video:
169
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Video not found")
170
+
171
+ if video["uploader_id"] != current_user["id"]:
172
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
173
+
174
+ # Get analysis results
175
+ analysis_results = await supabase.select(
176
+ "analysis_results",
177
+ filters={"video_id": video_id},
178
+ order_by="created_at",
179
+ ascending=False,
180
+ limit=1
181
+ )
182
+
183
+ if not analysis_results:
184
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No analysis results found")
185
+
186
+ analysis = analysis_results[0]
187
+ analysis = _hydrate_analysis_result(analysis)
188
+ advanced_analytics = analysis.get("advanced_analytics")
189
+
190
+ if not advanced_analytics:
191
+ return PlayerAdvancedAnalysis(
192
+ player_track_id=player_track_id,
193
+ video_id=UUID(video_id),
194
+ spacing_involvement=0,
195
+ defensive_reactions=[],
196
+ transition_efforts=[],
197
+ decision_analyses=[],
198
+ fatigue_indices=[],
199
+ avg_effort_score=0.0,
200
+ avg_reaction_delay_ms=None,
201
+ fatigue_level="low"
202
+ )
203
+
204
+ # Filter data for this specific player
205
+ player_defensive_reactions = []
206
+ if "defensive_reactions" in advanced_analytics:
207
+ all_reactions = advanced_analytics["defensive_reactions"].get("defensive_reactions", [])
208
+ player_defensive_reactions = [
209
+ DefensiveReaction(**r) for r in all_reactions
210
+ if r.get("defender_track_id") == player_track_id
211
+ ]
212
+
213
+ player_transition_efforts = []
214
+ if "transition_effort" in advanced_analytics:
215
+ all_efforts = advanced_analytics["transition_effort"].get("transition_efforts", [])
216
+ player_transition_efforts = [
217
+ TransitionEffort(**e) for e in all_efforts
218
+ if e.get("player_track_id") == player_track_id
219
+ ]
220
+
221
+ player_decision_analyses = []
222
+ if "decision_quality" in advanced_analytics:
223
+ all_decisions = advanced_analytics["decision_quality"].get("decision_analyses", [])
224
+ player_decision_analyses = [
225
+ DecisionAnalysis(**d) for d in all_decisions
226
+ if d.get("shooter_track_id") == player_track_id
227
+ ]
228
+
229
+ player_fatigue_indices = []
230
+ if "fatigue" in advanced_analytics:
231
+ all_fatigue = advanced_analytics["fatigue"].get("fatigue_indices", [])
232
+ player_fatigue_indices = [
233
+ FatigueIndex(**f) for f in all_fatigue
234
+ if f.get("player_track_id") == player_track_id
235
+ ]
236
+
237
+ # Calculate player-specific aggregates
238
+ spacing_involvement = 0
239
+ if "spacing" in advanced_analytics:
240
+ all_spacing = advanced_analytics["spacing"].get("spacing_metrics", [])
241
+ for metric in all_spacing:
242
+ player_positions = metric.get("player_positions", {})
243
+ if str(player_track_id) in player_positions:
244
+ spacing_involvement += 1
245
+
246
+ avg_effort_score = 0.0
247
+ if player_transition_efforts:
248
+ avg_effort_score = sum(e.effort_score for e in player_transition_efforts) / len(player_transition_efforts)
249
+
250
+ avg_reaction_delay_ms = None
251
+ if player_defensive_reactions:
252
+ valid_delays = [r.reaction_delay_ms for r in player_defensive_reactions if r.reaction_delay_ms is not None]
253
+ if valid_delays:
254
+ avg_reaction_delay_ms = sum(valid_delays) / len(valid_delays)
255
+
256
+ fatigue_level = "low"
257
+ if player_fatigue_indices:
258
+ latest_fatigue = player_fatigue_indices[-1]
259
+ fatigue_level = latest_fatigue.fatigue_level
260
+
261
+ return PlayerAdvancedAnalysis(
262
+ player_track_id=player_track_id,
263
+ video_id=UUID(video_id),
264
+ spacing_involvement=spacing_involvement,
265
+ defensive_reactions=player_defensive_reactions,
266
+ transition_efforts=player_transition_efforts,
267
+ decision_analyses=player_decision_analyses,
268
+ fatigue_indices=player_fatigue_indices,
269
+ avg_effort_score=avg_effort_score,
270
+ avg_reaction_delay_ms=avg_reaction_delay_ms,
271
+ fatigue_level=fatigue_level
272
+ )
273
+
274
+
275
+ @router.get("/lineups/{video_id}", response_model=LineupComparison)
276
+ async def get_lineup_comparison(
277
+ video_id: str,
278
+ current_user: dict = Depends(get_current_user),
279
+ supabase: SupabaseService = Depends(get_supabase),
280
+ ):
281
+ """
282
+ Get lineup performance comparison for a game.
283
+
284
+ Returns metrics for all 5-player combinations from both teams.
285
+ """
286
+ # Verify video access
287
+ video = await supabase.select_one("videos", video_id)
288
+ if not video:
289
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Video not found")
290
+
291
+ if video["uploader_id"] != current_user["id"]:
292
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
293
+
294
+ # Get analysis results
295
+ analysis_results = await supabase.select(
296
+ "analysis_results",
297
+ filters={"video_id": video_id},
298
+ order_by="created_at",
299
+ ascending=False,
300
+ limit=1
301
+ )
302
+
303
+ if not analysis_results:
304
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No analysis results found")
305
+
306
+ analysis = analysis_results[0]
307
+ analysis = _hydrate_analysis_result(analysis)
308
+ advanced_analytics = analysis.get("advanced_analytics")
309
+
310
+ if not advanced_analytics or "lineup_impact" not in advanced_analytics:
311
+ return LineupComparison(
312
+ video_id=UUID(video_id),
313
+ team_1_lineups=[],
314
+ team_2_lineups=[],
315
+ best_overall_lineup=None,
316
+ worst_overall_lineup=None
317
+ )
318
+
319
+ lineup_data = advanced_analytics["lineup_impact"]
320
+ all_lineups = lineup_data.get("lineup_metrics", [])
321
+
322
+ team_1_lineups = [LineupMetric(**l) for l in all_lineups if l.get("team_id") == 1]
323
+ team_2_lineups = [LineupMetric(**l) for l in all_lineups if l.get("team_id") == 2]
324
+
325
+ if not all_lineups:
326
+ raise HTTPException(
327
+ status_code=status.HTTP_404_NOT_FOUND,
328
+ detail="No lineup data available"
329
+ )
330
+
331
+ # Find best and worst lineups
332
+ best_lineup = max(all_lineups, key=lambda x: x.get("net_rating", 0))
333
+ worst_lineup = min(all_lineups, key=lambda x: x.get("net_rating", 0))
334
+
335
+ return LineupComparison(
336
+ video_id=UUID(video_id),
337
+ team_1_lineups=team_1_lineups,
338
+ team_2_lineups=team_2_lineups,
339
+ best_overall_lineup=LineupMetric(**best_lineup),
340
+ worst_overall_lineup=LineupMetric(**worst_lineup)
341
+ )
342
+
343
+
344
+ @router.get("/clips/{video_id}", response_model=ClipCatalog)
345
+ async def get_coaching_clips(
346
+ video_id: str,
347
+ clip_type: str = None,
348
+ current_user: dict = Depends(get_current_user),
349
+ supabase: SupabaseService = Depends(get_supabase),
350
+ ):
351
+ """
352
+ Get automatically generated coaching clips for a game.
353
+
354
+ Optionally filter by clip type (poor_spacing, late_rotation, etc.).
355
+ """
356
+ # Verify video access
357
+ video = await supabase.select_one("videos", video_id)
358
+ if not video:
359
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Video not found")
360
+
361
+ if video["uploader_id"] != current_user["id"]:
362
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
363
+
364
+ # Get analysis results
365
+ analysis_results = await supabase.select(
366
+ "analysis_results",
367
+ filters={"video_id": video_id},
368
+ order_by="created_at",
369
+ ascending=False,
370
+ limit=1
371
+ )
372
+
373
+ if not analysis_results:
374
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No analysis results found")
375
+
376
+ analysis = analysis_results[0]
377
+ analysis = _hydrate_analysis_result(analysis)
378
+ advanced_analytics = analysis.get("advanced_analytics")
379
+
380
+ if not advanced_analytics or "clips" not in advanced_analytics:
381
+ return ClipCatalog(
382
+ video_id=UUID(video_id),
383
+ clips=[],
384
+ summary=ClipSummary(
385
+ total_clips_generated=0,
386
+ clips_by_type={},
387
+ output_directory=""
388
+ )
389
+ )
390
+
391
+ clip_data = advanced_analytics["clips"]
392
+ all_clips = clip_data.get("auto_clips", [])
393
+
394
+ # Filter by clip type if specified
395
+ if clip_type:
396
+ all_clips = [c for c in all_clips if c.get("clip_type") == clip_type]
397
+
398
+ clips = [AutoClip(**c) for c in all_clips]
399
+ summary = ClipSummary(**clip_data.get("summary", {}))
400
+
401
+ return ClipCatalog(
402
+ video_id=UUID(video_id),
403
+ clips=clips,
404
+ summary=summary
405
+ )
app/api/analysis.py ADDED
@@ -0,0 +1,689 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Analysis API endpoints for triggering and retrieving video analysis.
3
+ """
4
+ import asyncio
5
+ from uuid import uuid4
6
+ from datetime import datetime
7
+ from typing import Optional, Union
8
+ from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status
9
+ import anyio
10
+
11
+ from app.dependencies import (
12
+ get_current_user,
13
+ require_team_account,
14
+ require_personal_account,
15
+ get_supabase,
16
+ )
17
+ from app.models.video import VideoStatus, AnalysisMode
18
+ from app.models.analysis import (
19
+ AnalysisRequest,
20
+ AnalysisResult,
21
+ PersonalAnalysisResult,
22
+ Detection,
23
+ )
24
+ from app.services.supabase_client import SupabaseService
25
+
26
+
27
+ router = APIRouter()
28
+
29
+
30
+ def _run_dispatch_in_thread(video_path: str, mode: AnalysisMode, video_id: str, options: dict | None):
31
+ """Run async dispatch in a dedicated thread event loop."""
32
+ try:
33
+ from analysis.dispatcher import dispatch_analysis
34
+ return asyncio.run(dispatch_analysis(video_path, mode, options=options, video_id=video_id))
35
+ except ImportError:
36
+ print("⚠️ Analysis dispatcher not available (heavy dependencies missing)")
37
+ return {"status": "skipped", "reason": "heavy dependencies missing"}
38
+
39
+
40
+ async def run_analysis_background(video_id: str, mode: str, supabase: SupabaseService, options: Optional[dict] = None):
41
+ """
42
+ Background task for running video analysis.
43
+ Wraps the template analysis pipeline for API use.
44
+ """
45
+ try:
46
+ # Update status to processing
47
+ await supabase.update("videos", video_id, {
48
+ "status": VideoStatus.PROCESSING.value,
49
+ "current_step": "Initializing analysis",
50
+ "progress_percent": 0,
51
+ })
52
+
53
+ # Get video info
54
+ video = await supabase.select_one("videos", video_id)
55
+ if not video:
56
+ return
57
+
58
+ # Run analysis based on mode (offload CPU/GPU-heavy work to a thread)
59
+ result = await anyio.to_thread.run_sync(
60
+ _run_dispatch_in_thread,
61
+ video["storage_path"],
62
+ AnalysisMode(mode),
63
+ video_id,
64
+ options or {},
65
+ )
66
+
67
+ if result.get("status") == "failed":
68
+ raise Exception(result.get("error", "Analysis failed in dispatcher"))
69
+
70
+ if result.get("total_frames", 0) == 0:
71
+ raise Exception("Analysis returned 0 frames. The process may have been killed due to lack of memory or encountered a fatal error.")
72
+
73
+ # Pull out large/extra payloads that should not be inserted into analysis_results
74
+ detections = result.pop("detections", None) or []
75
+ # This field is useful for UI but isn't part of the DB schema
76
+ result.pop("primary_player_frames", None)
77
+
78
+ # For PERSONAL mode, attach the user's player_id if available
79
+ player_id_for_analytics = None
80
+ if mode == AnalysisMode.PERSONAL.value:
81
+ players = await supabase.select("players", filters={"user_id": video["uploader_id"]})
82
+ if players:
83
+ player_id_for_analytics = players[0].get("id")
84
+
85
+ # Store results (only columns that exist in analysis_results table)
86
+ allowed_fields = {
87
+ "total_frames",
88
+ "duration_seconds",
89
+ "players_detected",
90
+ "team_1_possession_percent",
91
+ "team_2_possession_percent",
92
+ "total_passes",
93
+ "team_1_passes",
94
+ "team_2_passes",
95
+ "total_interceptions",
96
+ "team_1_interceptions",
97
+ "team_2_interceptions",
98
+ "shot_attempts",
99
+ "shots_made",
100
+ "shots_missed",
101
+ "shooting_percentage",
102
+ "team_1_shot_attempts",
103
+ "team_1_shots_made",
104
+ "team_2_shot_attempts",
105
+ "team_2_shots_made",
106
+ "overall_shooting_percentage",
107
+ "defensive_actions",
108
+ "shot_form_consistency",
109
+ "dribble_count",
110
+ "dribble_frequency_per_minute",
111
+ "total_distance_meters",
112
+ "avg_speed_kmh",
113
+ "max_speed_kmh",
114
+ "fps",
115
+ "acceleration_events",
116
+ "avg_knee_bend_angle",
117
+ "avg_elbow_angle_shooting",
118
+ "training_load_score",
119
+ "events",
120
+ "processing_time_seconds",
121
+ }
122
+
123
+ def convert_numpy(obj):
124
+ import numpy as np
125
+ if isinstance(obj, (np.int64, np.int32, np.int16, np.int8)):
126
+ return int(obj)
127
+ if isinstance(obj, (np.float64, np.float32, np.float16)):
128
+ return float(obj)
129
+ if isinstance(obj, np.ndarray):
130
+ return obj.tolist()
131
+ if isinstance(obj, dict):
132
+ return {k: convert_numpy(v) for k, v in obj.items()}
133
+ if isinstance(obj, list):
134
+ return [convert_numpy(v) for v in obj]
135
+ return obj
136
+
137
+ result = convert_numpy(result)
138
+ detections = convert_numpy(detections)
139
+
140
+ analysis_id = str(uuid4())
141
+ analysis_payload = {k: v for k, v in result.items() if k in allowed_fields}
142
+
143
+ # Ensure required fields have values (not null)
144
+ required_fields = {
145
+ "total_frames": 0,
146
+ "duration_seconds": 0.0,
147
+ "players_detected": 0,
148
+ "team_1_possession_percent": 50.0,
149
+ "team_2_possession_percent": 50.0,
150
+ "total_passes": 0,
151
+ "total_interceptions": 0,
152
+ "shot_attempts": 0,
153
+ "processing_time_seconds": 0.0,
154
+ }
155
+
156
+ for field, default_val in required_fields.items():
157
+ if field not in analysis_payload or analysis_payload[field] is None:
158
+ analysis_payload[field] = default_val
159
+
160
+ # Capture extra metrics that might be missing from schema in the events JSONB
161
+ # This keeps the backend functional even if the DB hasn't been migrated yet.
162
+ extra_metrics = {
163
+ "defensive_actions": result.get("defensive_actions", 0),
164
+ "overall_shooting_percentage": result.get("overall_shooting_percentage", 0.0),
165
+ "total_distance_meters": result.get("total_distance_meters", 0.0),
166
+ "avg_speed_kmh": result.get("avg_speed_kmh", 0.0),
167
+ "max_speed_kmh": result.get("max_speed_kmh", 0.0),
168
+ "advanced_analytics": result.get("advanced_analytics"),
169
+ # Jersey colors — stored so the frontend can colour player markers by actual kit
170
+ "team_1_jersey": (options or {}).get("our_team_jersey", ""),
171
+ "team_2_jersey": (options or {}).get("opponent_jersey", ""),
172
+ }
173
+
174
+ current_events = result.get("events", [])
175
+ if isinstance(current_events, list):
176
+ current_events.append({
177
+ "event_type": "summary_stats",
178
+ "frame": 0,
179
+ "timestamp_seconds": 0.0,
180
+ "details": extra_metrics
181
+ })
182
+ analysis_payload["events"] = current_events
183
+
184
+ analysis_record = {
185
+ "id": analysis_id,
186
+ "video_id": video_id,
187
+ **analysis_payload,
188
+ "created_at": datetime.utcnow().isoformat(),
189
+ }
190
+
191
+ try:
192
+ await supabase.insert("analysis_results", analysis_record)
193
+ except Exception as e:
194
+ raise e
195
+
196
+ # Persist detections for overlay playback (always every frame for smoothness)
197
+ store_detections = True
198
+ detections_stride = 1
199
+ max_detections = 200_000
200
+ if options:
201
+ store_detections = bool(options.get("store_detections", True))
202
+ try:
203
+ detections_stride = int(options.get("detections_stride", detections_stride))
204
+ except Exception:
205
+ pass
206
+ try:
207
+ max_detections = int(options.get("max_detections", max_detections))
208
+ except Exception:
209
+ pass
210
+
211
+ if store_detections and detections:
212
+ await supabase.update("videos", video_id, {
213
+ "current_step": f"Saving {len(detections)} detection highlights",
214
+ "progress_percent": 99,
215
+ })
216
+
217
+ max_detections = max(1_000, max_detections)
218
+
219
+ rows = []
220
+ for d in detections:
221
+ bbox = d.get("bbox")
222
+ if not bbox or len(bbox) != 4:
223
+ continue
224
+ obj_type = d.get("object_type")
225
+ # Map non-DB types to player/ball but keep real type in keypoints JSON
226
+ db_obj_type = "player"
227
+ if obj_type in ("ball", "basketball"):
228
+ db_obj_type = "ball"
229
+
230
+ # Store the original type and tactical coordinates in keypoints for the frontend
231
+ keypoints = d.get("keypoints") or {}
232
+ if not isinstance(keypoints, dict):
233
+ keypoints = {"data": keypoints}
234
+ keypoints["real_type"] = obj_type
235
+
236
+ # Store tactical coordinates if available
237
+ if "tactical_x" in d:
238
+ keypoints["tactical_x"] = d["tactical_x"]
239
+ keypoints["tactical_y"] = d["tactical_y"]
240
+
241
+ rows.append({
242
+ "video_id": video_id,
243
+ "frame": int(d.get("frame", 0)),
244
+ "object_type": db_obj_type,
245
+ "track_id": int(d.get("track_id", 0)) if isinstance(d.get("track_id"), (int, float)) else int(str(d.get("track_id", 0)).split('-')[0] if '-' in str(d.get("track_id", "")) else 0),
246
+ "bbox": bbox,
247
+ "confidence": float(d.get("confidence", 1.0)),
248
+ "keypoints": keypoints,
249
+ "team_id": d.get("team_id"),
250
+ "has_ball": bool(d.get("has_ball", False)),
251
+ "tactical_x": d.get("tactical_x"),
252
+ "tactical_y": d.get("tactical_y"),
253
+ })
254
+ if len(rows) >= max_detections:
255
+ break
256
+
257
+ # Replace old detections for this video (best-effort)
258
+ try:
259
+ await supabase.delete_where("detections", {"video_id": video_id})
260
+ except Exception:
261
+ pass
262
+
263
+ await supabase.insert_many("detections", rows, chunk_size=500)
264
+
265
+ # Persist PERSONAL analytics time-series (best-effort)
266
+ if mode == AnalysisMode.PERSONAL.value and player_id_for_analytics:
267
+ player_id = player_id_for_analytics
268
+ analytics_rows = []
269
+ if analysis_payload.get("total_distance_meters") is not None:
270
+ analytics_rows.append({
271
+ "player_id": player_id,
272
+ "video_id": video_id,
273
+ "metric_type": "distance_km",
274
+ "value": float(analysis_payload["total_distance_meters"]) / 1000.0,
275
+ })
276
+ if analysis_payload.get("avg_speed_kmh") is not None:
277
+ analytics_rows.append({
278
+ "player_id": player_id,
279
+ "video_id": video_id,
280
+ "metric_type": "avg_speed_kmh",
281
+ "value": float(analysis_payload["avg_speed_kmh"]),
282
+ })
283
+ if analysis_payload.get("max_speed_kmh") is not None:
284
+ analytics_rows.append({
285
+ "player_id": player_id,
286
+ "video_id": video_id,
287
+ "metric_type": "max_speed_kmh",
288
+ "value": float(analysis_payload["max_speed_kmh"]),
289
+ })
290
+ if analysis_payload.get("dribble_count") is not None:
291
+ analytics_rows.append({
292
+ "player_id": player_id,
293
+ "video_id": video_id,
294
+ "metric_type": "dribble_count",
295
+ "value": float(analysis_payload["dribble_count"]),
296
+ })
297
+ if analysis_payload.get("shot_attempts") is not None:
298
+ analytics_rows.append({
299
+ "player_id": player_id,
300
+ "video_id": video_id,
301
+ "metric_type": "shot_attempt",
302
+ "value": float(analysis_payload["shot_attempts"]),
303
+ })
304
+ if analysis_payload.get("shot_form_consistency") is not None:
305
+ analytics_rows.append({
306
+ "player_id": player_id,
307
+ "video_id": video_id,
308
+ "metric_type": "form_consistency",
309
+ "value": float(analysis_payload["shot_form_consistency"]),
310
+ })
311
+
312
+ if analytics_rows:
313
+ try:
314
+ await supabase.insert_many("analytics", analytics_rows, chunk_size=500)
315
+ except Exception:
316
+ pass
317
+
318
+ # ── Upload annotated video to Supabase Storage ────────────────────────
319
+ import os as _os
320
+ _ANNOTATED_BUCKET = "team-analysis-videos"
321
+ _annotated_local = _os.path.join("output_videos", "annotated", f"{video_id}.mp4")
322
+
323
+ if _os.path.exists(_annotated_local):
324
+ try:
325
+ _storage_path = f"{video.get('uploader_id', 'unknown')}/{video_id}_annotated.mp4"
326
+ await supabase.upload_file_from_path(
327
+ bucket=_ANNOTATED_BUCKET,
328
+ storage_path=_storage_path,
329
+ local_path=_annotated_local,
330
+ content_type="video/mp4",
331
+ )
332
+ _signed_url = await supabase.get_long_lived_url(
333
+ bucket=_ANNOTATED_BUCKET,
334
+ storage_path=_storage_path,
335
+ expires_in=60 * 60 * 24 * 7, # 7 days
336
+ )
337
+ if _signed_url:
338
+ # Store signed URL in the videos record for direct playback
339
+ await supabase.update("videos", video_id, {"annotated_url": _signed_url})
340
+ # Clean up local annotated file
341
+ try:
342
+ _os.remove(_annotated_local)
343
+ except Exception:
344
+ pass
345
+ except Exception as _upload_err:
346
+ # Non-fatal — fallback to local FileResponse endpoint
347
+ print(f"[{video_id}] Supabase annotated upload failed (using local fallback): {_upload_err}")
348
+
349
+ # Update video status to COMPLETED
350
+ await supabase.update("videos", video_id, {
351
+ "status": VideoStatus.COMPLETED.value,
352
+ "progress_percent": 100,
353
+ "current_step": "Complete",
354
+ "completed_at": datetime.utcnow().isoformat(),
355
+ "has_annotated": True,
356
+ })
357
+
358
+ except Exception as e:
359
+ # Update status on failure
360
+ await supabase.update("videos", video_id, {
361
+ "status": VideoStatus.FAILED.value,
362
+ "error_message": str(e),
363
+ })
364
+
365
+
366
+ @router.post("/team", response_model=dict, status_code=status.HTTP_202_ACCEPTED)
367
+ async def trigger_team_analysis(
368
+ request: AnalysisRequest,
369
+ background_tasks: BackgroundTasks,
370
+ current_user: dict = Depends(require_team_account),
371
+ supabase: SupabaseService = Depends(get_supabase),
372
+ ):
373
+ """
374
+ Trigger team analysis on an uploaded video.
375
+
376
+ **Requires TEAM account.**
377
+
378
+ This is an async operation. Use GET /api/videos/{id}/status to check progress.
379
+ """
380
+ # Verify video exists and belongs to user
381
+ video = await supabase.select_one("videos", str(request.video_id))
382
+
383
+ if not video:
384
+ raise HTTPException(
385
+ status_code=status.HTTP_404_NOT_FOUND,
386
+ detail="Video not found"
387
+ )
388
+
389
+ if video["uploader_id"] != current_user["id"]:
390
+ raise HTTPException(
391
+ status_code=status.HTTP_403_FORBIDDEN,
392
+ detail="You don't have access to this video"
393
+ )
394
+
395
+ if video["status"] == VideoStatus.PROCESSING.value:
396
+ raise HTTPException(
397
+ status_code=status.HTTP_409_CONFLICT,
398
+ detail="Video is already being processed"
399
+ )
400
+
401
+ # Build comprehensive options dict from request
402
+ options = request.options or {}
403
+
404
+ # Add all detection and display parameters to options
405
+ if request.our_team_jersey:
406
+ options["our_team_jersey"] = request.our_team_jersey
407
+ if request.opponent_jersey:
408
+ options["opponent_jersey"] = request.opponent_jersey
409
+ if request.our_team_id:
410
+ options["our_team_id"] = request.our_team_id
411
+
412
+ # Detection parameters
413
+ options["player_confidence"] = request.player_confidence or 0.3
414
+ options["ball_confidence"] = request.ball_confidence or 0.15
415
+ options["detection_batch_size"] = request.detection_batch_size or 10
416
+ options["image_size"] = request.image_size or 1080
417
+ options["max_players_on_court"] = request.max_players_on_court or 12
418
+
419
+ # Analysis options
420
+ options["use_cached_detections"] = request.use_cached_detections or False
421
+ options["clear_cache_after"] = request.clear_cache_after if request.clear_cache_after is not None else True
422
+ options["save_annotated_video"] = request.save_annotated_video if request.save_annotated_video is not None else True
423
+
424
+ # Display options
425
+ options["render_speed_text"] = request.render_speed_text if request.render_speed_text is not None else True
426
+ options["render_distance_text"] = request.render_distance_text if request.render_distance_text is not None else True
427
+ options["render_tactical_view"] = request.render_tactical_view if request.render_tactical_view is not None else True
428
+ options["render_court_keypoints"] = request.render_court_keypoints if request.render_court_keypoints is not None else True
429
+
430
+ # Queue analysis
431
+ background_tasks.add_task(
432
+ run_analysis_background,
433
+ str(request.video_id),
434
+ AnalysisMode.TEAM.value,
435
+ supabase,
436
+ options,
437
+ )
438
+
439
+ return {
440
+ "message": "Analysis queued",
441
+ "video_id": str(request.video_id),
442
+ "mode": "team",
443
+ }
444
+
445
+
446
+ @router.post("/personal", response_model=dict, status_code=status.HTTP_202_ACCEPTED)
447
+ async def trigger_personal_analysis(
448
+ request: AnalysisRequest,
449
+ background_tasks: BackgroundTasks,
450
+ current_user: dict = Depends(require_personal_account),
451
+ supabase: SupabaseService = Depends(get_supabase),
452
+ ):
453
+ """
454
+ Trigger personal analysis on an uploaded video.
455
+
456
+ **Requires PERSONAL account.**
457
+
458
+ This is an async operation. Use GET /api/videos/{id}/status to check progress.
459
+ """
460
+ video = await supabase.select_one("videos", str(request.video_id))
461
+
462
+ if not video:
463
+ raise HTTPException(
464
+ status_code=status.HTTP_404_NOT_FOUND,
465
+ detail="Video not found"
466
+ )
467
+
468
+ if video["uploader_id"] != current_user["id"]:
469
+ raise HTTPException(
470
+ status_code=status.HTTP_403_FORBIDDEN,
471
+ detail="You don't have access to this video"
472
+ )
473
+
474
+ if video["status"] == VideoStatus.PROCESSING.value:
475
+ raise HTTPException(
476
+ status_code=status.HTTP_409_CONFLICT,
477
+ detail="Video is already being processed"
478
+ )
479
+
480
+ background_tasks.add_task(
481
+ run_analysis_background,
482
+ str(request.video_id),
483
+ AnalysisMode.PERSONAL.value,
484
+ supabase,
485
+ request.options or {},
486
+ )
487
+
488
+ return {
489
+ "message": "Analysis queued",
490
+ "video_id": str(request.video_id),
491
+ "mode": "personal",
492
+ }
493
+
494
+
495
+ def _hydrate_analysis_result(result: dict) -> dict:
496
+ """Extract summary stats from events JSONB if they exist and merge into top-level."""
497
+ if not result or "events" not in result:
498
+ return result
499
+
500
+ events = result.get("events", [])
501
+ if not isinstance(events, list):
502
+ return result
503
+
504
+ for event in events:
505
+ if isinstance(event, dict) and event.get("event_type") == "summary_stats":
506
+ details = event.get("details", {})
507
+ if isinstance(details, dict):
508
+ # Only fill if the main result field is missing or None
509
+ for key, value in details.items():
510
+ # Only fill if the main result field is missing or None
511
+ if key not in result or result[key] is None or key == "advanced_analytics":
512
+ result[key] = value
513
+ break
514
+ return result
515
+
516
+
517
+ @router.get("/by-video/{video_id}", response_model=Union[AnalysisResult, PersonalAnalysisResult])
518
+ async def get_latest_analysis_for_video(
519
+ video_id: str,
520
+ current_user: dict = Depends(get_current_user),
521
+ supabase: SupabaseService = Depends(get_supabase),
522
+ ):
523
+ """
524
+ Get the latest analysis result for a given video_id.
525
+ Useful for frontend: poll video status, then fetch latest analysis.
526
+ """
527
+ video = await supabase.select_one("videos", video_id)
528
+ if not video:
529
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Video not found")
530
+ has_access = False
531
+ if video.get("uploader_id") == current_user["id"]:
532
+ has_access = True
533
+ elif video.get("analysis_mode") == AnalysisMode.TEAM.value:
534
+ org_id = current_user.get("organization_id")
535
+ if not org_id and current_user.get("account_type") == "team":
536
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
537
+ if orgs: org_id = orgs[0]["id"]
538
+ elif not org_id:
539
+ # Maybe they are a player linked to a team?
540
+ players = await supabase.select("players", filters={"user_id": current_user["id"]})
541
+ for p in players:
542
+ if p.get("organization_id"):
543
+ org_id = p["organization_id"]
544
+ break
545
+
546
+ if org_id and str(video.get("organization_id")) == str(org_id):
547
+ has_access = True
548
+
549
+ if not has_access:
550
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this video")
551
+
552
+ if video.get("analysis_mode") == AnalysisMode.PERSONAL.value:
553
+ results = await supabase.select(
554
+ "personal_analyses",
555
+ filters={"job_id": video_id},
556
+ order_by="created_at",
557
+ ascending=False,
558
+ limit=1,
559
+ )
560
+ if not results:
561
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No analysis found for this video")
562
+ result = results[0]
563
+ # Map fields so it can be returned via PersonalAnalysisResult if needed
564
+ # Or return as is if the schema matches
565
+ result_json = result.get("results_json", {})
566
+ if isinstance(result_json, str):
567
+ import json
568
+ try: result_json = json.loads(result_json)
569
+ except: result_json = {}
570
+
571
+ return PersonalAnalysisResult(**result)
572
+ else:
573
+ results = await supabase.select(
574
+ "analysis_results",
575
+ filters={"video_id": video_id},
576
+ order_by="created_at",
577
+ ascending=False,
578
+ limit=1,
579
+ )
580
+ if not results:
581
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No analysis found for this video")
582
+
583
+ result = results[0]
584
+ result = _hydrate_analysis_result(result)
585
+ return AnalysisResult(**result)
586
+
587
+
588
+ @router.get("/{analysis_id}", response_model=Union[AnalysisResult, PersonalAnalysisResult])
589
+ async def get_analysis_result(
590
+ analysis_id: str,
591
+ current_user: dict = Depends(get_current_user),
592
+ supabase: SupabaseService = Depends(get_supabase),
593
+ ):
594
+ """
595
+ Get analysis results by ID.
596
+ """
597
+ result = await supabase.select_one("analysis_results", analysis_id)
598
+
599
+ if not result:
600
+ raise HTTPException(
601
+ status_code=status.HTTP_404_NOT_FOUND,
602
+ detail="Analysis result not found"
603
+ )
604
+
605
+ # Verify ownership via video
606
+ video = await supabase.select_one("videos", result["video_id"])
607
+ if not video:
608
+ raise HTTPException(
609
+ status_code=status.HTTP_404_NOT_FOUND,
610
+ detail="Video not found"
611
+ )
612
+
613
+ has_access = False
614
+ if video.get("uploader_id") == current_user["id"]:
615
+ has_access = True
616
+ elif video.get("analysis_mode") == AnalysisMode.TEAM.value:
617
+ org_id = current_user.get("organization_id")
618
+ if not org_id and current_user.get("account_type") == "team":
619
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
620
+ if orgs: org_id = orgs[0]["id"]
621
+ elif not org_id:
622
+ players = await supabase.select("players", filters={"user_id": current_user["id"]})
623
+ for p in players:
624
+ if p.get("organization_id"):
625
+ org_id = p["organization_id"]
626
+ break
627
+
628
+ if org_id and str(video.get("organization_id")) == str(org_id):
629
+ has_access = True
630
+
631
+ if not has_access:
632
+ raise HTTPException(
633
+ status_code=status.HTTP_403_FORBIDDEN,
634
+ detail="You don't have access to this analysis"
635
+ )
636
+
637
+ # Hydrate with extra metrics if stored in events
638
+ result = _hydrate_analysis_result(result)
639
+
640
+ # Return model based on video's analysis_mode
641
+ if video.get("analysis_mode") == AnalysisMode.PERSONAL.value:
642
+ return PersonalAnalysisResult(**result)
643
+
644
+ return AnalysisResult(**result)
645
+
646
+
647
+ @router.get("/{analysis_id}/detections")
648
+ async def get_analysis_detections(
649
+ analysis_id: str,
650
+ frame_start: Optional[int] = None,
651
+ frame_end: Optional[int] = None,
652
+ current_user: dict = Depends(get_current_user),
653
+ supabase: SupabaseService = Depends(get_supabase),
654
+ ):
655
+ """
656
+ Get frame-by-frame detections for an analysis.
657
+
658
+ Optionally filter by frame range.
659
+ """
660
+ # Verify access
661
+ result = await supabase.select_one("analysis_results", analysis_id)
662
+ if not result:
663
+ raise HTTPException(
664
+ status_code=status.HTTP_404_NOT_FOUND,
665
+ detail="Analysis result not found"
666
+ )
667
+
668
+ video = await supabase.select_one("videos", result["video_id"])
669
+ if not video or video["uploader_id"] != current_user["id"]:
670
+ raise HTTPException(
671
+ status_code=status.HTTP_403_FORBIDDEN,
672
+ detail="You don't have access to this analysis"
673
+ )
674
+
675
+ # Get detections
676
+ filters = {"video_id": result["video_id"]}
677
+ detections = await supabase.select("detections", filters=filters, limit=100000)
678
+
679
+ # Filter by frame range if specified
680
+ if frame_start is not None:
681
+ detections = [d for d in detections if d.get("frame", 0) >= frame_start]
682
+ if frame_end is not None:
683
+ detections = [d for d in detections if d.get("frame", 0) <= frame_end]
684
+
685
+ return {
686
+ "analysis_id": analysis_id,
687
+ "total_detections": len(detections),
688
+ "detections": detections,
689
+ }
app/api/analytics.py ADDED
@@ -0,0 +1,402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Analytics and metrics API endpoints.
3
+ """
4
+ from datetime import date, datetime, timedelta
5
+ from typing import Optional
6
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
7
+
8
+ from app.dependencies import (
9
+ get_current_user,
10
+ require_team_account,
11
+ require_personal_account,
12
+ get_supabase,
13
+ )
14
+ from app.models.user import AccountType
15
+ from app.models.analytics import (
16
+ PlayerAnalyticsSummary,
17
+ TeamAnalyticsSummary,
18
+ SkillSummary,
19
+ ProgressReport,
20
+ ProgressData,
21
+ )
22
+ from app.services.supabase_client import SupabaseService
23
+
24
+
25
+ router = APIRouter()
26
+
27
+ def _parse_ts(ts: Optional[str]) -> Optional[datetime]:
28
+ if not ts:
29
+ return None
30
+ try:
31
+ # Accept both "Z" and "+00:00" ISO formats
32
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
33
+ except Exception:
34
+ return None
35
+
36
+
37
+ @router.get("/player/{player_id}", response_model=PlayerAnalyticsSummary)
38
+ async def get_player_analytics(
39
+ player_id: str,
40
+ period_days: int = Query(30, ge=1, le=365),
41
+ current_user: dict = Depends(get_current_user),
42
+ supabase: SupabaseService = Depends(get_supabase),
43
+ ):
44
+ """
45
+ Get aggregated analytics for a player over a time period.
46
+ """
47
+ # Verify player access
48
+ player = await supabase.select_one("players", player_id)
49
+
50
+ if not player:
51
+ raise HTTPException(
52
+ status_code=status.HTTP_404_NOT_FOUND,
53
+ detail="Player not found"
54
+ )
55
+
56
+ # Check access based on account type
57
+ has_access = False
58
+ if current_user.get("account_type") == AccountType.TEAM.value:
59
+ if player.get("organization_id"):
60
+ org = await supabase.select_one("organizations", player["organization_id"])
61
+ has_access = org and org["owner_id"] == current_user["id"]
62
+ elif current_user.get("account_type") == AccountType.COACH.value:
63
+ player_org = player.get("organization_id")
64
+ coach_org = current_user.get("organization_id")
65
+ has_access = player_org and coach_org and str(player_org) == str(coach_org)
66
+ else:
67
+ has_access = player.get("user_id") == current_user["id"]
68
+
69
+ if not has_access:
70
+ raise HTTPException(status_code=403, detail="Access denied")
71
+
72
+ # Calculate period
73
+ period_end = date.today()
74
+ period_start = period_end - timedelta(days=period_days)
75
+ period_start_dt = datetime.combine(period_start, datetime.min.time())
76
+ period_end_dt = datetime.combine(period_end, datetime.max.time())
77
+
78
+ # Get analytics data
79
+ analytics = await supabase.select("analytics", filters={"player_id": player_id})
80
+ # Filter by period when timestamps exist
81
+ analytics = [
82
+ a for a in analytics
83
+ if (ts := _parse_ts(a.get("timestamp"))) is None or (period_start_dt <= ts <= period_end_dt)
84
+ ]
85
+
86
+ # Aggregate metrics
87
+ def safe_float(v):
88
+ try:
89
+ return float(v) if v is not None else 0.0
90
+ except (ValueError, TypeError):
91
+ return 0.0
92
+
93
+ total_distance = sum(safe_float(a.get("value")) for a in analytics if a.get("metric_type") == "distance_km")
94
+
95
+ speed_values = [safe_float(a.get("value")) for a in analytics if a.get("metric_type") == "avg_speed_kmh" and a.get("value") is not None]
96
+ max_speed_values = [safe_float(a.get("value")) for a in analytics if a.get("metric_type") == "max_speed_kmh" and a.get("value") is not None]
97
+
98
+ shot_attempts = sum(
99
+ int(safe_float(a.get("value")))
100
+ for a in analytics
101
+ if a.get("metric_type") == "shot_attempt"
102
+ )
103
+
104
+ form_scores = [safe_float(a.get("value")) for a in analytics if a.get("metric_type") == "form_consistency" and a.get("value") is not None]
105
+
106
+ dribbles = sum(safe_float(a.get("value")) for a in analytics if a.get("metric_type") == "dribble_count")
107
+
108
+ # Get unique video count
109
+ video_ids = set(str(a.get("video_id")) for a in analytics if a.get("video_id"))
110
+
111
+ # Calculate training time from videos
112
+ training_minutes = 0.0
113
+ if video_ids:
114
+ try:
115
+ # Batch fetch video durations
116
+ vids = await supabase.select_in("videos", "id", list(video_ids), columns="id,duration_seconds")
117
+ for v in vids:
118
+ if v and v.get("duration_seconds") is not None:
119
+ training_minutes += safe_float(v["duration_seconds"]) / 60
120
+ except Exception as e:
121
+ print(f"Background: Error batch fetching video durations: {e}")
122
+ for vid in video_ids:
123
+ try:
124
+ video = await supabase.select_one("videos", vid)
125
+ if video and video.get("duration_seconds") is not None:
126
+ training_minutes += safe_float(video["duration_seconds"]) / 60
127
+ except Exception:
128
+ continue
129
+
130
+ return PlayerAnalyticsSummary(
131
+ player_id=player_id,
132
+ period_start=period_start,
133
+ period_end=period_end,
134
+ total_training_sessions=len(video_ids),
135
+ total_training_minutes=training_minutes,
136
+ total_videos_analyzed=len(video_ids),
137
+ total_distance_km=total_distance if total_distance > 0 else None,
138
+ avg_speed_kmh=sum(speed_values) / len(speed_values) if speed_values else None,
139
+ max_speed_kmh=max(max_speed_values) if max_speed_values else None,
140
+ total_shot_attempts=shot_attempts,
141
+ avg_shot_form_consistency=sum(form_scores) / len(form_scores) if form_scores else None,
142
+ total_dribbles=int(dribbles),
143
+ )
144
+
145
+
146
+ @router.get("/team/{org_id}", response_model=TeamAnalyticsSummary)
147
+ async def get_team_analytics(
148
+ org_id: str,
149
+ period_days: int = Query(30, ge=1, le=365),
150
+ current_user: dict = Depends(require_team_account),
151
+ supabase: SupabaseService = Depends(get_supabase),
152
+ ):
153
+ """
154
+ Get aggregated team analytics over a time period.
155
+
156
+ **Requires TEAM account.**
157
+ """
158
+ # Verify org access
159
+ org = await supabase.select_one("organizations", org_id)
160
+
161
+ if not org:
162
+ raise HTTPException(
163
+ status_code=status.HTTP_404_NOT_FOUND,
164
+ detail="Organization not found"
165
+ )
166
+
167
+ # Verify access
168
+ has_access = False
169
+ if current_user.get("account_type") == AccountType.TEAM.value:
170
+ has_access = org["owner_id"] == current_user["id"]
171
+ elif current_user.get("account_type") == AccountType.COACH.value:
172
+ coach_org_id = current_user.get("organization_id")
173
+ has_access = coach_org_id and str(coach_org_id) == str(org_id)
174
+
175
+ if not has_access:
176
+ raise HTTPException(
177
+ status_code=status.HTTP_403_FORBIDDEN,
178
+ detail="Access denied"
179
+ )
180
+
181
+ period_end = date.today()
182
+ period_start = period_end - timedelta(days=period_days)
183
+ period_start_dt = datetime.combine(period_start, datetime.min.time())
184
+ period_end_dt = datetime.combine(period_end, datetime.max.time())
185
+
186
+ # Get team videos
187
+ videos = await supabase.select("videos", filters={"organization_id": org_id})
188
+ completed_videos = [v for v in videos if v.get("status") == "completed"]
189
+
190
+ # Get analytics for team players
191
+ players = await supabase.select("players", filters={"organization_id": org_id})
192
+ player_ids = [p["id"] for p in players]
193
+
194
+ all_analytics = []
195
+ for pid in player_ids:
196
+ analytics = await supabase.select("analytics", filters={"player_id": pid})
197
+ # Filter by period when timestamps exist
198
+ analytics = [
199
+ a for a in analytics
200
+ if (ts := _parse_ts(a.get("timestamp"))) is None or (period_start_dt <= ts <= period_end_dt)
201
+ ]
202
+ all_analytics.extend(analytics)
203
+
204
+ # Aggregate team metrics
205
+ total_passes = sum(1 for a in all_analytics if a.get("metric_type") == "pass")
206
+ total_interceptions = sum(1 for a in all_analytics if a.get("metric_type") == "interception")
207
+
208
+ return TeamAnalyticsSummary(
209
+ organization_id=org_id,
210
+ period_start=period_start,
211
+ period_end=period_end,
212
+ total_games_analyzed=len([v for v in completed_videos if v.get("analysis_mode") == "team"]),
213
+ total_training_sessions=len(completed_videos),
214
+ total_passes=total_passes,
215
+ avg_passes_per_game=total_passes / len(completed_videos) if completed_videos else None,
216
+ total_interceptions=total_interceptions,
217
+ avg_interceptions_per_game=total_interceptions / len(completed_videos) if completed_videos else None,
218
+ )
219
+
220
+
221
+ @router.get("/skills/summary", response_model=SkillSummary)
222
+ async def get_skill_summary(
223
+ current_user: dict = Depends(require_personal_account),
224
+ supabase: SupabaseService = Depends(get_supabase),
225
+ ):
226
+ """
227
+ Get personal skill summary for dashboard display.
228
+
229
+ **Requires PERSONAL account.**
230
+ """
231
+ # Get user's player profile
232
+ players = await supabase.select("players", filters={"user_id": current_user["id"]})
233
+
234
+ if not players:
235
+ raise HTTPException(
236
+ status_code=status.HTTP_404_NOT_FOUND,
237
+ detail="No player profile found. Create one first."
238
+ )
239
+
240
+ player = players[0]
241
+ player_id = player["id"]
242
+
243
+ # Get all analytics
244
+ analytics = await supabase.select("analytics", filters={"player_id": player_id})
245
+
246
+ # Calculate scores (0-100 scale)
247
+ def calculate_score(values):
248
+ clean_values = [float(v) for v in values if v is not None]
249
+ if not clean_values:
250
+ return 50.0 # Neutral score if no data
251
+ avg = sum(clean_values) / len(clean_values)
252
+ return min(100.0, max(0.0, avg))
253
+
254
+ # Extract metrics defensively
255
+ form_scores = [a.get("value") for a in analytics if a.get("metric_type") == "form_consistency"]
256
+ speed_scores = [a.get("value") for a in analytics if a.get("metric_type") == "movement_score"]
257
+ dribble_scores = [a.get("value") for a in analytics if a.get("metric_type") == "dribble_score"]
258
+
259
+ shooting_score = calculate_score(form_scores)
260
+ movement_score = calculate_score(speed_scores)
261
+ dribbling_score = calculate_score(dribble_scores)
262
+ consistency_score = calculate_score([shooting_score, movement_score, dribbling_score])
263
+ overall_score = (shooting_score + movement_score + dribbling_score + consistency_score) / 4
264
+
265
+ # Determine strengths and areas to improve
266
+ scores = {
267
+ "Shooting Form": shooting_score,
268
+ "Movement": movement_score,
269
+ "Ball Handling": dribbling_score,
270
+ }
271
+
272
+ sorted_scores = sorted(scores.items(), key=lambda x: x[1], reverse=True)
273
+ strengths = [s[0] for s in sorted_scores if s[1] >= 70][:2]
274
+ areas_to_improve = [s[0] for s in sorted_scores if s[1] < 60][:2]
275
+
276
+ # Generate recommendations
277
+ recommendations = []
278
+ if shooting_score < 60:
279
+ recommendations.append("Focus on shooting form consistency - practice elbow alignment")
280
+ if movement_score < 60:
281
+ recommendations.append("Work on lateral movement drills to improve court coverage")
282
+ if dribbling_score < 60:
283
+ recommendations.append("Practice dribbling with both hands to improve ball control")
284
+
285
+ if not recommendations:
286
+ recommendations.append("Keep up the great work! Try advanced drills to maintain progress")
287
+
288
+ return SkillSummary(
289
+ player_id=player_id,
290
+ last_updated=datetime.utcnow(),
291
+ overall_score=overall_score,
292
+ shooting_score=shooting_score,
293
+ dribbling_score=dribbling_score,
294
+ movement_score=movement_score,
295
+ consistency_score=consistency_score,
296
+ strengths=strengths,
297
+ areas_to_improve=areas_to_improve,
298
+ recommendations=recommendations[:3],
299
+ )
300
+
301
+
302
+ @router.get("/progress/{player_id}", response_model=ProgressReport)
303
+ async def get_player_progress(
304
+ player_id: str,
305
+ metric_type: str = Query(..., description="Metric to track: 'speed', 'form', 'distance'"),
306
+ period_days: int = Query(30, ge=7, le=365),
307
+ current_user: dict = Depends(get_current_user),
308
+ supabase: SupabaseService = Depends(get_supabase),
309
+ ):
310
+ """
311
+ Get progress report for a specific metric over time.
312
+ """
313
+ # Verify access (same as player analytics)
314
+ player = await supabase.select_one("players", player_id)
315
+
316
+ if not player:
317
+ raise HTTPException(status_code=404, detail="Player not found")
318
+
319
+ # Check access
320
+ has_access = False
321
+ if current_user.get("account_type") == AccountType.TEAM.value:
322
+ if player.get("organization_id"):
323
+ org = await supabase.select_one("organizations", player["organization_id"])
324
+ has_access = org and org["owner_id"] == current_user["id"]
325
+ elif current_user.get("account_type") == AccountType.COACH.value:
326
+ player_org = player.get("organization_id")
327
+ coach_org = current_user.get("organization_id")
328
+ has_access = player_org and coach_org and str(player_org) == str(coach_org)
329
+ else:
330
+ has_access = player.get("user_id") == current_user["id"]
331
+
332
+ if not has_access:
333
+ raise HTTPException(status_code=403, detail="Access denied")
334
+
335
+ period_end = date.today()
336
+ period_start = period_end - timedelta(days=period_days)
337
+ period_start_dt = datetime.combine(period_start, datetime.min.time())
338
+ period_end_dt = datetime.combine(period_end, datetime.max.time())
339
+
340
+ # Map user-friendly names to internal metric types
341
+ metric_map = {
342
+ "speed": "avg_speed_kmh",
343
+ "form": "form_consistency",
344
+ "distance": "distance_km",
345
+ }
346
+
347
+ internal_metric = metric_map.get(metric_type, metric_type)
348
+
349
+ # Get analytics data
350
+ analytics = await supabase.select("analytics", filters={"player_id": player_id})
351
+ metric_data = [a for a in analytics if a.get("metric_type") == internal_metric]
352
+ metric_data = [
353
+ a for a in metric_data
354
+ if (ts := _parse_ts(a.get("timestamp"))) is None or (period_start_dt <= ts <= period_end_dt)
355
+ ]
356
+
357
+ # Build data points (mock aggregation by date)
358
+ data_points = []
359
+ for a in metric_data:
360
+ timestamp = a.get("timestamp")
361
+ value = a.get("value")
362
+ if timestamp and value is not None:
363
+ try:
364
+ dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
365
+ data_points.append(ProgressData(
366
+ date=dt.date(),
367
+ value=float(value),
368
+ metric_type=metric_type,
369
+ ))
370
+ except (ValueError, TypeError):
371
+ pass
372
+
373
+ # Calculate trend
374
+ if len(data_points) >= 2:
375
+ sorted_points = sorted(data_points, key=lambda x: x.date)
376
+ first_value = sorted_points[0].value
377
+ last_value = sorted_points[-1].value
378
+
379
+ if first_value > 0:
380
+ percent_change = ((last_value - first_value) / first_value) * 100
381
+ else:
382
+ percent_change = 0
383
+
384
+ if percent_change > 5:
385
+ trend = "improving"
386
+ elif percent_change < -5:
387
+ trend = "declining"
388
+ else:
389
+ trend = "stable"
390
+ else:
391
+ trend = "stable"
392
+ percent_change = 0
393
+
394
+ return ProgressReport(
395
+ player_id=player_id,
396
+ metric_type=metric_type,
397
+ period_start=period_start,
398
+ period_end=period_end,
399
+ data_points=data_points,
400
+ trend=trend,
401
+ percent_change=percent_change,
402
+ )
app/api/auth.py ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication API endpoints.
3
+ """
4
+ from datetime import datetime, timedelta
5
+ import os
6
+ from uuid import uuid4
7
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
8
+
9
+ from slowapi import Limiter
10
+ from slowapi.util import get_remote_address
11
+
12
+ from app.config import get_settings
13
+ from app.core.security import (
14
+ create_access_token,
15
+ create_refresh_token,
16
+ get_password_hash,
17
+ verify_password,
18
+ decode_access_token,
19
+ )
20
+ from app.dependencies import get_current_user, get_supabase
21
+ from app.models.user import (
22
+ UserCreate,
23
+ UserLogin,
24
+ UserUpdate,
25
+ User,
26
+ TokenResponse,
27
+ RefreshTokenRequest,
28
+ AccountType,
29
+ )
30
+ from app.services.supabase_client import SupabaseService
31
+
32
+
33
+ router = APIRouter()
34
+
35
+
36
+ def _get_limiter(request: Request) -> Limiter:
37
+ # Helper to get the global limiter attached in app.main
38
+ return request.app.state.limiter
39
+
40
+
41
+ @router.post(
42
+ "/register",
43
+ response_model=TokenResponse,
44
+ status_code=status.HTTP_201_CREATED,
45
+ )
46
+ async def register(
47
+ user_data: UserCreate,
48
+ supabase: SupabaseService = Depends(get_supabase),
49
+ ):
50
+ """
51
+ Register a new user account.
52
+
53
+ - **email**: Valid email address
54
+ - **password**: Minimum 8 characters
55
+ - **account_type**: 'team' or 'personal'
56
+ """
57
+ settings = get_settings()
58
+
59
+ # Check if user already exists
60
+ existing = await supabase.select("users", filters={"email": user_data.email})
61
+ if existing:
62
+ raise HTTPException(
63
+ status_code=status.HTTP_400_BAD_REQUEST,
64
+ detail="Email already registered"
65
+ )
66
+
67
+ # Create user
68
+ user_id = str(uuid4())
69
+ hashed_password = get_password_hash(user_data.password)
70
+
71
+ user_record = {
72
+ "id": user_id,
73
+ "email": user_data.email,
74
+ "hashed_password": hashed_password,
75
+ "account_type": user_data.account_type.value,
76
+ "full_name": user_data.full_name,
77
+ }
78
+
79
+ await supabase.insert("users", user_record)
80
+
81
+ # If team account, check for org or create one?
82
+ # Schema says organizations(owner_id).
83
+ org_id = None
84
+ if user_data.account_type == AccountType.TEAM:
85
+ org_id = str(uuid4())
86
+ await supabase.insert("organizations", {
87
+ "id": org_id,
88
+ "name": f"{user_data.full_name or user_id}'s Team",
89
+ "owner_id": user_id
90
+ })
91
+ # Link org back to user record
92
+ await supabase.update("users", user_id, {"organization_id": org_id})
93
+
94
+ # Create tokens
95
+ token_data = {
96
+ "sub": user_id,
97
+ "email": user_data.email,
98
+ "account_type": user_data.account_type.value,
99
+ "organization_id": org_id
100
+ }
101
+
102
+ access_token = create_access_token(token_data)
103
+ refresh_token = create_refresh_token(user_id)
104
+
105
+ user = User(
106
+ id=user_id,
107
+ email=user_data.email,
108
+ account_type=user_data.account_type,
109
+ full_name=user_data.full_name,
110
+ organization_id=org_id,
111
+ created_at=datetime.now()
112
+ )
113
+
114
+ return TokenResponse(
115
+ access_token=access_token,
116
+ refresh_token=refresh_token,
117
+ expires_in=settings.jwt_expiration_minutes * 60,
118
+ user=user
119
+ )
120
+
121
+
122
+ @router.post("/login", response_model=TokenResponse)
123
+ async def login(
124
+ request: Request,
125
+ credentials: UserLogin,
126
+ supabase: SupabaseService = Depends(get_supabase),
127
+ ):
128
+ """
129
+ Authenticate and get access tokens.
130
+ """
131
+ settings = get_settings()
132
+
133
+ # Apply a stricter rate limit on login to protect against brute force.
134
+ limiter = _get_limiter(request)
135
+ # The decorated function must accept a 'request' argument for slowapi inspection
136
+ limiter.limit("60/minute")(lambda request: None)(request)
137
+
138
+ # Find user
139
+ users = await supabase.select("users", filters={"email": credentials.email})
140
+ if not users:
141
+ raise HTTPException(
142
+ status_code=status.HTTP_401_UNAUTHORIZED,
143
+ detail="Invalid credentials"
144
+ )
145
+
146
+ user = users[0]
147
+
148
+ # Verify password
149
+ if not verify_password(credentials.password, user.get("hashed_password", "")):
150
+ raise HTTPException(
151
+ status_code=status.HTTP_401_UNAUTHORIZED,
152
+ detail="Invalid credentials"
153
+ )
154
+
155
+ # Fetch organization_id if applicable
156
+ org_id = user.get("organization_id")
157
+ if not org_id and user["account_type"] == AccountType.TEAM.value:
158
+ orgs = await supabase.select("organizations", filters={"owner_id": user["id"]})
159
+ if orgs:
160
+ org_id = orgs[0]["id"]
161
+ elif not org_id and user["account_type"] == AccountType.COACH.value:
162
+ # For coaches, we expect organization_id to be set in users table already via linking
163
+ pass
164
+
165
+ # Create tokens
166
+ token_data = {
167
+ "sub": user["id"],
168
+ "email": user["email"],
169
+ "account_type": user["account_type"],
170
+ "organization_id": org_id
171
+ }
172
+
173
+ access_token = create_access_token(token_data)
174
+ refresh_token = create_refresh_token(user["id"])
175
+
176
+ return TokenResponse(
177
+ access_token=access_token,
178
+ refresh_token=refresh_token,
179
+ expires_in=settings.jwt_expiration_minutes * 60,
180
+ user=User(
181
+ id=user["id"],
182
+ email=user["email"],
183
+ account_type=AccountType(user["account_type"]),
184
+ full_name=user.get("full_name"),
185
+ organization_id=org_id,
186
+ created_at=user.get("created_at") or datetime.now()
187
+ )
188
+ )
189
+
190
+
191
+ @router.post("/refresh", response_model=TokenResponse)
192
+ async def refresh_token(
193
+ body: RefreshTokenRequest,
194
+ supabase: SupabaseService = Depends(get_supabase),
195
+ ):
196
+ """
197
+ Refresh access token using a valid refresh token.
198
+ """
199
+ settings = get_settings()
200
+
201
+ payload = decode_access_token(body.refresh_token)
202
+ if not payload or payload.get("type") != "refresh":
203
+ raise HTTPException(
204
+ status_code=status.HTTP_401_UNAUTHORIZED,
205
+ detail="Invalid refresh token"
206
+ )
207
+
208
+ user_id = payload.get("sub")
209
+ user = await supabase.select_one("users", user_id)
210
+
211
+ if not user:
212
+ raise HTTPException(
213
+ status_code=status.HTTP_401_UNAUTHORIZED,
214
+ detail="User not found"
215
+ )
216
+
217
+ token_data = {
218
+ "sub": user["id"],
219
+ "email": user["email"],
220
+ "account_type": user["account_type"],
221
+ }
222
+
223
+ new_access_token = create_access_token(token_data)
224
+ new_refresh_token = create_refresh_token(user["id"])
225
+
226
+ return TokenResponse(
227
+ access_token=new_access_token,
228
+ refresh_token=new_refresh_token,
229
+ expires_in=settings.jwt_expiration_minutes * 60,
230
+ )
231
+
232
+
233
+ @router.post("/refresh-token", response_model=TokenResponse)
234
+ async def refresh_token_alias(
235
+ body: RefreshTokenRequest,
236
+ supabase: SupabaseService = Depends(get_supabase),
237
+ ):
238
+ """
239
+ Alias for /refresh to support frontend expectations.
240
+ """
241
+ return await refresh_token(body, supabase)
242
+
243
+
244
+ @router.get("/me", response_model=User)
245
+ async def get_current_user_profile(
246
+ current_user: dict = Depends(get_current_user),
247
+ supabase: SupabaseService = Depends(get_supabase),
248
+ ):
249
+ """
250
+ Get the currently authenticated user's profile.
251
+ """
252
+ user = await supabase.select_one("users", current_user["id"])
253
+ if not user:
254
+ raise HTTPException(
255
+ status_code=status.HTTP_404_NOT_FOUND,
256
+ detail="User not found"
257
+ )
258
+
259
+ # Fetch org info
260
+ org_id = user.get("organization_id")
261
+ if not org_id and user["account_type"] == AccountType.TEAM.value:
262
+ orgs = await supabase.select("organizations", filters={"owner_id": user["id"]})
263
+ if orgs:
264
+ org_id = orgs[0]["id"]
265
+ elif not org_id and user["account_type"] == AccountType.COACH.value:
266
+ pass
267
+
268
+ return User(
269
+ id=user["id"],
270
+ email=user["email"],
271
+ account_type=AccountType(user["account_type"]),
272
+ full_name=user.get("full_name"),
273
+ avatar_url=user.get("avatar_url"),
274
+ organization_id=org_id,
275
+ created_at=user.get("created_at"),
276
+ updated_at=user.get("updated_at"),
277
+ )
278
+
279
+
280
+ @router.put("/me", response_model=User)
281
+ async def update_current_user_profile(
282
+ update_data: UserUpdate,
283
+ current_user: dict = Depends(get_current_user),
284
+ supabase: SupabaseService = Depends(get_supabase),
285
+ ):
286
+ """
287
+ Update the currently authenticated user's profile.
288
+ """
289
+ update_dict = update_data.model_dump(exclude_unset=True)
290
+
291
+ if not update_dict:
292
+ raise HTTPException(
293
+ status_code=status.HTTP_400_BAD_REQUEST,
294
+ detail="No fields to update"
295
+ )
296
+
297
+ updated = await supabase.update("users", current_user["id"], update_dict)
298
+
299
+ return User(
300
+ id=updated["id"],
301
+ email=updated["email"],
302
+ account_type=AccountType(updated["account_type"]),
303
+ full_name=updated.get("full_name"),
304
+ avatar_url=updated.get("avatar_url"),
305
+ created_at=updated.get("created_at"),
306
+ updated_at=updated.get("updated_at"),
307
+ )
308
+
309
+
310
+ @router.post("/logout")
311
+ async def logout():
312
+ """
313
+ Log out the current user.
314
+ Since we use stateless JWT, this is primarily for client-side cleanup.
315
+ """
316
+ return {"message": "Successfully logged out"}
317
+
318
+
319
+ @router.delete("/account", status_code=status.HTTP_204_NO_CONTENT)
320
+ async def delete_current_user_account(
321
+ current_user: dict = Depends(get_current_user),
322
+ supabase: SupabaseService = Depends(get_supabase),
323
+ ):
324
+ """
325
+ Permanently delete the current user's account and all associated data.
326
+ """
327
+ user_id = current_user["id"]
328
+
329
+ # 1. Cleanup Videos and physical files
330
+ # Get all videos uploaded by this user
331
+ videos = await supabase.select("videos", filters={"uploader_id": user_id})
332
+
333
+ for video in videos:
334
+ video_id = str(video.get("id"))
335
+ storage_path = video.get("storage_path")
336
+
337
+ # Delete physical file
338
+ if storage_path and os.path.exists(storage_path):
339
+ try:
340
+ os.remove(storage_path)
341
+ except Exception as e:
342
+ print(f"Error removing file {storage_path}: {e}")
343
+
344
+ # Also check for annotated video
345
+ annotated_path = os.path.join(
346
+ os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
347
+ "output_videos", "annotated", f"{video_id}.mp4"
348
+ )
349
+ if os.path.exists(annotated_path):
350
+ try:
351
+ os.remove(annotated_path)
352
+ except Exception:
353
+ pass
354
+
355
+ # Cleanup related DB records (best effort)
356
+ for table in ["analysis_results", "detections", "analytics", "clips"]:
357
+ try:
358
+ await supabase.delete_where(table, {"video_id": video_id})
359
+ except Exception:
360
+ pass
361
+
362
+ # Delete video record
363
+ await supabase.delete("videos", video_id)
364
+
365
+ # 2. Cleanup Organization if Owner
366
+ if current_user.get("account_type") == AccountType.TEAM.value:
367
+ orgs = await supabase.select("organizations", filters={"owner_id": user_id})
368
+ for org in orgs:
369
+ org_id = str(org.get("id"))
370
+ # Unlink staff/players or delete them?
371
+ # For now, let's just delete the organization.
372
+ await supabase.delete("organizations", org_id)
373
+
374
+ # 3. Delete Profile (from users table)
375
+ await supabase.delete("users", user_id)
376
+
377
+ # 4. Delete from Supabase Auth
378
+ success = await supabase.delete_user_auth(user_id)
379
+ if not success:
380
+ print(f"CRITICAL: Failed to delete user {user_id} from Supabase Auth")
381
+
382
+ return None
app/api/communications.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from uuid import uuid4
2
+ from datetime import datetime
3
+ from typing import List
4
+ from fastapi import APIRouter, Depends, HTTPException, status
5
+
6
+ from app.dependencies import (
7
+ get_supabase,
8
+ get_current_user,
9
+ require_team_account,
10
+ require_staff_member,
11
+ require_linked_account
12
+ )
13
+ from app.services.supabase_client import SupabaseService
14
+ from app.models.communication import Announcement, AnnouncementCreate, AnnouncementListResponse
15
+
16
+ router = APIRouter()
17
+
18
+ @router.get("/announcements", response_model=AnnouncementListResponse)
19
+ async def get_announcements(
20
+ current_user: dict = Depends(get_current_user),
21
+ supabase: SupabaseService = Depends(get_supabase),
22
+ ):
23
+ """
24
+ Get all announcements for the user's organization.
25
+ Accessible by Team Owners, Coaches, and Linked Players.
26
+ """
27
+ org_id = current_user.get("organization_id")
28
+ user_id = current_user["id"]
29
+ account_type = current_user.get("account_type", "")
30
+
31
+ # --- Resolve org_id if not in token ---
32
+ if not org_id:
33
+ if account_type == "team":
34
+ # Team owner: look up via organizations table
35
+ orgs = await supabase.select("organizations", filters={"owner_id": user_id})
36
+ if orgs:
37
+ org_id = orgs[0]["id"]
38
+ else:
39
+ # Player/coach: check users table first
40
+ user_record = await supabase.select_one("users", user_id)
41
+ if user_record and user_record.get("organization_id"):
42
+ org_id = user_record["organization_id"]
43
+ else:
44
+ # Personal players linked to a team via players table
45
+ player_rows = await supabase.select("players", filters={"user_id": user_id})
46
+ linked = [p for p in player_rows if p.get("organization_id")]
47
+ if linked:
48
+ org_id = linked[0]["organization_id"]
49
+
50
+ if not org_id:
51
+ return AnnouncementListResponse(announcements=[], total=0)
52
+
53
+ announcements_data = await supabase.select(
54
+ "announcements",
55
+ filters={"organization_id": str(org_id)},
56
+ order_by="created_at",
57
+ ascending=False
58
+ )
59
+
60
+ announcements = []
61
+ for item in announcements_data:
62
+ announcement = Announcement(**item)
63
+ try:
64
+ author = await supabase.select_one("users", str(item["author_id"]))
65
+ if author:
66
+ announcement.author_name = author.get("full_name") or author.get("email")
67
+ except Exception:
68
+ pass
69
+ announcements.append(announcement)
70
+
71
+ return AnnouncementListResponse(announcements=announcements, total=len(announcements))
72
+
73
+ @router.post("/announcements", response_model=Announcement, status_code=status.HTTP_201_CREATED)
74
+ async def create_announcement(
75
+ data: AnnouncementCreate,
76
+ current_user: dict = Depends(require_staff_member),
77
+ supabase: SupabaseService = Depends(get_supabase),
78
+ ):
79
+ """
80
+ Create a new announcement for the organization.
81
+ Restricted to Coaching Staff and Team Owners.
82
+ """
83
+ org_id = current_user.get("organization_id")
84
+
85
+ # Team owners may not have org_id in token — resolve from DB
86
+ if not org_id:
87
+ if current_user.get("account_type") == "team":
88
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
89
+ if orgs:
90
+ org_id = orgs[0]["id"]
91
+
92
+ if not org_id:
93
+ raise HTTPException(status_code=403, detail="You must be linked to an organization to post announcements")
94
+
95
+ announcement_id = str(uuid4())
96
+ record = {
97
+ "id": announcement_id,
98
+ "organization_id": str(org_id),
99
+ "author_id": current_user["id"],
100
+ "title": data.title,
101
+ "content": data.content,
102
+ "created_at": datetime.now().isoformat(),
103
+ "updated_at": datetime.now().isoformat()
104
+ }
105
+
106
+ saved = await supabase.insert("announcements", record)
107
+
108
+ # Notify all players in the org roster
109
+ try:
110
+ players = await supabase.select("players", filters={"organization_id": str(org_id)})
111
+ for player in players:
112
+ if player.get("user_id"):
113
+ await supabase.insert("notifications", {
114
+ "id": str(uuid4()),
115
+ "recipient_id": player["user_id"],
116
+ "title": f"New Announcement: {data.title}",
117
+ "message": f"{current_user.get('email')} posted a new announcement.",
118
+ "type": "announcement",
119
+ "read": False,
120
+ "created_at": datetime.now().isoformat()
121
+ })
122
+ except Exception as e:
123
+ print(f"Warning: Failed to send announcement notifications: {e}")
124
+
125
+ return Announcement(**saved)
126
+
127
+ @router.delete("/announcements/{announcement_id}", status_code=status.HTTP_204_NO_CONTENT)
128
+ async def delete_announcement(
129
+ announcement_id: str,
130
+ current_user: dict = Depends(require_staff_member),
131
+ supabase: SupabaseService = Depends(get_supabase),
132
+ ):
133
+ """
134
+ Delete an announcement.
135
+ Restricted to Coaches.
136
+ """
137
+ org_id = current_user.get("organization_id")
138
+ announcement = await supabase.select_one("announcements", announcement_id)
139
+
140
+ if not announcement:
141
+ raise HTTPException(status_code=404, detail="Announcement not found")
142
+
143
+ if str(announcement["organization_id"]) != str(org_id):
144
+ raise HTTPException(status_code=403, detail="Access denied")
145
+
146
+ await supabase.delete("announcements", announcement_id)
147
+ return None
app/api/personal_analysis.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Personal Analysis API endpoints.
3
+
4
+ Handles video upload + triggering the swiss basketball shot analysis pipeline
5
+ for individual (personal account) players.
6
+
7
+ Does NOT touch or interfere with the team analysis pipeline.
8
+ """
9
+ import os
10
+ import uuid
11
+ import logging
12
+ from typing import Optional
13
+ from datetime import datetime
14
+ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, BackgroundTasks
15
+ from fastapi.responses import JSONResponse
16
+
17
+ from app.dependencies import require_personal_account, get_supabase
18
+ from app.services.supabase_client import SupabaseService
19
+ from app.models.video import VideoStatus, AnalysisMode
20
+
21
+ logger = logging.getLogger("personal_analysis_api")
22
+
23
+ router = APIRouter()
24
+
25
+ # Where processed output videos are stored and served
26
+ _BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
27
+ PERSONAL_OUTPUT_DIR = os.path.join(_BASE_DIR, "uploads", "personal_output")
28
+ os.makedirs(PERSONAL_OUTPUT_DIR, exist_ok=True)
29
+
30
+ # In-memory job status store (simple; survives server restart via DB)
31
+ _job_cache: dict = {}
32
+
33
+
34
+ async def _save_job_to_db(supabase: SupabaseService, job: dict):
35
+ """Persist the job record to Supabase. Best-effort only."""
36
+ try:
37
+ existing = await supabase.select("personal_analyses", filters={"job_id": job["job_id"]})
38
+ if existing:
39
+ await supabase.update("personal_analyses", existing[0]["id"], job)
40
+ else:
41
+ await supabase.insert("personal_analyses", {**job, "id": str(uuid.uuid4())})
42
+ except Exception as e:
43
+ logger.warning(f"Could not save job to DB: {e}")
44
+
45
+
46
+ async def _run_and_update(job_id: str, video_path: str, user_id: str, supabase: SupabaseService, shooting_arm: str = "right"):
47
+ """Background task that runs the pipeline and updates the DB."""
48
+ from personal_analysis.pipeline import run_personal_analysis
49
+
50
+ BUCKET = "personal-analysis-videos"
51
+
52
+ _job_cache[job_id] = {"job_id": job_id, "status": "processing", "user_id": user_id}
53
+
54
+ result = await run_personal_analysis(
55
+ video_path=video_path,
56
+ output_dir=PERSONAL_OUTPUT_DIR,
57
+ job_id=job_id,
58
+ shooting_arm=shooting_arm,
59
+ )
60
+
61
+ # ── Upload annotated video to Supabase Storage ────────────────────────────
62
+ if result.get("status") == "completed":
63
+ local_output = os.path.join(PERSONAL_OUTPUT_DIR, f"{job_id}_output.mp4")
64
+ if os.path.exists(local_output):
65
+ try:
66
+ storage_path = f"{user_id}/{job_id}_output.mp4"
67
+ await supabase.upload_file_from_path(
68
+ bucket=BUCKET,
69
+ storage_path=storage_path,
70
+ local_path=local_output,
71
+ content_type="video/mp4",
72
+ )
73
+ signed_url = await supabase.get_long_lived_url(
74
+ bucket=BUCKET,
75
+ storage_path=storage_path,
76
+ expires_in=60 * 60 * 24 * 7, # 7 days
77
+ )
78
+ if signed_url:
79
+ result["annotated_video_url"] = signed_url
80
+ logger.info(f"[{job_id}] Uploaded to Supabase Storage → {storage_path}")
81
+ # Clean up local file after successful upload
82
+ try:
83
+ os.remove(local_output)
84
+ # Remove tmp file if it still exists
85
+ tmp = local_output.replace("_output.mp4", "_output_tmp.mp4")
86
+ if os.path.exists(tmp):
87
+ os.remove(tmp)
88
+ except Exception:
89
+ pass
90
+ except Exception as upload_err:
91
+ # Upload failed — fall back to local URL so results still work
92
+ logger.warning(f"[{job_id}] Supabase upload failed, using local URL: {upload_err}")
93
+ result["annotated_video_url"] = f"/personal-output/{job_id}_output.mp4"
94
+
95
+ _job_cache[job_id] = {**result, "user_id": user_id}
96
+
97
+ # Persist to DB (personal_analyses table)
98
+ await _save_job_to_db(supabase, {
99
+ "job_id": job_id,
100
+ "user_id": user_id,
101
+ "status": result.get("status", "completed"),
102
+ "results_json": result,
103
+ "created_at": datetime.utcnow().isoformat(),
104
+ })
105
+
106
+ # ── Push to global analytics table ────────────────────────────────────────
107
+ if result.get("status") == "completed":
108
+ try:
109
+ # Get player_id (personal users have 1 player record)
110
+ p_rows = await supabase.select("players", filters={"user_id": user_id})
111
+ if p_rows:
112
+ player_id = p_rows[0]["id"]
113
+ ts = datetime.utcnow().isoformat()
114
+
115
+ metrics_to_save = [
116
+ ("shot_attempt", result.get("shots_total", 0)),
117
+ ("shot_made", result.get("shots_made", 0)),
118
+ ("distance_km", float(result.get("total_distance_meters", 0) or 0) / 1000.0),
119
+ ("avg_speed_kmh", result.get("avg_speed_kmh", 0)),
120
+ ("max_speed_kmh", result.get("max_speed_kmh", 0)),
121
+ ("dribble_count", result.get("dribble_count", 0)),
122
+ ("form_consistency", 100 if result.get("overall_verdict") == "GOOD FORM" else 60),
123
+ ]
124
+
125
+ for m_type, val in metrics_to_save:
126
+ if val is not None:
127
+ await supabase.insert("analytics", {
128
+ "id": str(uuid.uuid4()),
129
+ "player_id": player_id,
130
+ "metric_type": m_type,
131
+ "value": float(val),
132
+ "timestamp": ts,
133
+ "video_id": job_id
134
+ })
135
+ except Exception as ae:
136
+ logger.warning(f"Could not push to analytics table: {ae}")
137
+
138
+ # Update global videos table status
139
+ try:
140
+ final_video_status = VideoStatus.COMPLETED.value if result.get("status") == "completed" else VideoStatus.FAILED.value
141
+
142
+ update_payload = {
143
+ "status": final_video_status,
144
+ "progress_percent": 100 if final_video_status == VideoStatus.COMPLETED.value else 0,
145
+ "has_annotated": final_video_status == VideoStatus.COMPLETED.value,
146
+ }
147
+
148
+ if result.get("annotated_video_url"):
149
+ update_payload["annotated_url"] = result.get("annotated_video_url")
150
+
151
+ await supabase.update("videos", job_id, update_payload)
152
+ except Exception as e:
153
+ logger.warning(f"Could not update videos table status: {e}")
154
+
155
+ # Clean up the raw upload to save disk space
156
+ try:
157
+ if os.path.exists(video_path):
158
+ os.remove(video_path)
159
+ except Exception:
160
+ pass
161
+
162
+
163
+ @router.post("/analysis/trigger")
164
+ async def trigger_analysis(
165
+ background_tasks: BackgroundTasks,
166
+ video: UploadFile = File(...),
167
+ shooting_arm: str = "right",
168
+ current_user: dict = Depends(require_personal_account),
169
+ supabase: SupabaseService = Depends(get_supabase),
170
+ ):
171
+ """
172
+ Upload a personal training video and start shot analysis.
173
+ Returns a job_id immediately — poll /analysis/{job_id} for results.
174
+ """
175
+ # Validate file type
176
+ allowed_ext = {".mp4", ".avi", ".mov", ".mkv"}
177
+ _, ext = os.path.splitext(video.filename or "video.mp4")
178
+ if ext.lower() not in allowed_ext:
179
+ raise HTTPException(
180
+ status_code=status.HTTP_400_BAD_REQUEST,
181
+ detail=f"Unsupported video format '{ext}'. Allowed: {', '.join(allowed_ext)}"
182
+ )
183
+
184
+ # Save to temporary upload path
185
+ job_id = str(uuid.uuid4())
186
+ upload_path = os.path.join(PERSONAL_OUTPUT_DIR, f"{job_id}_input{ext}")
187
+
188
+ content = await video.read()
189
+ if len(content) > 500 * 1024 * 1024: # 500 MB limit
190
+ raise HTTPException(status_code=413, detail="Video file too large (max 500 MB)")
191
+
192
+ with open(upload_path, "wb") as f:
193
+ f.write(content)
194
+
195
+ # 1. Register in the global videos table so it shows up in general lists
196
+ try:
197
+ # Get basic video info for the record
198
+ # In a real app we'd use cv2 here, but for personal portal we can use defaults
199
+ video_record = {
200
+ "id": job_id,
201
+ "uploader_id": current_user["id"],
202
+ "title": video.filename or f"Analysis {datetime.utcnow().strftime('%Y-%m-%d %H:%M')}",
203
+ "description": f"Personal shot analysis (hand: {shooting_arm})",
204
+ "analysis_mode": AnalysisMode.PERSONAL.value,
205
+ "status": VideoStatus.PROCESSING.value,
206
+ "storage_path": upload_path,
207
+ "file_size_bytes": len(content),
208
+ "created_at": datetime.utcnow().isoformat(),
209
+ }
210
+ await supabase.insert("videos", video_record)
211
+ except Exception as e:
212
+ logger.warning(f"Could not insert into videos table: {e}")
213
+
214
+ user_id = current_user["id"]
215
+ _job_cache[job_id] = {"job_id": job_id, "status": "processing", "user_id": user_id}
216
+
217
+ # Fire and forget — analysis runs in background
218
+ background_tasks.add_task(
219
+ _run_and_update, job_id, upload_path, user_id, supabase, shooting_arm
220
+ )
221
+
222
+ return {
223
+ "job_id": job_id,
224
+ "status": "processing",
225
+ "message": "Analysis started. Poll /player/analysis/${job_id} for results.",
226
+ }
227
+
228
+
229
+ @router.get("/analysis/{job_id}")
230
+ async def get_analysis_result(
231
+ job_id: str,
232
+ current_user: dict = Depends(require_personal_account),
233
+ supabase: SupabaseService = Depends(get_supabase),
234
+ ):
235
+ """
236
+ Poll the status / results of a personal analysis job.
237
+ Returns 'processing' until done, then the full results.
238
+ """
239
+ # Check in-memory cache first
240
+ if job_id in _job_cache:
241
+ job = _job_cache[job_id]
242
+ if job.get("user_id") != current_user["id"]:
243
+ raise HTTPException(status_code=403, detail="Access denied")
244
+ return job
245
+
246
+ # Fall back to DB
247
+ try:
248
+ rows = await supabase.select("personal_analyses", filters={"job_id": job_id})
249
+ if rows:
250
+ record = rows[0]
251
+ if record.get("user_id") != current_user["id"]:
252
+ raise HTTPException(status_code=403, detail="Access denied")
253
+
254
+ # results_json holds the full pipeline output dict.
255
+ # Merge it with the top-level DB record so callers always see
256
+ # shots_total, made_percentage, annotated_video_url etc. at the
257
+ # root level (not buried inside a nested "results_json" key).
258
+ results_json = record.get("results_json") or {}
259
+ if isinstance(results_json, str):
260
+ import json as _json
261
+ try:
262
+ results_json = _json.loads(results_json)
263
+ except Exception:
264
+ results_json = {}
265
+
266
+ merged = {**record, **results_json}
267
+ return merged
268
+ except HTTPException:
269
+ raise
270
+ except Exception:
271
+ pass
272
+
273
+ raise HTTPException(status_code=404, detail="Analysis job not found")
274
+
275
+
276
+ @router.get("/analysis")
277
+ async def list_my_analyses(
278
+ current_user: dict = Depends(require_personal_account),
279
+ supabase: SupabaseService = Depends(get_supabase),
280
+ ):
281
+ """
282
+ List all past personal analysis jobs for the current player.
283
+ """
284
+ try:
285
+ rows = await supabase.select(
286
+ "personal_analyses",
287
+ filters={"user_id": current_user["id"]},
288
+ order_by="created_at",
289
+ ascending=False
290
+ )
291
+ return rows or []
292
+ except Exception as e:
293
+ logger.warning(f"Could not fetch analyses: {e}")
294
+ return []
295
+
296
+
297
+ @router.delete("/analysis/{job_id}", status_code=200)
298
+ async def delete_analysis(
299
+ job_id: str,
300
+ current_user: dict = Depends(require_personal_account),
301
+ supabase: SupabaseService = Depends(get_supabase),
302
+ ):
303
+ """
304
+ Delete a personal analysis job and its output files.
305
+ """
306
+ # Verify ownership
307
+ try:
308
+ rows = await supabase.select("personal_analyses", filters={"job_id": job_id})
309
+ except Exception:
310
+ rows = []
311
+
312
+ if not rows:
313
+ raise HTTPException(status_code=404, detail="Analysis not found")
314
+
315
+ record = rows[0]
316
+ if record.get("user_id") != current_user["id"]:
317
+ raise HTTPException(status_code=403, detail="Access denied")
318
+
319
+ # Delete from DB
320
+ try:
321
+ await supabase.delete("personal_analyses", record["id"])
322
+ except Exception as e:
323
+ logger.warning(f"Could not delete personal_analyses record: {e}")
324
+
325
+ # Delete from videos table too
326
+ try:
327
+ await supabase.delete("videos", job_id)
328
+ except Exception:
329
+ pass
330
+
331
+ # Remove output files from disk
332
+ for suffix in ["_output.mp4", "_output.avi", "_report.txt"]:
333
+ fpath = os.path.join(PERSONAL_OUTPUT_DIR, f"{job_id}{suffix}")
334
+ try:
335
+ if os.path.exists(fpath):
336
+ os.remove(fpath)
337
+ except Exception:
338
+ pass
339
+
340
+ # Remove from in-memory cache
341
+ _job_cache.pop(job_id, None)
342
+
343
+ return {"message": "Analysis deleted successfully"}
app/api/player_routes.py ADDED
@@ -0,0 +1,679 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Player Portal API endpoints (for Personal/Individual Player Users).
3
+ """
4
+ import os
5
+ from uuid import uuid4
6
+ from datetime import datetime
7
+ from typing import Optional, List
8
+ from fastapi import APIRouter, Depends, HTTPException, Query, status, UploadFile, File
9
+ from fastapi.responses import JSONResponse
10
+
11
+ from ..dependencies import require_personal_account, get_supabase, get_current_user
12
+ from ..services.supabase_client import SupabaseService
13
+ from ..models.player import Player, PlayerCreate, PlayerUpdate
14
+ from ..models.schedule import Schedule
15
+ from ..models.notification import Notification
16
+
17
+ router = APIRouter()
18
+
19
+ @router.get("/debug-portal")
20
+ async def debug_portal():
21
+ return {"status": "portal-registered"}
22
+
23
+ @router.get("/players")
24
+ async def get_my_player(
25
+ current_user: dict = Depends(require_personal_account),
26
+ supabase: SupabaseService = Depends(get_supabase),
27
+ ):
28
+ """
29
+ Get the current user's player profile.
30
+ """
31
+ players = await supabase.select("players", filters={"user_id": current_user["id"]})
32
+ if not players:
33
+ return [] # Or raise 404? Frontend expects list?
34
+ return players
35
+
36
+ @router.get("/players/{player_id}")
37
+ async def get_player_by_id(
38
+ player_id: str,
39
+ current_user: dict = Depends(require_personal_account),
40
+ supabase: SupabaseService = Depends(get_supabase),
41
+ ):
42
+ """
43
+ Get generic player information.
44
+ Currently used in personal dashboard too.
45
+ """
46
+ player = await supabase.select_one("players", player_id)
47
+ if not player:
48
+ raise HTTPException(status_code=404, detail="Player not found")
49
+
50
+ # Security check: must be the user's player
51
+ if player.get("user_id") != current_user["id"]:
52
+ raise HTTPException(status_code=403, detail="Access denied")
53
+
54
+ return player
55
+
56
+ @router.post("/players/{player_id}/activities")
57
+ async def add_player_activity(
58
+ player_id: str,
59
+ activity_data: dict, # Using dict for flexibility or could use Pydantic model
60
+ current_user: dict = Depends(require_personal_account),
61
+ supabase: SupabaseService = Depends(get_supabase),
62
+ ):
63
+ player = await supabase.select_one("players", player_id)
64
+ if not player or player.get("user_id") != current_user["id"]:
65
+ raise HTTPException(status_code=403, detail="Access denied")
66
+
67
+ activity_record = activity_data.copy()
68
+ activity_record["player_id"] = player_id
69
+ activity_record["id"] = str(uuid4())
70
+ if "date" not in activity_record:
71
+ activity_record["date"] = datetime.utcnow().isoformat()
72
+
73
+ saved = await supabase.insert("activities", activity_record)
74
+ return saved
75
+
76
+ @router.get("/players/{player_id}/activities")
77
+ async def get_player_activities(
78
+ player_id: str,
79
+ current_user: dict = Depends(require_personal_account),
80
+ supabase: SupabaseService = Depends(get_supabase),
81
+ ):
82
+ player = await supabase.select_one("players", player_id)
83
+ if not player or player.get("user_id") != current_user["id"]:
84
+ raise HTTPException(status_code=403, detail="Access denied")
85
+
86
+ activities = await supabase.select("activities", filters={"player_id": player_id}, order_by="date", ascending=False)
87
+ return activities
88
+
89
+ @router.get("/schedule")
90
+ async def get_schedule(
91
+ current_user: dict = Depends(require_personal_account),
92
+ supabase: SupabaseService = Depends(get_supabase),
93
+ ):
94
+ """
95
+ Get schedule for the player.
96
+ Includes personal events + team events if they belong to a team.
97
+ Always prefers the player record that has an organization_id.
98
+ """
99
+ players = await supabase.select("players", filters={"user_id": current_user["id"]})
100
+ if not players:
101
+ return []
102
+
103
+ # If multiple player records exist, prefer the one with an organization_id
104
+ linked = [p for p in players if p.get("organization_id")]
105
+ player = linked[0] if linked else players[0]
106
+
107
+ org_id = player.get("organization_id")
108
+
109
+ if org_id:
110
+ schedule = await supabase.select("schedules", filters={"organization_id": org_id})
111
+ else:
112
+ schedule = []
113
+
114
+ return schedule or []
115
+
116
+ @router.get("/training")
117
+ async def get_training_sessions(
118
+ date: Optional[str] = None,
119
+ current_user: dict = Depends(require_personal_account),
120
+ supabase: SupabaseService = Depends(get_supabase),
121
+ ):
122
+ """
123
+ Get training sessions (videos) for a specific date or all.
124
+ """
125
+ filters = {"uploader_id": current_user["id"]}
126
+ # If date provided, filter by created_at range
127
+
128
+ videos = await supabase.select("videos", filters=filters)
129
+
130
+ if date:
131
+ try:
132
+ target_date = datetime.strptime(date, "%Y-%m-%d").date()
133
+ videos = [v for v in videos if datetime.fromisoformat(v["created_at"].replace("Z", "+00:00")).date() == target_date]
134
+ except ValueError:
135
+ pass # Ignore invalid date
136
+
137
+ return videos
138
+
139
+
140
+ @router.get("/training-videos")
141
+ async def get_training_videos(
142
+ current_user: dict = Depends(require_personal_account),
143
+ supabase: SupabaseService = Depends(get_supabase),
144
+ ):
145
+ """
146
+ Alias for /training to satisfy frontend PlayerDashboard.
147
+ """
148
+ try:
149
+ videos = await supabase.select("videos", filters={"uploader_id": current_user["id"]})
150
+ return videos or []
151
+ except Exception as e:
152
+ print(f"Error fetching training videos: {e}")
153
+ return []
154
+
155
+
156
+ @router.get("/training-history")
157
+ async def get_training_history(
158
+ current_user: dict = Depends(require_personal_account),
159
+ supabase: SupabaseService = Depends(get_supabase),
160
+ ):
161
+ """
162
+ Get training history for the player including activities and personal analyses.
163
+ """
164
+ try:
165
+ players = await supabase.select("players", filters={"user_id": current_user["id"]})
166
+ player_id = players[0]["id"] if players else None
167
+
168
+ history = []
169
+
170
+ # 1. Fetch manual activities
171
+ if player_id:
172
+ activities = await supabase.select("activities", filters={"player_id": player_id})
173
+ for act in activities:
174
+ history.append({
175
+ "id": act.get("id"),
176
+ "type": act.get("type", "training"),
177
+ "title": act.get("title", "Practice Session"),
178
+ "date": act.get("date"),
179
+ "duration": act.get("duration"),
180
+ "category": "Manual"
181
+ })
182
+
183
+ # 2. Fetch personal analyses
184
+ analyses = await supabase.select("personal_analyses", filters={"user_id": current_user["id"]})
185
+ for row in analyses:
186
+ results = row.get("results_json") or {}
187
+ if isinstance(results, str):
188
+ try: import json; results = json.loads(results);
189
+ except: results = {}
190
+
191
+ history.append({
192
+ "id": row.get("id"),
193
+ "type": "shooting",
194
+ "title": f"Shot Analysis: {results.get('shots_made', 0)}/{results.get('shots_total', 0)}",
195
+ "date": row.get("created_at"),
196
+ "duration": "15 min", # Estimated
197
+ "category": "AI Analysis",
198
+ "outcome": results.get("made_percentage")
199
+ })
200
+
201
+ # Sort by date descending
202
+ history.sort(key=lambda x: x["date"] or "", reverse=True)
203
+ return history
204
+ except Exception as e:
205
+ print(f"Error fetching training history: {e}")
206
+ return []
207
+
208
+ @router.post("/training")
209
+ async def log_training(
210
+ training_data: dict,
211
+ current_user: dict = Depends(require_personal_account),
212
+ supabase: SupabaseService = Depends(get_supabase),
213
+ ):
214
+ """
215
+ Log a manual training session.
216
+ (Currently just a placeholder or could store in a separate table or events)
217
+ """
218
+ # For now, maybe just return success mock
219
+ return {"message": "Training logged", "id": str(uuid4())}
220
+
221
+ @router.get("/notifications")
222
+ async def get_notifications(
223
+ current_user: dict = Depends(get_current_user),
224
+ supabase: SupabaseService = Depends(get_supabase),
225
+ ):
226
+ notifs = await supabase.select("notifications", filters={"recipient_id": current_user["id"]}, order_by="created_at", ascending=False)
227
+ return notifs
228
+
229
+ @router.put("/notifications/{notification_id}/read")
230
+ async def mark_notification(
231
+ notification_id: str,
232
+ current_user: dict = Depends(get_current_user),
233
+ supabase: SupabaseService = Depends(get_supabase),
234
+ ):
235
+ await supabase.update("notifications", notification_id, {"read": True})
236
+ return {"message": "Marked as read"}
237
+
238
+ @router.put("/notifications/read-all")
239
+ async def mark_all_read(
240
+ current_user: dict = Depends(get_current_user),
241
+ supabase: SupabaseService = Depends(get_supabase),
242
+ ):
243
+ # This might need a custom query or loop
244
+ notifs = await supabase.select("notifications", filters={"recipient_id": current_user["id"], "read": False})
245
+ for n in notifs:
246
+ await supabase.update("notifications", n["id"], {"read": True})
247
+ return {"message": "All marked as read"}
248
+
249
+ @router.delete("/notifications/{notification_id}")
250
+ async def delete_notification(
251
+ notification_id: str,
252
+ current_user: dict = Depends(get_current_user),
253
+ supabase: SupabaseService = Depends(get_supabase),
254
+ ):
255
+ await supabase.delete("notifications", notification_id)
256
+ return {"message": "Deleted"}
257
+
258
+
259
+ @router.get("/performance-metrics")
260
+ async def get_performance_metrics(
261
+ current_user: dict = Depends(require_personal_account),
262
+ supabase: SupabaseService = Depends(get_supabase),
263
+ ):
264
+ """
265
+ Get real performance metrics aggregated from personal analyses and activities.
266
+ """
267
+ try:
268
+ analyses = await supabase.select("personal_analyses", filters={"user_id": current_user["id"]})
269
+ activities = await supabase.select("activities", filters={"player_id": current_user["id"]}) # Assuming player_id is user_id in some contexts, adjust if needed
270
+
271
+ # Pull basic player info to get session count if activities are stored elsewhere
272
+ players = await supabase.select("players", filters={"user_id": current_user["id"]})
273
+ player_id = players[0]["id"] if players else None
274
+
275
+ if player_id:
276
+ activities = await supabase.select("activities", filters={"player_id": player_id})
277
+
278
+ total_shots = 0
279
+ total_made = 0
280
+ total_form_score = 0
281
+ total_distance = 0.0
282
+ total_minutes = 0.0
283
+ completed_analyses = 0
284
+
285
+ for row in analyses:
286
+ results = row.get("results_json") or {}
287
+ # Handle if nested string
288
+ if isinstance(results, str):
289
+ try: import json; results = json.loads(results)
290
+ except: results = {}
291
+
292
+ if results.get("status") == "completed":
293
+ # Real stats from AI pipeline
294
+ total_shots += int(results.get("shots_total", 0) or 0)
295
+ total_made += int(results.get("shots_made", 0) or 0)
296
+ total_distance += float(results.get("total_distance_meters", 0) or 0) / 1000.0 # Convert to km
297
+ total_minutes += float(results.get("duration_seconds", 0) or 0) / 60.0
298
+
299
+ # Simple form score: 100 for GOOD FORM, 60 for NEEDS WORK
300
+ reports = results.get("shot_reports", [])
301
+ if reports and isinstance(reports, list):
302
+ score = sum(100 if r.get("verdict") == "GOOD FORM" else 60 for r in reports) / len(reports)
303
+ total_form_score += score
304
+ completed_analyses += 1
305
+
306
+ accuracy = float(total_made / total_shots * 100) if total_shots > 0 else 0.0
307
+ overall_rating = float(total_form_score / completed_analyses) if completed_analyses > 0 else 0.0
308
+
309
+ # Merge manual activities for session counts
310
+ total_sessions = len(activities) + len(analyses)
311
+
312
+ return {
313
+ "shootingAccuracy": round(accuracy, 1),
314
+ "overallRating": round(overall_rating, 1),
315
+ "weeklyStats": {
316
+ "sessionsCompleted": total_sessions,
317
+ "training_sessions": total_sessions,
318
+ "minutesTrained": round(total_minutes, 1),
319
+ "training_minutes": round(total_minutes, 1),
320
+ "distance": round(total_distance, 2),
321
+ "shotsAttempted": total_shots,
322
+ "shotsMade": total_made
323
+ }
324
+ }
325
+ except Exception as e:
326
+ import traceback
327
+ print(f"CRITICAL ERROR aggregating performance metrics: {e}")
328
+ print(traceback.format_exc())
329
+ return {
330
+ "shootingAccuracy": 0, "overallRating": 0,
331
+ "weeklyStats": {"sessionsCompleted": 0, "minutesTrained": 0, "shotsAttempted": 0, "shotsMade": 0}
332
+ }
333
+
334
+
335
+ @router.get("/skill-trends")
336
+ async def get_skill_trends(
337
+ current_user: dict = Depends(require_personal_account),
338
+ supabase: SupabaseService = Depends(get_supabase),
339
+ ):
340
+ """
341
+ Get actual skill improvement trends from historical analysis data.
342
+ """
343
+ try:
344
+ analyses = await supabase.select("personal_analyses", filters={"user_id": current_user["id"]})
345
+
346
+ shooting_points = []
347
+ for row in sorted(analyses, key=lambda x: x["created_at"]):
348
+ results = row.get("results_json") or {}
349
+ if isinstance(results, str):
350
+ try: import json; results = json.loads(results);
351
+ except: results = {}
352
+
353
+ if results.get("status") == "completed":
354
+ shooting_points.append(results.get("made_percentage", 0))
355
+
356
+ # If we have too many points, sample or slice
357
+ recent_points: List[float] = []
358
+ for p in shooting_points[-12:]:
359
+ recent_points.append(p)
360
+
361
+ # If we have no data, return empty or mock if requested (usually empty is better for "Real Data")
362
+ return {
363
+ "shooting": recent_points,
364
+ "dribbling": [],
365
+ "defense": [],
366
+ "fitness": []
367
+ }
368
+ except Exception as e:
369
+ print(f"Error fetching trends: {e}")
370
+ return {"shooting": [], "dribbling": [], "defense": [], "fitness": []}
371
+
372
+
373
+ @router.get("/skills")
374
+ async def get_skills_analytics(
375
+ startDate: Optional[str] = Query(None),
376
+ endDate: Optional[str] = Query(None),
377
+ current_user: dict = Depends(require_personal_account),
378
+ supabase: SupabaseService = Depends(get_supabase),
379
+ ):
380
+ """
381
+ Get detailed skill analytics based on real personal analysis reports.
382
+ """
383
+ try:
384
+ analyses = await supabase.select("personal_analyses", filters={"user_id": current_user["id"]})
385
+
386
+ skills_data = []
387
+ shooting_scores = []
388
+
389
+ # Use latest analyses to build the report
390
+ for row in analyses[-10:]: # Look at last 10
391
+ results = row.get("results_json") or {}
392
+ if isinstance(results, str):
393
+ try: import json; results = json.loads(results)
394
+ except: results = {}
395
+
396
+ if results.get("status") == "completed":
397
+ accuracy = float(results.get("made_percentage", 0) or 0)
398
+ shooting_scores.append(accuracy)
399
+
400
+ reports = results.get("shot_reports", [])
401
+ if isinstance(reports, list):
402
+ for r in reports:
403
+ if not isinstance(r, dict): continue
404
+ metrics = r.get("metrics", {}) if isinstance(r.get("metrics"), dict) else {}
405
+
406
+ mapped_shot_details = [{
407
+ "outcome": "made" if r.get("verdict") == "GOOD FORM" else "missed",
408
+ "faults": r.get("issues", []) if isinstance(r.get("issues"), list) else [],
409
+ "feedback": "\n".join(r.get("issues", ["Form looking solid"])) if isinstance(r.get("issues"), list) else "Form looking solid",
410
+ "biometrics": {
411
+ "shoulder_angle": float(metrics.get("shoulder_angle", 0) or 0),
412
+ "elbow_angle": float(metrics.get("elbow_angle", 0) or 0),
413
+ "knee_angle": float(metrics.get("knee_angle", 0) or 0),
414
+ "hip_angle": float(metrics.get("hip_angle", 0) or 0),
415
+ }
416
+ }]
417
+
418
+ issues_list = r.get("issues", []) if isinstance(r.get("issues"), list) else []
419
+ if not issues_list or r.get("verdict") == "GOOD FORM":
420
+ dynamic_feedback = "Perfect execution on this shot! Mechanics look solid and fluid. Keep up the consistency."
421
+ else:
422
+ issues_str = " ".join(str(i) for i in issues_list)
423
+ dynamic_feedback = f"Focus areas for this attempt: {issues_str} Maintaining proper alignment will improve your accuracy."
424
+
425
+ skills_data.append({
426
+ "id": f"shot-{row.get('id', '')}-{r.get('shot_number', 0)}",
427
+ "name": f"Shot {r.get('shot_number', 0)}",
428
+ "category": "Shooting",
429
+ "score": 100 if r.get("verdict") == "GOOD FORM" else 60,
430
+ "feedback": issues_list,
431
+ "date": str(row.get("created_at", "")),
432
+ "videoUrl": str(results.get("annotated_video_url", "")),
433
+ "analysisData": {
434
+ "feedback": str(results.get("overall_feedback", dynamic_feedback)),
435
+ "shot_details": mapped_shot_details
436
+ }
437
+ })
438
+
439
+ avg_shooting = float(sum(shooting_scores) / len(shooting_scores)) if shooting_scores else 0.0
440
+
441
+ # Calculate real volume metrics
442
+ total_dist_km = 0.0
443
+ total_dur_mins = 0.0
444
+ for row in analyses:
445
+ res = row.get("results_json") or {}
446
+ if isinstance(res, str):
447
+ try: import json; res = json.loads(res)
448
+ except: res = {}
449
+ if res.get("status") == "completed":
450
+ total_dist_km += float(res.get("total_distance_meters", 0) or 0) / 1000.0
451
+ total_dur_mins += float(res.get("duration_seconds", 0) or 0) / 60.0
452
+
453
+ return {
454
+ "skills": skills_data,
455
+ "summary": {
456
+ "overall": round(avg_shooting, 1),
457
+ "shooting": round(avg_shooting, 1),
458
+ "defense": 0.0,
459
+ "training_sessions": len(analyses),
460
+ "training_minutes": round(total_dur_mins, 1),
461
+ "distance": round(total_dist_km, 2)
462
+ }
463
+ }
464
+ except Exception as e:
465
+ import traceback
466
+ print(f"CRITICAL ERROR fetching real skills: {e}")
467
+ print(traceback.format_exc())
468
+ return {"skills": [], "summary": {"overall": 0, "shooting": 0, "defense": 0}}
469
+
470
+
471
+ @router.delete("/skills/{skill_id}", status_code=status.HTTP_204_NO_CONTENT)
472
+ async def delete_skill_analysis(
473
+ skill_id: str,
474
+ current_user: dict = Depends(require_personal_account),
475
+ supabase: SupabaseService = Depends(get_supabase),
476
+ ):
477
+ """
478
+ Delete a personal analysis record.
479
+ The frontend ID format: shot-{db_id}-{shot_number}
480
+ """
481
+ # Extract the actual analysis ID
482
+ db_id = skill_id
483
+ if skill_id.startswith("shot-"):
484
+ parts = skill_id.split("-")
485
+ if len(parts) >= 2:
486
+ db_id = "-".join(parts[1:-1]) if len(parts) > 2 else parts[1]
487
+
488
+ analysis = await supabase.select_one("personal_analyses", db_id)
489
+ if not analysis:
490
+ raise HTTPException(status_code=404, detail="Analysis not found")
491
+
492
+ if analysis.get("user_id") != current_user["id"]:
493
+ raise HTTPException(status_code=403, detail="Not authorized to delete this analysis")
494
+
495
+ # If you want to delete the file from Supabase as well:
496
+ results = analysis.get("results_json") or {}
497
+ if isinstance(results, str):
498
+ try:
499
+ import json
500
+ results = json.loads(results)
501
+ except:
502
+ results = {}
503
+
504
+ # NOTE: Optional - delete from Supabase block
505
+ # annotated_url = results.get("annotated_video_url")
506
+ # if annotated_url and "personal-analysis-videos" in annotated_url:
507
+ # try:
508
+ # # Need a way to extract the path from the signed URL or derive it
509
+ # # For safety, we just delete the DB record.
510
+ # except Exception:
511
+ # pass
512
+
513
+ await supabase.delete("personal_analyses", db_id)
514
+ return None
515
+
516
+ @router.post("/profile/image")
517
+ async def upload_profile_image(
518
+ image: UploadFile = File(...),
519
+ current_user: dict = Depends(get_current_user),
520
+ supabase: SupabaseService = Depends(get_supabase),
521
+ ):
522
+ """
523
+ Upload a profile picture for the current user/player.
524
+ Saves the file locally and returns the public URL.
525
+ """
526
+ # Validate file type
527
+ valid_types = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"]
528
+ if image.content_type not in valid_types:
529
+ raise HTTPException(status_code=400, detail="Only image files (JPG, PNG, GIF, WEBP) are allowed")
530
+
531
+ # Read file content
532
+ content = await image.read()
533
+ if len(content) > 5 * 1024 * 1024: # 5MB limit
534
+ raise HTTPException(status_code=400, detail="Image size must be less than 5MB")
535
+
536
+ # Build a safe filename and storage path
537
+ ext = os.path.splitext(image.filename or "avatar.jpg")[1] or ".jpg"
538
+ safe_filename = f"{current_user['id']}_avatar{ext}"
539
+ avatars_dir = os.path.join("./uploads", "avatars")
540
+ os.makedirs(avatars_dir, exist_ok=True)
541
+ file_path = os.path.join(avatars_dir, safe_filename)
542
+
543
+ # Write the file to disk
544
+ with open(file_path, "wb") as f:
545
+ f.write(content)
546
+
547
+ # The public URL served by the backend static file server
548
+ image_url = f"/uploads/avatars/{safe_filename}"
549
+
550
+ # Update user avatar_url
551
+ try:
552
+ await supabase.update("users", current_user["id"], {"avatar_url": image_url})
553
+ except Exception as e:
554
+ print(f"Warning: Failed to update user avatar_url: {e}")
555
+
556
+ # If the user has a player profile, update that too
557
+ try:
558
+ players = await supabase.select("players", filters={"user_id": current_user["id"]})
559
+ for player in players:
560
+ await supabase.update("players", player["id"], {"avatar_url": image_url})
561
+ except Exception as e:
562
+ print(f"Warning: Failed to update player avatar_url: {e}")
563
+
564
+ return {"imageUrl": image_url}
565
+
566
+
567
+ @router.get("/profile")
568
+ async def get_profile(
569
+ current_user: dict = Depends(get_current_user),
570
+ supabase: SupabaseService = Depends(get_supabase),
571
+ ):
572
+ """
573
+ Get the current user's profile, merging user and player table data.
574
+ Auto-creates player record if missing.
575
+ """
576
+ try:
577
+ user = await supabase.select_one("users", current_user["id"])
578
+ if not user:
579
+ # Fallback to current_user info if no record in users table yet
580
+ user = {
581
+ "id": current_user["id"],
582
+ "email": current_user["email"],
583
+ "full_name": current_user.get("full_name", "Athlete")
584
+ }
585
+
586
+ # Ensure full_name is present (might be fullName in some DB views)
587
+ if isinstance(user, dict) and "fullName" in user and "full_name" not in user:
588
+ user["full_name"] = user["fullName"]
589
+
590
+ players = await supabase.select("players", filters={"user_id": current_user["id"]})
591
+ player = players[0] if players else None
592
+
593
+ if not player:
594
+ # Auto-create basic player record if missing
595
+ new_player = {
596
+ "user_id": current_user["id"],
597
+ "name": user.get("full_name") or user.get("fullName") or "New Athlete",
598
+ "email": user.get("email"),
599
+ "status": "active",
600
+ "created_at": datetime.utcnow().isoformat()
601
+ }
602
+ try:
603
+ player = await supabase.insert("players", new_player)
604
+ except Exception as pe:
605
+ print(f"Failed to auto-create player record: {pe}")
606
+
607
+ return {"user": user, "player": player}
608
+ except Exception as e:
609
+ import traceback
610
+ print(f"Error in get_profile: {e}")
611
+ print(traceback.format_exc())
612
+ return {"user": None, "player": None}
613
+
614
+ @router.put("/profile")
615
+ async def update_profile(
616
+ profile_data: dict,
617
+ current_user: dict = Depends(get_current_user),
618
+ supabase: SupabaseService = Depends(get_supabase),
619
+ ):
620
+ """
621
+ Update the current user's profile (both users table and players table).
622
+ Accepts { user: {...}, player: {...} } structure.
623
+ """
624
+ import traceback
625
+ updated_user = None
626
+ updated_player = None
627
+ errors = []
628
+
629
+ if "user" in profile_data and profile_data["user"]:
630
+ # Strip read-only / primary-key fields before sending to Supabase
631
+ user_payload = {
632
+ k: v for k, v in profile_data["user"].items()
633
+ if k not in ("id", "created_at", "account_type")
634
+ }
635
+ user_payload["updated_at"] = datetime.utcnow().isoformat()
636
+ try:
637
+ updated_user = await supabase.update("users", current_user["id"], user_payload)
638
+ print(f"[profile] users updated OK for {current_user['id']}: {list(user_payload.keys())}")
639
+ except Exception as e:
640
+ err_msg = f"users update failed: {e}"
641
+ print(f"[profile] ERROR {err_msg}")
642
+ print(traceback.format_exc())
643
+ errors.append(err_msg)
644
+
645
+ if "player" in profile_data and profile_data["player"]:
646
+ player_payload = {
647
+ k: v for k, v in profile_data["player"].items()
648
+ if k not in ("id", "created_at", "user_id", "organization_id")
649
+ }
650
+ player_payload["updated_at"] = datetime.utcnow().isoformat()
651
+ try:
652
+ players = await supabase.select("players", filters={"user_id": current_user["id"]})
653
+ if players:
654
+ updated_player = await supabase.update("players", players[0]["id"], player_payload)
655
+ print(f"[profile] players updated OK for player {players[0]['id']}: {list(player_payload.keys())}")
656
+ else:
657
+ # Auto-create if missing
658
+ new_player = {
659
+ "id": str(uuid4()),
660
+ "user_id": current_user["id"],
661
+ "status": "active",
662
+ "created_at": datetime.utcnow().isoformat(),
663
+ **player_payload
664
+ }
665
+ updated_player = await supabase.insert("players", new_player)
666
+ print(f"[profile] player auto-created for user {current_user['id']}")
667
+ except Exception as e:
668
+ err_msg = f"players update failed: {e}"
669
+ print(f"[profile] ERROR {err_msg}")
670
+ print(traceback.format_exc())
671
+ errors.append(err_msg)
672
+
673
+ if errors:
674
+ return JSONResponse(
675
+ status_code=207,
676
+ content={"message": "Partial update", "errors": errors}
677
+ )
678
+
679
+ return {"message": "Profile updated successfully"}
app/api/players.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Player management API endpoints.
3
+ """
4
+ from uuid import uuid4
5
+ from datetime import datetime
6
+ from typing import Optional
7
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
8
+
9
+ from app.dependencies import get_current_user, get_supabase
10
+ from app.models.user import AccountType
11
+ from app.models.player import (
12
+ PlayerCreate,
13
+ PlayerUpdate,
14
+ Player,
15
+ PlayerWithStats,
16
+ PlayerListResponse,
17
+ )
18
+ from app.services.supabase_client import SupabaseService
19
+
20
+
21
+ router = APIRouter()
22
+
23
+
24
+ @router.post("", response_model=Player, status_code=status.HTTP_201_CREATED)
25
+ async def create_player(
26
+ player_data: PlayerCreate,
27
+ current_user: dict = Depends(get_current_user),
28
+ supabase: SupabaseService = Depends(get_supabase),
29
+ ):
30
+ """
31
+ Create a new player profile.
32
+
33
+ - **TEAM accounts**: Must provide organization_id
34
+ - **PERSONAL accounts**: Player is linked to user account
35
+ """
36
+ player_id = str(uuid4())
37
+
38
+ player_record = {
39
+ "id": player_id,
40
+ "name": player_data.name,
41
+ "jersey_number": player_data.jersey_number,
42
+ "position": player_data.position,
43
+ "height_cm": player_data.height_cm,
44
+ "weight_kg": player_data.weight_kg,
45
+ "date_of_birth": str(player_data.date_of_birth) if player_data.date_of_birth else None,
46
+ }
47
+
48
+ # Handle based on account type
49
+ if current_user.get("account_type") == AccountType.TEAM.value:
50
+ if not player_data.organization_id:
51
+ raise HTTPException(
52
+ status_code=status.HTTP_400_BAD_REQUEST,
53
+ detail="organization_id is required for TEAM accounts"
54
+ )
55
+
56
+ # Verify org ownership
57
+ org = await supabase.select_one("organizations", str(player_data.organization_id))
58
+ if not org or org["owner_id"] != current_user["id"]:
59
+ raise HTTPException(
60
+ status_code=status.HTTP_403_FORBIDDEN,
61
+ detail="You don't have access to this organization"
62
+ )
63
+
64
+ player_record["organization_id"] = str(player_data.organization_id)
65
+ else:
66
+ # PERSONAL account - link to user
67
+ player_record["user_id"] = current_user["id"]
68
+
69
+ await supabase.insert("players", player_record)
70
+
71
+ return Player(**player_record, created_at=datetime.utcnow())
72
+
73
+
74
+ @router.get("", response_model=PlayerListResponse)
75
+ async def list_players(
76
+ organization_id: Optional[str] = Query(None),
77
+ current_user: dict = Depends(get_current_user),
78
+ supabase: SupabaseService = Depends(get_supabase),
79
+ ):
80
+ """
81
+ List players.
82
+
83
+ - **TEAM accounts**: Filter by organization_id
84
+ - **PERSONAL accounts**: Returns the user's player profile
85
+ """
86
+ if current_user.get("account_type") == AccountType.TEAM.value:
87
+ if not organization_id:
88
+ raise HTTPException(
89
+ status_code=status.HTTP_400_BAD_REQUEST,
90
+ detail="organization_id query parameter is required for TEAM accounts"
91
+ )
92
+
93
+ # Verify org ownership
94
+ org = await supabase.select_one("organizations", organization_id)
95
+ if not org or org["owner_id"] != current_user["id"]:
96
+ raise HTTPException(
97
+ status_code=status.HTTP_403_FORBIDDEN,
98
+ detail="You don't have access to this organization"
99
+ )
100
+
101
+ players = await supabase.select(
102
+ "players",
103
+ filters={"organization_id": organization_id},
104
+ order_by="name",
105
+ )
106
+ else:
107
+ # PERSONAL account
108
+ players = await supabase.select(
109
+ "players",
110
+ filters={"user_id": current_user["id"]},
111
+ )
112
+
113
+ return PlayerListResponse(
114
+ players=[Player(**p) for p in players],
115
+ total=len(players),
116
+ )
117
+
118
+
119
+ @router.get("/{player_id}", response_model=PlayerWithStats)
120
+ async def get_player(
121
+ player_id: str,
122
+ current_user: dict = Depends(get_current_user),
123
+ supabase: SupabaseService = Depends(get_supabase),
124
+ ):
125
+ """
126
+ Get player details with statistics.
127
+ """
128
+ player = await supabase.select_one("players", player_id)
129
+
130
+ if not player:
131
+ raise HTTPException(
132
+ status_code=status.HTTP_404_NOT_FOUND,
133
+ detail="Player not found"
134
+ )
135
+
136
+ # Verify access
137
+ has_access = False
138
+ if current_user.get("account_type") == AccountType.TEAM.value:
139
+ if player.get("organization_id"):
140
+ org = await supabase.select_one("organizations", str(player["organization_id"]))
141
+ has_access = org and str(org.get("owner_id")) == str(current_user["id"])
142
+ elif current_user.get("account_type") == AccountType.COACH.value:
143
+ player_org = player.get("organization_id")
144
+ coach_org = current_user.get("organization_id")
145
+ has_access = player_org and coach_org and str(player_org) == str(coach_org)
146
+ else:
147
+ has_access = player.get("user_id") == current_user["id"]
148
+
149
+ if not has_access:
150
+ raise HTTPException(
151
+ status_code=status.HTTP_403_FORBIDDEN,
152
+ detail="You don't have access to this player"
153
+ )
154
+
155
+ # Data Fallback: If this is an organization-linked record, try to fill missing fields from personal profile
156
+ if player.get("organization_id") and player.get("user_id"):
157
+ try:
158
+ # Get all profiles for this user
159
+ all_user_profiles = await supabase.select("players", filters={"user_id": str(player["user_id"])})
160
+ # Find the personal one (no organization_id)
161
+ personal = next((p for p in all_user_profiles if not p.get("organization_id")), None)
162
+
163
+ if personal:
164
+ # Fields to potentially fallback to
165
+ fallback_fields = [
166
+ "jersey_number", "position", "height_cm", "weight_kg",
167
+ "date_of_birth", "avatar_url", "phone", "address",
168
+ "experience_years", "bio", "status"
169
+ ]
170
+ for field in fallback_fields:
171
+ # Use personal data if team-specific data is missing or empty
172
+ val = player.get(field)
173
+ pers_val = personal.get(field)
174
+ if (val is None or val == "") and (pers_val is not None and pers_val != ""):
175
+ player[field] = pers_val
176
+ except Exception as e:
177
+ print(f"Warning: Failed to fetch personal profile fallback for player {player_id}: {e}")
178
+
179
+ # Ensure ppg exists and is a float
180
+ p_ppg = player.get("ppg")
181
+ player["ppg"] = float(p_ppg) if p_ppg is not None and p_ppg != "" else 0.0
182
+
183
+ # Coerce string fields that might have been stored as numbers in the DB
184
+ if player.get("experience_years") is not None:
185
+ player["experience_years"] = str(player["experience_years"])
186
+
187
+ # Fetch email if linked to a user
188
+ email = None
189
+ if player.get("user_id"):
190
+ user = await supabase.select_one("users", str(player["user_id"]))
191
+ if user:
192
+ email = user.get("email")
193
+
194
+ # Get stats from analytics (video-based tracked metrics)
195
+ analytics_data = await supabase.select("analytics", filters={"player_id": player_id})
196
+
197
+ video_dist = sum(a.get("value", 0) for a in analytics_data if a.get("metric_type") == "distance_km")
198
+ speed_values = [a.get("value", 0) for a in analytics_data if a.get("metric_type") == "avg_speed_kmh"]
199
+ avg_speed = sum(speed_values) / len(speed_values) if speed_values else None
200
+
201
+ # Calculate unique videos from tracking
202
+ tracking_video_ids = set(a.get("video_id") for a in analytics_data if a.get("video_id"))
203
+
204
+ # NEW: Fetch official match stats (linked from box score uploads)
205
+ match_stats = await supabase.select("match_player_stats", filters={"player_profile_id": player_id})
206
+
207
+ total_pts = sum(s.get("pts", 0) for s in match_stats)
208
+ total_matches = len(match_stats)
209
+
210
+ # Calculate PPG based on official match stats if available, otherwise fallback to existing
211
+ if total_matches > 0:
212
+ player["ppg"] = round(total_pts / total_matches, 1)
213
+ # We can also add more aggregated stats to the model if needed in the future
214
+
215
+ # Combine unique videos from both sources
216
+ match_video_ids = set(s.get("match_id") for s in match_stats if s.get("match_id"))
217
+ total_unique_videos = len(tracking_video_ids.union(match_video_ids))
218
+
219
+ # Build the final response dict
220
+ player["email"] = email
221
+ player["total_videos"] = total_unique_videos
222
+ player["total_training_minutes"] = total_unique_videos * 40.0 # Estimate 40m per game/video
223
+ player["total_distance_km"] = video_dist if video_dist > 0 else None
224
+ player["avg_speed_kmh"] = avg_speed
225
+
226
+ return PlayerWithStats(**player)
227
+
228
+
229
+ @router.put("/{player_id}", response_model=Player)
230
+ async def update_player(
231
+ player_id: str,
232
+ update_data: PlayerUpdate,
233
+ current_user: dict = Depends(get_current_user),
234
+ supabase: SupabaseService = Depends(get_supabase),
235
+ ):
236
+ """
237
+ Update player profile.
238
+ """
239
+ player = await supabase.select_one("players", player_id)
240
+
241
+ if not player:
242
+ raise HTTPException(
243
+ status_code=status.HTTP_404_NOT_FOUND,
244
+ detail="Player not found"
245
+ )
246
+
247
+ # Verify access
248
+ has_access = False
249
+ if current_user.get("account_type") == AccountType.TEAM.value:
250
+ if player.get("organization_id"):
251
+ org = await supabase.select_one("organizations", player["organization_id"])
252
+ has_access = org and org["owner_id"] == current_user["id"]
253
+ elif current_user.get("account_type") == AccountType.COACH.value:
254
+ player_org = player.get("organization_id")
255
+ coach_org = current_user.get("organization_id")
256
+ has_access = player_org and coach_org and str(player_org) == str(coach_org)
257
+ else:
258
+ has_access = player.get("user_id") == current_user["id"]
259
+
260
+ if not has_access:
261
+ raise HTTPException(
262
+ status_code=status.HTTP_403_FORBIDDEN,
263
+ detail="You don't have permission to update this player"
264
+ )
265
+
266
+ update_dict = update_data.model_dump(exclude_unset=True)
267
+
268
+ if "date_of_birth" in update_dict and update_dict["date_of_birth"]:
269
+ update_dict["date_of_birth"] = str(update_dict["date_of_birth"])
270
+
271
+ if not update_dict:
272
+ raise HTTPException(
273
+ status_code=status.HTTP_400_BAD_REQUEST,
274
+ detail="No fields to update"
275
+ )
276
+
277
+ updated = await supabase.update("players", player_id, update_dict)
278
+
279
+ return Player(**updated)
app/api/stat_import.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Form, UploadFile, File, Depends, HTTPException
2
+ from typing import Optional
3
+ from uuid import UUID
4
+ from datetime import date
5
+
6
+ from app.dependencies import require_linked_account
7
+ from app.stat_import.schemas import StatImportJobResponse, ImportReviewSchema, FinalizeImportRequest
8
+
9
+ router = APIRouter()
10
+
11
+ @router.post("/upload", response_model=StatImportJobResponse, summary="Upload a stat sheet (PDF/Image)")
12
+ async def upload_stat_sheet(
13
+ team_id: UUID = Form(...),
14
+ game_date: Optional[date] = Form(None),
15
+ opponent_name: Optional[str] = Form(None),
16
+ notes: Optional[str] = Form(None),
17
+ file: UploadFile = File(...),
18
+ account_info: dict = Depends(require_linked_account)
19
+ ):
20
+ """
21
+ Coach uploads a PDF/image stat sheet.
22
+ System stores it in Supabase Storage and creates an import job.
23
+ """
24
+ # TODO: Validate user is in team `team_id`
25
+ # TODO: Push to supabase storage
26
+ # TODO: Insert to `stat_import_jobs` table
27
+ raise HTTPException(status_code=501, detail="Not Implemented")
28
+
29
+
30
+ @router.post("/process/{import_job_id}", summary="Process an uploaded stat sheet")
31
+ async def process_stat_sheet(import_job_id: UUID, account_info: dict = Depends(require_linked_account)):
32
+ """
33
+ Trigger the asynchronous or synchronous OCR parsing pipeline for this upload.
34
+ Classification -> Extraction -> Parsing -> Matching -> Validation.
35
+ """
36
+ # TODO: Service layer to run the layout parser / paddleOCR logic
37
+ raise HTTPException(status_code=501, detail="Not Implemented")
38
+
39
+
40
+ @router.get("/{import_job_id}", summary="Get import job details")
41
+ async def get_import_job(import_job_id: UUID, account_info: dict = Depends(require_linked_account)):
42
+ """
43
+ Fetches the result of the pipeline: parsed data, status, etc.
44
+ """
45
+ raise HTTPException(status_code=501, detail="Not Implemented")
46
+
47
+
48
+ @router.post("/{import_job_id}/confirm", summary="Acknowledge overrides on review flow")
49
+ async def confirm_review_edits(import_job_id: UUID, account_info: dict = Depends(require_linked_account)):
50
+ """
51
+ Called periodically or as checkpoints when resolving unmatched players manually.
52
+ """
53
+ raise HTTPException(status_code=501, detail="Not Implemented")
54
+
55
+
56
+ @router.get("/{import_job_id}/issues", response_model=ImportReviewSchema, summary="Get extraction or validation issues")
57
+ async def get_import_issues(import_job_id: UUID, account_info: dict = Depends(require_linked_account)):
58
+ """
59
+ Return all validation and matching errors flagged during processing.
60
+ """
61
+ raise HTTPException(status_code=501, detail="Not Implemented")
62
+
63
+
64
+ @router.post("/{import_job_id}/finalize", summary="Commit the checked stat sheet to the database")
65
+ async def finalize_stat_import(
66
+ import_job_id: UUID,
67
+ payload: FinalizeImportRequest,
68
+ account_info: dict = Depends(require_linked_account)
69
+ ):
70
+ """
71
+ Saves final JSON mapped data into games, game_player_stats, game_team_totals tables.
72
+ Also triggers analytics event generation.
73
+ """
74
+ # TODO: Check if ALL blockers are resolved
75
+ # TODO: Open transaction and dump to relational schema
76
+ # TODO: Mark import job as finalized
77
+ raise HTTPException(status_code=501, detail="Not Implemented")
app/api/teams.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Team management API endpoints (TEAM accounts only).
3
+ """
4
+ from uuid import uuid4
5
+ from datetime import datetime
6
+ from typing import Optional
7
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
8
+
9
+ from app.dependencies import require_team_account, get_supabase
10
+ from app.models.team import (
11
+ OrganizationCreate,
12
+ OrganizationUpdate,
13
+ Organization,
14
+ OrganizationWithStats,
15
+ OrganizationListResponse,
16
+ )
17
+ from app.services.supabase_client import SupabaseService
18
+
19
+
20
+ router = APIRouter()
21
+
22
+
23
+ @router.post("", response_model=Organization, status_code=status.HTTP_201_CREATED)
24
+ async def create_organization(
25
+ org_data: OrganizationCreate,
26
+ current_user: dict = Depends(require_team_account),
27
+ supabase: SupabaseService = Depends(get_supabase),
28
+ ):
29
+ """
30
+ Create a new organization (team).
31
+
32
+ **Requires TEAM account.**
33
+ """
34
+ org_id = str(uuid4())
35
+
36
+ org_record = {
37
+ "id": org_id,
38
+ "name": org_data.name,
39
+ "description": org_data.description,
40
+ "logo_url": org_data.logo_url,
41
+ "owner_id": current_user["id"],
42
+ }
43
+
44
+ await supabase.insert("organizations", org_record)
45
+
46
+ return Organization(**org_record, created_at=datetime.utcnow())
47
+
48
+
49
+ @router.get("", response_model=OrganizationListResponse)
50
+ async def list_organizations(
51
+ current_user: dict = Depends(require_team_account),
52
+ supabase: SupabaseService = Depends(get_supabase),
53
+ ):
54
+ """
55
+ List organizations owned by the current user.
56
+
57
+ **Requires TEAM account.**
58
+ """
59
+ orgs = await supabase.select(
60
+ "organizations",
61
+ filters={"owner_id": current_user["id"]},
62
+ order_by="created_at",
63
+ ascending=False,
64
+ )
65
+
66
+ return OrganizationListResponse(
67
+ organizations=[Organization(**o) for o in orgs],
68
+ total=len(orgs),
69
+ )
70
+
71
+
72
+ @router.get("/{org_id}", response_model=OrganizationWithStats)
73
+ async def get_organization(
74
+ org_id: str,
75
+ current_user: dict = Depends(require_team_account),
76
+ supabase: SupabaseService = Depends(get_supabase),
77
+ ):
78
+ """
79
+ Get organization details with statistics.
80
+
81
+ **Requires TEAM account.**
82
+ """
83
+ org = await supabase.select_one("organizations", org_id)
84
+
85
+ if not org:
86
+ raise HTTPException(
87
+ status_code=status.HTTP_404_NOT_FOUND,
88
+ detail="Organization not found"
89
+ )
90
+
91
+ if org["owner_id"] != current_user["id"]:
92
+ raise HTTPException(
93
+ status_code=status.HTTP_403_FORBIDDEN,
94
+ detail="You don't have access to this organization"
95
+ )
96
+
97
+ # Get stats
98
+ players = await supabase.select("players", filters={"organization_id": org_id})
99
+ videos = await supabase.select("videos", filters={"organization_id": org_id})
100
+
101
+ return OrganizationWithStats(
102
+ **org,
103
+ player_count=len(players),
104
+ video_count=len(videos),
105
+ total_analysis_count=len([v for v in videos if v.get("status") == "completed"]),
106
+ )
107
+
108
+
109
+ @router.put("/{org_id}", response_model=Organization)
110
+ async def update_organization(
111
+ org_id: str,
112
+ update_data: OrganizationUpdate,
113
+ current_user: dict = Depends(require_team_account),
114
+ supabase: SupabaseService = Depends(get_supabase),
115
+ ):
116
+ """
117
+ Update organization details.
118
+
119
+ **Requires TEAM account.**
120
+ """
121
+ org = await supabase.select_one("organizations", org_id)
122
+
123
+ if not org:
124
+ raise HTTPException(
125
+ status_code=status.HTTP_404_NOT_FOUND,
126
+ detail="Organization not found"
127
+ )
128
+
129
+ if org["owner_id"] != current_user["id"]:
130
+ raise HTTPException(
131
+ status_code=status.HTTP_403_FORBIDDEN,
132
+ detail="You don't have permission to update this organization"
133
+ )
134
+
135
+ update_dict = update_data.model_dump(exclude_unset=True)
136
+
137
+ if not update_dict:
138
+ raise HTTPException(
139
+ status_code=status.HTTP_400_BAD_REQUEST,
140
+ detail="No fields to update"
141
+ )
142
+
143
+ updated = await supabase.update("organizations", org_id, update_dict)
144
+
145
+ return Organization(**updated)
146
+
147
+
148
+ @router.delete("/{org_id}", status_code=status.HTTP_204_NO_CONTENT)
149
+ async def delete_organization(
150
+ org_id: str,
151
+ current_user: dict = Depends(require_team_account),
152
+ supabase: SupabaseService = Depends(get_supabase),
153
+ ):
154
+ """
155
+ Delete an organization and all associated data.
156
+
157
+ **Requires TEAM account.**
158
+ """
159
+ org = await supabase.select_one("organizations", org_id)
160
+
161
+ if not org:
162
+ raise HTTPException(
163
+ status_code=status.HTTP_404_NOT_FOUND,
164
+ detail="Organization not found"
165
+ )
166
+
167
+ if org["owner_id"] != current_user["id"]:
168
+ raise HTTPException(
169
+ status_code=status.HTTP_403_FORBIDDEN,
170
+ detail="You don't have permission to delete this organization"
171
+ )
172
+
173
+ # Best-effort cascade delete associated data
174
+ videos = await supabase.select("videos", filters={"organization_id": org_id})
175
+ video_ids = [v.get("id") for v in videos if v.get("id")]
176
+
177
+ # Delete analysis/detections/analytics linked to videos
178
+ for vid in video_ids:
179
+ try:
180
+ await supabase.delete_where("analysis_results", {"video_id": vid})
181
+ except Exception:
182
+ pass
183
+ try:
184
+ await supabase.delete_where("detections", {"video_id": vid})
185
+ except Exception:
186
+ pass
187
+ try:
188
+ await supabase.delete_where("analytics", {"video_id": vid})
189
+ except Exception:
190
+ pass
191
+ try:
192
+ await supabase.delete("videos", vid)
193
+ except Exception:
194
+ pass
195
+
196
+ # Delete players for the org
197
+ players = await supabase.select("players", filters={"organization_id": org_id})
198
+ player_ids = [p.get("id") for p in players if p.get("id")]
199
+ for pid in player_ids:
200
+ try:
201
+ await supabase.delete_where("analytics", {"player_id": pid})
202
+ except Exception:
203
+ pass
204
+ try:
205
+ await supabase.delete("players", pid)
206
+ except Exception:
207
+ pass
208
+
209
+ await supabase.delete("organizations", org_id)
210
+
211
+ return None
app/api/videos.py ADDED
@@ -0,0 +1,614 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Video management API endpoints.
3
+ """
4
+ import os
5
+ import shutil
6
+ from uuid import uuid4
7
+ from datetime import datetime
8
+ from typing import Optional
9
+ from urllib.parse import quote
10
+
11
+ from fastapi import (
12
+ APIRouter,
13
+ Depends,
14
+ HTTPException,
15
+ Request,
16
+ UploadFile,
17
+ File,
18
+ Form,
19
+ Query,
20
+ status,
21
+ )
22
+ from fastapi.responses import FileResponse
23
+
24
+ from slowapi import Limiter
25
+ from slowapi.util import get_remote_address
26
+
27
+ from app.config import get_settings
28
+ from app.dependencies import get_current_user, get_supabase
29
+ from app.models.user import AccountType
30
+ from app.models.video import (
31
+ VideoUpload,
32
+ Video,
33
+ VideoStatus,
34
+ AnalysisMode,
35
+ VideoStatusResponse,
36
+ VideoListResponse,
37
+ )
38
+ from app.services.supabase_client import SupabaseService
39
+
40
+
41
+ router = APIRouter()
42
+
43
+ def _annotated_video_path(video_id: str) -> str:
44
+ # Produced by analysis pipelines (e.g. analysis/team_analysis.py)
45
+ return os.path.join("output_videos", "annotated", f"{video_id}.mp4")
46
+
47
+
48
+ def _get_limiter(request: Request) -> Limiter:
49
+ return request.app.state.limiter
50
+
51
+
52
+ def get_video_info(file_path: str) -> dict:
53
+ """Extract video metadata using OpenCV."""
54
+ try:
55
+ import cv2
56
+ cap = cv2.VideoCapture(file_path)
57
+ except ImportError:
58
+ print("⚠️ OpenCV not installed, skipping video metadata extraction")
59
+ return {
60
+ "fps": 30.0,
61
+ "frame_count": 0,
62
+ "width": 1920,
63
+ "height": 1080,
64
+ "duration_seconds": 0,
65
+ }
66
+
67
+ try:
68
+ if not cap.isOpened():
69
+ return {}
70
+
71
+ fps = cap.get(cv2.CAP_PROP_FPS)
72
+ frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
73
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
74
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
75
+
76
+ duration = frame_count / fps if fps > 0 else 0
77
+
78
+ cap.release()
79
+
80
+ return {
81
+ "fps": fps,
82
+ "frame_count": frame_count,
83
+ "width": width,
84
+ "height": height,
85
+ "duration_seconds": duration,
86
+ }
87
+ except Exception:
88
+ return {}
89
+ finally:
90
+ try:
91
+ cap.release()
92
+ except Exception:
93
+ pass
94
+
95
+
96
+ @router.post("/upload", response_model=Video, status_code=status.HTTP_201_CREATED)
97
+ async def upload_video(
98
+ request: Request,
99
+ file: UploadFile = File(...),
100
+ title: Optional[str] = Form(None, max_length=200),
101
+ description: Optional[str] = Form(None, max_length=1000),
102
+ analysis_mode: AnalysisMode = Form(...),
103
+ organization_id: Optional[str] = Form(None),
104
+ current_user: dict = Depends(get_current_user),
105
+ supabase: SupabaseService = Depends(get_supabase),
106
+ ):
107
+ """
108
+ Upload a video for analysis.
109
+
110
+ - **file**: Video file (mp4, avi, mov, mkv)
111
+ - **analysis_mode**: 'team' or 'personal'
112
+ - **organization_id**: Required for TEAM analysis
113
+ """
114
+ settings = get_settings()
115
+
116
+ # Apply a modest rate limit to uploads to avoid resource exhaustion.
117
+ limiter = _get_limiter(request)
118
+ limiter.limit("10/hour")(lambda request: None)(request)
119
+
120
+ # Validate file extension
121
+ if not file.filename:
122
+ raise HTTPException(
123
+ status_code=status.HTTP_400_BAD_REQUEST,
124
+ detail="Missing filename"
125
+ )
126
+
127
+ _, ext = os.path.splitext(file.filename)
128
+ ext = ext.lstrip(".").lower()
129
+ if ext not in settings.allowed_extensions_list:
130
+ raise HTTPException(
131
+ status_code=status.HTTP_400_BAD_REQUEST,
132
+ detail=f"Invalid file type. Allowed: {settings.allowed_video_extensions}"
133
+ )
134
+
135
+ # Basic content-type validation (defence-in-depth – still rely on OpenCV check later)
136
+ allowed_mime_types = {
137
+ "video/mp4",
138
+ "video/x-msvideo",
139
+ "video/quicktime",
140
+ "video/x-matroska",
141
+ }
142
+ if file.content_type and file.content_type.lower() not in allowed_mime_types:
143
+ raise HTTPException(
144
+ status_code=status.HTTP_400_BAD_REQUEST,
145
+ detail="Invalid content type for video upload",
146
+ )
147
+
148
+ # Validate file size
149
+ file.file.seek(0, 2) # Seek to end
150
+ file_size = file.file.tell()
151
+ file.file.seek(0) # Reset
152
+
153
+ if file_size > settings.max_upload_size_bytes:
154
+ raise HTTPException(
155
+ status_code=status.HTTP_400_BAD_REQUEST,
156
+ detail=f"File too large. Maximum: {settings.max_upload_size_mb}MB"
157
+ )
158
+
159
+ # Validate team analysis requirements
160
+ if analysis_mode == AnalysisMode.TEAM:
161
+ allowed_types = [AccountType.TEAM.value, AccountType.COACH.value]
162
+ if current_user.get("account_type") not in allowed_types:
163
+ raise HTTPException(
164
+ status_code=status.HTTP_403_FORBIDDEN,
165
+ detail="Team analysis requires a TEAM or COACH account"
166
+ )
167
+
168
+ # Determine the user's organization context
169
+ user_org_id = current_user.get("organization_id")
170
+ if user_org_id in ("null", "undefined", ""):
171
+ user_org_id = None
172
+
173
+ # If the user is a team manager but organization_id isn't in token (unlikely but safe check)
174
+ if not user_org_id and current_user.get("account_type") == AccountType.TEAM.value:
175
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
176
+ if orgs:
177
+ user_org_id = str(orgs[0]["id"])
178
+
179
+ if organization_id in ("null", "undefined", ""):
180
+ organization_id = None
181
+
182
+ if not organization_id:
183
+ organization_id = user_org_id
184
+
185
+ # Determine if we should allow null organization_id
186
+ # We allow it for individual coaches who aren't linked yet.
187
+ is_coach = current_user.get("account_type") == AccountType.COACH.value
188
+
189
+ if not organization_id and not is_coach:
190
+ raise HTTPException(
191
+ status_code=status.HTTP_400_BAD_REQUEST,
192
+ detail="organization_id is required and you are not linked to any organization"
193
+ )
194
+
195
+ # Verify org access if an organization_id is provided or found
196
+ if organization_id and str(organization_id) != str(user_org_id):
197
+ # For robustness, if it's a team account, double check org ownership if the ID doesn't match token
198
+ if current_user.get("account_type") == AccountType.TEAM.value:
199
+ org = await supabase.select_one("organizations", str(organization_id))
200
+ if not org or org.get("owner_id") != current_user["id"]:
201
+ raise HTTPException(status_code= status.HTTP_403_FORBIDDEN, detail="Access denied to this organization")
202
+ else:
203
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only upload to your linked organization")
204
+
205
+ # Generate unique filename and save. We never trust the original name for paths.
206
+ video_id = str(uuid4())
207
+ filename = f"{video_id}.{ext}"
208
+ # Ensure uploader id does not introduce path traversal
209
+ safe_uploader_id = str(current_user["id"]).replace("/", "_").replace("\\", "_")
210
+ storage_path = os.path.join(settings.upload_dir, safe_uploader_id, filename)
211
+
212
+ os.makedirs(os.path.dirname(storage_path), exist_ok=True)
213
+
214
+ with open(storage_path, "wb") as buffer:
215
+ shutil.copyfileobj(file.file, buffer)
216
+
217
+ # Get video metadata
218
+ video_info = get_video_info(storage_path)
219
+ if not video_info or not video_info.get("frame_count"):
220
+ # Reject unreadable / non-video uploads
221
+ try:
222
+ os.remove(storage_path)
223
+ except Exception:
224
+ pass
225
+ raise HTTPException(
226
+ status_code=status.HTTP_400_BAD_REQUEST,
227
+ detail="Uploaded file is not a readable video"
228
+ )
229
+
230
+ # Create database record
231
+ video_record = {
232
+ "id": video_id,
233
+ "uploader_id": current_user["id"],
234
+ "title": title or file.filename,
235
+ "description": description,
236
+ "analysis_mode": analysis_mode.value,
237
+ "status": VideoStatus.PENDING.value,
238
+ "storage_path": storage_path,
239
+ "file_size_bytes": file_size,
240
+ "organization_id": organization_id,
241
+ **video_info,
242
+ }
243
+
244
+ await supabase.insert("videos", video_record)
245
+
246
+ return Video(
247
+ **video_record,
248
+ created_at=datetime.utcnow(),
249
+ download_url=f"/api/videos/{video_id}/download",
250
+ annotated_download_url=f"/api/videos/{video_id}/annotated",
251
+ has_annotated=os.path.exists(_annotated_video_path(video_id)),
252
+ )
253
+
254
+
255
+ @router.get("", response_model=VideoListResponse)
256
+ async def list_videos(
257
+ page: int = Query(1, ge=1),
258
+ page_size: int = Query(20, ge=1, le=100),
259
+ status_filter: Optional[VideoStatus] = Query(None),
260
+ current_user: dict = Depends(get_current_user),
261
+ supabase: SupabaseService = Depends(get_supabase),
262
+ ):
263
+ """
264
+ List videos uploaded by the current user.
265
+ """
266
+ # If TEAM account, filter by organization_id by default if possible
267
+ filters = {}
268
+ or_filter = None
269
+ if current_user.get("account_type") == AccountType.TEAM.value:
270
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
271
+ if orgs:
272
+ filters["organization_id"] = orgs[0]["id"]
273
+ else:
274
+ filters["uploader_id"] = current_user["id"]
275
+ else:
276
+ # Player account
277
+ if current_user.get("organization_id"):
278
+ uid = current_user["id"]
279
+ oid = current_user["organization_id"]
280
+ or_filter = f"uploader_id.eq.{uid},and(organization_id.eq.{oid},analysis_mode.eq.team)"
281
+ else:
282
+ filters["uploader_id"] = current_user["id"]
283
+
284
+ if status_filter:
285
+ filters["status"] = status_filter.value
286
+
287
+ videos = await supabase.select(
288
+ "videos",
289
+ filters=filters,
290
+ order_by="created_at",
291
+ ascending=False,
292
+ or_filter=or_filter,
293
+ )
294
+
295
+ # Paginate
296
+ total = len(videos)
297
+ start = (page - 1) * page_size
298
+ end = start + page_size
299
+ paginated = videos[start:end]
300
+
301
+ return VideoListResponse(
302
+ videos=[
303
+ Video(
304
+ **{
305
+ **v,
306
+ "download_url": f"/api/videos/{v.get('id')}/download",
307
+ "annotated_download_url": f"/api/videos/{v.get('id')}/annotated",
308
+ "has_annotated": bool(v.get("annotated_url")) or os.path.exists(_annotated_video_path(str(v.get('id')))),
309
+ }
310
+ )
311
+ for v in paginated
312
+ ],
313
+ total=total,
314
+ page=page,
315
+ page_size=page_size,
316
+ )
317
+
318
+
319
+ @router.get("/{video_id}", response_model=Video)
320
+ async def get_video(
321
+ video_id: str,
322
+ current_user: dict = Depends(get_current_user),
323
+ supabase: SupabaseService = Depends(get_supabase),
324
+ ):
325
+ """
326
+ Get video details by ID.
327
+ """
328
+ video = await supabase.select_one("videos", video_id)
329
+
330
+ if not video:
331
+ raise HTTPException(
332
+ status_code=status.HTTP_404_NOT_FOUND,
333
+ detail="Video not found"
334
+ )
335
+
336
+ # Check ownership
337
+ is_owner = str(video["uploader_id"]) == str(current_user["id"])
338
+ is_org_member = False
339
+ if not is_owner and video.get("organization_id") and current_user.get("organization_id"):
340
+ if str(video["organization_id"]) == str(current_user["organization_id"]):
341
+ is_org_member = True
342
+
343
+ if not is_owner and not is_org_member:
344
+ raise HTTPException(
345
+ status_code=status.HTTP_403_FORBIDDEN,
346
+ detail="You don't have access to this video"
347
+ )
348
+
349
+ return Video(
350
+ **{
351
+ **video,
352
+ "download_url": f"/api/videos/{video_id}/download",
353
+ "annotated_download_url": f"/api/videos/{video_id}/annotated",
354
+ "has_annotated": bool(video.get("annotated_url")) or os.path.exists(_annotated_video_path(video_id)),
355
+ }
356
+ )
357
+
358
+
359
+ @router.get("/{video_id}/download")
360
+ async def download_video(
361
+ video_id: str,
362
+ current_user: dict = Depends(get_current_user),
363
+ supabase: SupabaseService = Depends(get_supabase),
364
+ ):
365
+ """
366
+ Download a previously uploaded video.
367
+ Authenticated and ownership-checked.
368
+ """
369
+ video = await supabase.select_one("videos", video_id)
370
+ if not video:
371
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Video not found")
372
+
373
+ if video.get("uploader_id") != current_user["id"]:
374
+ is_org_member = False
375
+ if video.get("organization_id") and current_user.get("organization_id"):
376
+ if str(video["organization_id"]) == str(current_user["organization_id"]):
377
+ is_org_member = True
378
+
379
+ # Also check if it's the TEAM owner
380
+ if current_user.get("account_type") == AccountType.TEAM.value:
381
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
382
+ if orgs and str(video.get("organization_id")) == str(orgs[0]["id"]):
383
+ is_org_member = True
384
+
385
+ if not is_org_member:
386
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this video")
387
+
388
+ storage_path = video.get("storage_path")
389
+ if not storage_path or not os.path.exists(storage_path):
390
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Video file not found on server")
391
+
392
+ # Prefer the original filename for download if available
393
+ original_title = (video.get("title") or f"{video_id}").strip()
394
+ safe_name = quote(original_title.replace("/", "_").replace("\\", "_"))
395
+ _, ext = os.path.splitext(storage_path)
396
+ ext = ext if ext else ".mp4"
397
+
398
+ # Guess media type based on extension
399
+ media_types = {
400
+ ".mp4": "video/mp4",
401
+ ".webm": "video/webm",
402
+ ".avi": "video/x-msvideo",
403
+ ".mov": "video/quicktime",
404
+ ".mkv": "video/x-matroska",
405
+ }
406
+ media_type = media_types.get(ext.lower(), "video/mp4")
407
+
408
+ return FileResponse(
409
+ path=storage_path,
410
+ filename=f"{safe_name}{ext}",
411
+ media_type=media_type,
412
+ content_disposition_type="inline"
413
+ )
414
+
415
+
416
+ @router.get("/{video_id}/annotated")
417
+ async def download_annotated_video(
418
+ video_id: str,
419
+ current_user: dict = Depends(get_current_user),
420
+ supabase: SupabaseService = Depends(get_supabase),
421
+ ):
422
+ """
423
+ Download the annotated output video (if available).
424
+ Redirects to Supabase signed URL when stored in cloud, otherwise streams from disk.
425
+ Ownership-checked via the uploaded video record.
426
+ """
427
+ from fastapi.responses import RedirectResponse
428
+
429
+ video = await supabase.select_one("videos", video_id)
430
+ if not video:
431
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Video not found")
432
+ if video.get("uploader_id") != current_user["id"]:
433
+ is_org_member = False
434
+ if video.get("organization_id") and current_user.get("organization_id"):
435
+ if str(video["organization_id"]) == str(current_user["organization_id"]):
436
+ is_org_member = True
437
+
438
+ # Also check if it's the TEAM owner
439
+ if current_user.get("account_type") == AccountType.TEAM.value:
440
+ orgs = await supabase.select("organizations", filters={"owner_id": current_user["id"]})
441
+ if orgs and str(video.get("organization_id")) == str(orgs[0]["id"]):
442
+ is_org_member = True
443
+
444
+ if not is_org_member:
445
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this video")
446
+
447
+ # Prefer Supabase signed URL (cloud storage) — redirect the client directly
448
+ annotated_url = video.get("annotated_url")
449
+ if annotated_url and annotated_url.startswith("https"):
450
+ return RedirectResponse(url=annotated_url, status_code=302)
451
+
452
+ # Fallback: serve from local disk
453
+ annotated_path = _annotated_video_path(video_id)
454
+ if not os.path.exists(annotated_path):
455
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Annotated video not available yet")
456
+
457
+ original_title = (video.get("title") or f"{video_id}").strip()
458
+ safe_name = quote(original_title.replace("/", "_").replace("\\", "_"))
459
+ return FileResponse(
460
+ path=annotated_path,
461
+ filename=f"{safe_name}-annotated.mp4",
462
+ media_type="video/mp4",
463
+ content_disposition_type="inline"
464
+ )
465
+
466
+
467
+ @router.get("/{video_id}/status", response_model=VideoStatusResponse)
468
+ async def get_video_status(
469
+ video_id: str,
470
+ current_user: dict = Depends(get_current_user),
471
+ supabase: SupabaseService = Depends(get_supabase),
472
+ ):
473
+ """
474
+ Get video processing status with robust error handling for network issues.
475
+ """
476
+ try:
477
+ video = await supabase.select_one("videos", video_id)
478
+
479
+ if not video:
480
+ raise HTTPException(
481
+ status_code=status.HTTP_404_NOT_FOUND,
482
+ detail="Video not found"
483
+ )
484
+
485
+ # Security: Only owner or team members can see status
486
+ is_owner = str(video["uploader_id"]) == str(current_user["id"])
487
+
488
+ # Check org access if uploader is different
489
+ is_org_member = False
490
+ if not is_owner and video.get("organization_id") and current_user.get("organization_id"):
491
+ if str(video["organization_id"]) == str(current_user["organization_id"]):
492
+ is_org_member = True
493
+
494
+ if not is_owner and not is_org_member:
495
+ raise HTTPException(
496
+ status_code=status.HTTP_403_FORBIDDEN,
497
+ detail="You don't have access to this video status"
498
+ )
499
+
500
+ # HEAL: If status is failed but an analysis result was actually written,
501
+ # then the process likely finished but the final status update was interrupted.
502
+ if video["status"] == VideoStatus.FAILED.value:
503
+ try:
504
+ results = await supabase.select("analysis_results", filters={"video_id": video_id}, limit=1)
505
+ if results:
506
+ video["status"] = VideoStatus.COMPLETED.value
507
+ video["progress_percent"] = 100
508
+ video["current_step"] = "Complete (Auto-Recovered)"
509
+ # Apply heal to DB so it doesn't repeat
510
+ await supabase.update("videos", video_id, {
511
+ "status": VideoStatus.COMPLETED.value,
512
+ "progress_percent": 100,
513
+ "current_step": "Complete (Auto-Recovered)"
514
+ })
515
+ except Exception as e:
516
+ print(f"⚠️ Auto-heal check failed for video {video_id}: {e}")
517
+
518
+ return VideoStatusResponse(
519
+ id=video["id"],
520
+ status=VideoStatus(video["status"]),
521
+ progress_percent=video.get("progress_percent") or 0,
522
+ current_step=video.get("current_step") or "Initializing",
523
+ error_message=video.get("error_message"),
524
+ )
525
+ except HTTPException:
526
+ raise
527
+ except Exception as e:
528
+ print(f"❌ Error fetching video status for {video_id}: {e}")
529
+ # Return 503 instead of 500 for network/database connection errors
530
+ raise HTTPException(
531
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
532
+ detail="Temporary database connection error. Please try again in a few moments."
533
+ )
534
+
535
+
536
+ @router.delete("/{video_id}", status_code=status.HTTP_204_NO_CONTENT)
537
+ async def delete_video(
538
+ video_id: str,
539
+ current_user: dict = Depends(get_current_user),
540
+ supabase: SupabaseService = Depends(get_supabase),
541
+ ):
542
+ """
543
+ Delete a video and associated data.
544
+ Allows uploader, team owner, or coach (if in same org) to delete.
545
+ """
546
+ video = await supabase.select_one("videos", video_id)
547
+
548
+ if not video:
549
+ raise HTTPException(
550
+ status_code=status.HTTP_404_NOT_FOUND,
551
+ detail="Video not found"
552
+ )
553
+
554
+ # Permission Check: Uploader can always delete
555
+ is_owner = str(video["uploader_id"]) == str(current_user["id"])
556
+
557
+ # Organization Check: Team Owner or Coach can delete team videos
558
+ is_org_authorized = False
559
+ if video.get("organization_id") and current_user.get("organization_id"):
560
+ if str(video["organization_id"]) == str(current_user["organization_id"]):
561
+ if current_user.get("account_type") in [AccountType.TEAM.value, AccountType.COACH.value]:
562
+ is_org_authorized = True
563
+
564
+ if not is_owner and not is_org_authorized:
565
+ raise HTTPException(
566
+ status_code=status.HTTP_403_FORBIDDEN,
567
+ detail="You don't have permission to delete this video"
568
+ )
569
+
570
+ # 1. Delete original file
571
+ if video.get("storage_path") and os.path.exists(video["storage_path"]):
572
+ try:
573
+ os.remove(video["storage_path"])
574
+ except Exception as e:
575
+ print(f"Error removing original video: {e}")
576
+
577
+ # 2. Delete annotated video (if any)
578
+ annotated_path = _annotated_video_path(video_id)
579
+ if os.path.exists(annotated_path):
580
+ try:
581
+ os.remove(annotated_path)
582
+ except Exception as e:
583
+ print(f"Error removing annotated video: {e}")
584
+
585
+ # 3. Clean up personal analysis outputs (if any)
586
+ # Personal analysis uses a different naming convention: {video_id}_output.mp4 etc.
587
+ from app.api.personal_analysis import PERSONAL_OUTPUT_DIR
588
+ for suffix in ["_output.mp4", "_output.avi", "_report.txt", "_input.mp4", "_input.avi"]:
589
+ personal_path = os.path.join(PERSONAL_OUTPUT_DIR, f"{video_id}{suffix}")
590
+ if os.path.exists(personal_path):
591
+ try:
592
+ os.remove(personal_path)
593
+ except Exception:
594
+ pass
595
+
596
+ # 4. Best-effort cascade delete related rows
597
+ for table in ["analysis_results", "detections", "analytics", "personal_analyses", "clips"]:
598
+ try:
599
+ await supabase.delete_where(table, {"video_id": video_id})
600
+ except Exception:
601
+ # Fallback for tables that might use different columns or not exist
602
+ try:
603
+ if table == "personal_analyses":
604
+ await supabase.delete_where(table, {"job_id": video_id})
605
+ elif table == "clips":
606
+ # Advanced analytics clips usually linked via video_id
607
+ await supabase.delete_where(table, {"video_id": video_id})
608
+ except Exception:
609
+ pass
610
+
611
+ # 5. Delete database record
612
+ await supabase.delete("videos", video_id)
613
+
614
+ return None
app/config.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration settings for the Basketball Analysis API.
3
+
4
+ Uses Pydantic Settings for environment variable management with type validation.
5
+ """
6
+ from functools import lru_cache
7
+ from typing import Optional
8
+ from pydantic_settings import BaseSettings, SettingsConfigDict
9
+
10
+
11
+ class Settings(BaseSettings):
12
+ """Application configuration loaded from environment variables."""
13
+
14
+ model_config = SettingsConfigDict(
15
+ env_file=".env",
16
+ env_file_encoding="utf-8",
17
+ case_sensitive=False,
18
+ extra="ignore"
19
+ )
20
+
21
+ # Application settings
22
+ app_name: str = "Basketball Analysis API"
23
+ app_version: str = "1.0.0"
24
+ debug: bool = False
25
+ default_rate_limit: str = "100 per minute"
26
+
27
+ # Server settings
28
+ host: str = "0.0.0.0"
29
+ port: int = 8000
30
+ request_timeout_seconds: int = 120
31
+
32
+ # Supabase configuration
33
+ supabase_url: Optional[str] = None
34
+ supabase_key: Optional[str] = None
35
+ supabase_service_key: Optional[str] = None
36
+
37
+ # JWT settings
38
+ jwt_secret: str = "your-super-secret-key-change-in-production"
39
+ jwt_algorithm: str = "HS256"
40
+ jwt_expiration_minutes: int = 60 * 24 # 24 hours
41
+
42
+ # CORS (comma-separated list). Example: "http://localhost:5173,https://yourapp.com"
43
+ cors_origins: str = ""
44
+
45
+ # File storage
46
+ upload_dir: str = "./uploads"
47
+ max_upload_size_mb: int = 500
48
+ allowed_video_extensions: str = "mp4,avi,mov,mkv"
49
+ serve_uploads_in_debug: bool = True
50
+
51
+ # GPU settings
52
+ gpu_enabled: bool = True
53
+ cuda_device: int = 0
54
+
55
+ # Model paths (relative to backend root)
56
+ team_model_path: str = "models/player_detector.pt"
57
+ personal_model_path: str = "models/player_detector.pt"
58
+
59
+ # Independent detector models trained on NBL dataset
60
+ player_detector_path: str = "models/player_detector.pt"
61
+ ball_detector_path: str = "models/ball_detector.pt"
62
+ court_keypoint_detector_path: str = "models/court_keypoint_detector.pt"
63
+ pose_model_path: str = "models/yolov8n-pose.pt"
64
+
65
+ # Swish-Vision specific models
66
+ swish_ball_rim_model: str = "models/swish_ball_rim.pt"
67
+ swish_pose_model: str = "models/swish_pose.pt"
68
+
69
+ # Processing settings
70
+ batch_size: int = 20
71
+ detection_confidence: float = 0.5
72
+
73
+ @property
74
+ def allowed_extensions_list(self) -> list[str]:
75
+ """Get allowed video extensions as a list."""
76
+ return [ext.strip().lower() for ext in self.allowed_video_extensions.split(",")]
77
+
78
+ @property
79
+ def cors_origins_list(self) -> list[str]:
80
+ """Get CORS origins as a list."""
81
+ if not self.cors_origins:
82
+ return []
83
+ return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
84
+
85
+ @property
86
+ def max_upload_size_bytes(self) -> int:
87
+ """Get max upload size in bytes."""
88
+ return self.max_upload_size_mb * 1024 * 1024
89
+
90
+
91
+ @lru_cache
92
+ def get_settings() -> Settings:
93
+ """Get cached settings instance."""
94
+ return Settings()
app/core/__init__.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Custom exceptions for the Basketball Analysis API.
3
+ """
4
+ from typing import Optional, Dict, Any
5
+
6
+
7
+ class BasketballAPIException(Exception):
8
+ """Base exception for all API errors."""
9
+
10
+ def __init__(
11
+ self,
12
+ message: str,
13
+ status_code: int = 500,
14
+ details: Optional[Dict[str, Any]] = None
15
+ ):
16
+ self.message = message
17
+ self.status_code = status_code
18
+ self.details = details or {}
19
+ super().__init__(self.message)
20
+
21
+
22
+ class AuthenticationError(BasketballAPIException):
23
+ """Raised when authentication fails."""
24
+
25
+ def __init__(self, message: str = "Authentication failed", details: Optional[Dict[str, Any]] = None):
26
+ super().__init__(message, status_code=401, details=details)
27
+
28
+
29
+ class AuthorizationError(BasketballAPIException):
30
+ """Raised when user lacks permission for an action."""
31
+
32
+ def __init__(self, message: str = "Access denied", details: Optional[Dict[str, Any]] = None):
33
+ super().__init__(message, status_code=403, details=details)
34
+
35
+
36
+ class NotFoundError(BasketballAPIException):
37
+ """Raised when a resource is not found."""
38
+
39
+ def __init__(self, resource: str = "Resource", resource_id: Optional[str] = None):
40
+ message = f"{resource} not found"
41
+ if resource_id:
42
+ message = f"{resource} with id '{resource_id}' not found"
43
+ super().__init__(message, status_code=404)
44
+
45
+
46
+ class ValidationError(BasketballAPIException):
47
+ """Raised when request validation fails."""
48
+
49
+ def __init__(self, message: str = "Validation error", details: Optional[Dict[str, Any]] = None):
50
+ super().__init__(message, status_code=422, details=details)
51
+
52
+
53
+ class VideoProcessingError(BasketballAPIException):
54
+ """Raised when video processing fails."""
55
+
56
+ def __init__(self, message: str = "Video processing failed", details: Optional[Dict[str, Any]] = None):
57
+ super().__init__(message, status_code=500, details=details)
58
+
59
+
60
+ class StorageError(BasketballAPIException):
61
+ """Raised when file storage operations fail."""
62
+
63
+ def __init__(self, message: str = "Storage operation failed", details: Optional[Dict[str, Any]] = None):
64
+ super().__init__(message, status_code=500, details=details)
65
+
66
+
67
+ class SupabaseError(BasketballAPIException):
68
+ """Raised when Supabase operations fail."""
69
+
70
+ def __init__(self, message: str = "Database operation failed", details: Optional[Dict[str, Any]] = None):
71
+ super().__init__(message, status_code=500, details=details)
72
+
73
+
74
+ class AnalysisError(BasketballAPIException):
75
+ """Raised when video analysis fails."""
76
+
77
+ def __init__(self, message: str = "Analysis failed", details: Optional[Dict[str, Any]] = None):
78
+ super().__init__(message, status_code=500, details=details)
app/core/security.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Security utilities for JWT token handling and password hashing.
3
+ """
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Optional, Dict, Any
6
+ from jose import jwt, JWTError
7
+ import bcrypt
8
+ from app.config import get_settings
9
+
10
+
11
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
12
+ """Verify a password against its hash."""
13
+ try:
14
+ password_bytes = plain_password.encode('utf-8')
15
+ hash_bytes = hashed_password.encode('utf-8')
16
+ return bcrypt.checkpw(password_bytes, hash_bytes)
17
+ except Exception as e:
18
+ print(f"Error verifying password: {e}")
19
+ return False
20
+
21
+
22
+ def get_password_hash(password: str) -> str:
23
+ """Generate a password hash."""
24
+ password_bytes = password.encode('utf-8')
25
+ salt = bcrypt.gensalt()
26
+ hashed = bcrypt.hashpw(password_bytes, salt)
27
+ return hashed.decode('utf-8')
28
+
29
+
30
+ def create_access_token(
31
+ data: Dict[str, Any],
32
+ expires_delta: Optional[timedelta] = None
33
+ ) -> str:
34
+ """
35
+ Create a JWT access token.
36
+
37
+ Args:
38
+ data: Payload data to encode in the token
39
+ expires_delta: Optional custom expiration time
40
+
41
+ Returns:
42
+ Encoded JWT token string
43
+ """
44
+ settings = get_settings()
45
+ to_encode = data.copy()
46
+
47
+ if expires_delta:
48
+ expire = datetime.now(timezone.utc) + expires_delta
49
+ else:
50
+ expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expiration_minutes)
51
+
52
+ to_encode.update({"exp": expire})
53
+ encoded_jwt = jwt.encode(
54
+ to_encode,
55
+ settings.jwt_secret,
56
+ algorithm=settings.jwt_algorithm
57
+ )
58
+ return encoded_jwt
59
+
60
+
61
+ def decode_access_token(token: str) -> Optional[Dict[str, Any]]:
62
+ """
63
+ Decode and validate a JWT access token.
64
+
65
+ Args:
66
+ token: JWT token string
67
+
68
+ Returns:
69
+ Decoded payload if valid, None otherwise
70
+ """
71
+ settings = get_settings()
72
+ try:
73
+ payload = jwt.decode(
74
+ token,
75
+ settings.jwt_secret,
76
+ algorithms=[settings.jwt_algorithm]
77
+ )
78
+ return payload
79
+ except JWTError:
80
+ return None
81
+
82
+
83
+ def create_refresh_token(user_id: str) -> str:
84
+ """
85
+ Create a refresh token with extended expiration.
86
+
87
+ Args:
88
+ user_id: User ID to encode in the token
89
+
90
+ Returns:
91
+ Encoded JWT refresh token
92
+ """
93
+ settings = get_settings()
94
+ expire = datetime.now(timezone.utc) + timedelta(days=7) # 7 days for refresh token
95
+
96
+ to_encode = {
97
+ "sub": user_id,
98
+ "type": "refresh",
99
+ "exp": expire
100
+ }
101
+
102
+ return jwt.encode(
103
+ to_encode,
104
+ settings.jwt_secret,
105
+ algorithm=settings.jwt_algorithm
106
+ )
app/dependencies.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI dependency injection functions.
3
+
4
+ Provides common dependencies for authentication, authorization,
5
+ and service access across all API endpoints.
6
+ """
7
+ from typing import Optional
8
+ from fastapi import Depends, HTTPException, status, Query
9
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
10
+
11
+ from app.config import get_settings, Settings
12
+ from app.core.security import decode_access_token
13
+ from app.core import AuthenticationError, AuthorizationError
14
+ from app.models.user import AccountType, TokenPayload
15
+ from app.services.supabase_client import SupabaseService, get_supabase_service
16
+
17
+
18
+ # OAuth2 bearer scheme for JWT tokens
19
+ security = HTTPBearer(auto_error=False)
20
+
21
+
22
+ async def get_settings_dep() -> Settings:
23
+ """Dependency to get application settings."""
24
+ return get_settings()
25
+
26
+
27
+ async def get_supabase(
28
+ settings: Settings = Depends(get_settings_dep)
29
+ ) -> SupabaseService:
30
+ """Dependency to get Supabase service instance."""
31
+ return get_supabase_service()
32
+
33
+
34
+ async def get_current_user_optional(
35
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
36
+ ) -> Optional[dict]:
37
+ """
38
+ Get current user from JWT token if present.
39
+ Returns None if no token provided (for public endpoints).
40
+ """
41
+ if not credentials:
42
+ return None
43
+
44
+ token = credentials.credentials
45
+ payload = decode_access_token(token)
46
+
47
+ if not payload:
48
+ return None
49
+
50
+ return {
51
+ "id": payload.get("sub"),
52
+ "email": payload.get("email"),
53
+ "account_type": payload.get("account_type"),
54
+ "organization_id": payload.get("organization_id")
55
+ }
56
+
57
+
58
+
59
+
60
+ async def get_current_user(
61
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
62
+ token_query: Optional[str] = Query(None, alias="token")
63
+ ) -> dict:
64
+ """
65
+ Get current authenticated user from JWT token (Header or Query param).
66
+ Raises HTTPException if not authenticated.
67
+ """
68
+ token = None
69
+ if credentials:
70
+ token = credentials.credentials
71
+ elif token_query:
72
+ token = token_query
73
+
74
+ if not token:
75
+ raise HTTPException(
76
+ status_code=status.HTTP_401_UNAUTHORIZED,
77
+ detail="Not authenticated",
78
+ headers={"WWW-Authenticate": "Bearer"},
79
+ )
80
+
81
+ payload = decode_access_token(token)
82
+
83
+ if not payload:
84
+ raise HTTPException(
85
+ status_code=status.HTTP_401_UNAUTHORIZED,
86
+ detail="Invalid or expired token",
87
+ headers={"WWW-Authenticate": "Bearer"},
88
+ )
89
+
90
+ return {
91
+ "id": payload.get("sub"),
92
+ "email": payload.get("email"),
93
+ "account_type": payload.get("account_type"),
94
+ "organization_id": payload.get("organization_id")
95
+ }
96
+
97
+
98
+ async def require_team_account(
99
+ current_user: dict = Depends(get_current_user),
100
+ ) -> dict:
101
+ """
102
+ Dependency that requires a TEAM or COACH account type.
103
+ Use for team-only endpoints.
104
+ """
105
+ allowed_types = [AccountType.TEAM.value, AccountType.COACH.value]
106
+ if current_user.get("account_type") not in allowed_types:
107
+ raise HTTPException(
108
+ status_code=status.HTTP_403_FORBIDDEN,
109
+ detail="This endpoint requires a TEAM or COACH account",
110
+ )
111
+ return current_user
112
+
113
+
114
+ async def require_organization_admin(
115
+ current_user: dict = Depends(get_current_user),
116
+ ) -> dict:
117
+ """
118
+ Dependency that requires a TEAM account type (Organization Owner).
119
+ Use for critical administrative tasks like staff linking and settings.
120
+ """
121
+ if current_user.get("account_type") != AccountType.TEAM.value:
122
+ raise HTTPException(
123
+ status_code=status.HTTP_403_FORBIDDEN,
124
+ detail="This operation requires organization owner administrative privileges",
125
+ )
126
+ return current_user
127
+
128
+ async def require_personal_account(
129
+ current_user: dict = Depends(get_current_user),
130
+ ) -> dict:
131
+ """
132
+ Dependency that requires a PERSONAL account type.
133
+ Use for personal-only endpoints.
134
+ """
135
+ if current_user.get("account_type") != AccountType.PERSONAL.value:
136
+ raise HTTPException(
137
+ status_code=status.HTTP_403_FORBIDDEN,
138
+ detail="This endpoint requires a PERSONAL account",
139
+ )
140
+ return current_user
141
+
142
+
143
+ async def require_linked_account(
144
+ current_user: dict = Depends(get_current_user),
145
+ ) -> dict:
146
+ """
147
+ Dependency that requires the user to be linked to an organization.
148
+ """
149
+ if not current_user.get("organization_id"):
150
+ # For TEAM accounts, they might not have organization_id in token yet if just created,
151
+ # but for players/coaches, they MUST be linked.
152
+ if current_user.get("account_type") != AccountType.TEAM.value:
153
+ raise HTTPException(
154
+ status_code=status.HTTP_403_FORBIDDEN,
155
+ detail="You must be linked to a team to access this feature",
156
+ )
157
+ return current_user
158
+
159
+
160
+ async def require_staff_member(
161
+ current_user: dict = Depends(require_team_account),
162
+ ) -> dict:
163
+ """
164
+ Dependency that requires the user to be a COACH or TEAM owner who is linked to an organization.
165
+ Used for features delegated to coaching staff (match upload, scheduling, stats).
166
+ """
167
+ allowed_types = [AccountType.COACH.value, AccountType.TEAM.value]
168
+ if current_user.get("account_type") not in allowed_types:
169
+ raise HTTPException(
170
+ status_code=status.HTTP_403_FORBIDDEN,
171
+ detail="This feature is managed by the Team Owner or Coaching Staff",
172
+ )
173
+ if not current_user.get("organization_id"):
174
+ # For TEAM accounts, we might need to fetch the org_id if not in token
175
+ # but the check below is a safe guard.
176
+ if current_user.get("account_type") != AccountType.TEAM.value:
177
+ raise HTTPException(
178
+ status_code=status.HTTP_403_FORBIDDEN,
179
+ detail="Your account has not been linked to a team yet.",
180
+ )
181
+ return current_user
182
+
183
+ def require_owner_or_admin(resource_owner_id: str):
184
+ """
185
+ Factory for dependency that checks if user owns a resource.
186
+
187
+ Usage:
188
+ @router.delete("/items/{item_id}")
189
+ async def delete_item(
190
+ item_id: str,
191
+ _: dict = Depends(require_owner_or_admin(item.owner_id))
192
+ ):
193
+ ...
194
+ """
195
+ async def dependency(current_user: dict = Depends(get_current_user)) -> dict:
196
+ if current_user.get("id") != resource_owner_id:
197
+ raise HTTPException(
198
+ status_code=status.HTTP_403_FORBIDDEN,
199
+ detail="You don't have permission to access this resource",
200
+ )
201
+ return current_user
202
+ return dependency
app/main.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Basketball Analysis API - Main FastAPI Application
3
+
4
+ This module sets up the FastAPI application with all routes, middleware,
5
+ and exception handlers for the basketball performance analysis platform.
6
+ """
7
+ import os
8
+ from contextlib import asynccontextmanager
9
+ from typing import Dict, Any
10
+
11
+ from fastapi import FastAPI, Request, status
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.responses import JSONResponse
14
+ from fastapi.staticfiles import StaticFiles
15
+
16
+ from app.config import get_settings
17
+ from app.core import BasketballAPIException
18
+ from app.api import (
19
+ auth,
20
+ videos,
21
+ analysis,
22
+ teams,
23
+ players,
24
+ analytics,
25
+ admin,
26
+ player_routes,
27
+ advanced_analytics,
28
+ communications,
29
+ personal_analysis,
30
+ stat_import
31
+ )
32
+ from app.middleware.timeout import TimeoutMiddleware
33
+
34
+ # Basic rate limiting for abuse protection
35
+ from slowapi import Limiter
36
+ from slowapi.util import get_remote_address
37
+ from slowapi.errors import RateLimitExceeded
38
+ from slowapi.middleware import SlowAPIMiddleware
39
+
40
+
41
+ @asynccontextmanager
42
+ async def lifespan(app: FastAPI):
43
+ """
44
+ Application lifespan handler for startup and shutdown events.
45
+ """
46
+ # Startup
47
+ settings = get_settings()
48
+ print(f"🏀 Starting {settings.app_name} v{settings.app_version}")
49
+
50
+ # Basic production hardening
51
+ if not settings.debug and settings.jwt_secret == "your-super-secret-key-change-in-production":
52
+ raise ValueError("JWT_SECRET must be set in production (refusing to start with default secret).")
53
+
54
+ # Ensure upload directory exists
55
+ os.makedirs(settings.upload_dir, exist_ok=True)
56
+
57
+ # Check GPU availability
58
+ if settings.gpu_enabled:
59
+ try:
60
+ import torch
61
+ if torch.cuda.is_available():
62
+ print(f"✅ GPU acceleration enabled: {torch.cuda.get_device_name(settings.cuda_device)}")
63
+ else:
64
+ print("⚠️ GPU requested but CUDA not available, falling back to CPU")
65
+ except ImportError:
66
+ print("⚠️ PyTorch not installed, running without GPU check")
67
+
68
+ yield
69
+
70
+ # Shutdown
71
+ print("🛑 Shutting down application")
72
+
73
+
74
+ def create_app() -> FastAPI:
75
+ """
76
+ Application factory for creating the FastAPI instance.
77
+ """
78
+ settings = get_settings()
79
+
80
+ app = FastAPI(
81
+ title=settings.app_name,
82
+ version=settings.app_version,
83
+ description="""
84
+ AI-driven basketball performance analysis platform.
85
+
86
+ ## Features
87
+ - **Video Analysis**: Upload and analyze basketball footage
88
+ - **Team Analysis**: Multi-player tracking, passes, interceptions
89
+ - **Personal Analysis**: Individual skill metrics and pose analysis
90
+ - **Progress Tracking**: Monitor improvement over time
91
+
92
+ ## Account Types
93
+ - **TEAM**: Manage organizations, players, and team analytics
94
+ - **PERSONAL**: Focus on individual training and skill development
95
+ """,
96
+ docs_url="/docs",
97
+ redoc_url="/redoc",
98
+ openapi_url="/api/openapi.json",
99
+ lifespan=lifespan,
100
+ )
101
+
102
+ # Attach a rate limiter to the app. We keep limits conservative but safe by default.
103
+ limiter = Limiter(key_func=get_remote_address, default_limits=[settings.default_rate_limit])
104
+ app.state.limiter = limiter
105
+ app.add_middleware(SlowAPIMiddleware)
106
+
107
+ # Configure CORS
108
+ # - In production, require explicit origins (settings.cors_origins)
109
+ # - In debug, allow "*" but disable credentials to avoid insecure/invalid combo
110
+ cors_origins = settings.cors_origins_list
111
+ if settings.debug and not cors_origins:
112
+ allow_origins = ["*"]
113
+ allow_credentials = False
114
+ else:
115
+ allow_origins = cors_origins
116
+ allow_credentials = True
117
+
118
+ app.add_middleware(
119
+ CORSMiddleware,
120
+ allow_origins=allow_origins,
121
+ allow_credentials=allow_credentials,
122
+ allow_methods=["*"],
123
+ allow_headers=["*"],
124
+ )
125
+
126
+ # Request timeout (avoid resource exhaustion)
127
+ app.add_middleware(TimeoutMiddleware, timeout_seconds=settings.request_timeout_seconds)
128
+
129
+ # Register exception handlers
130
+ register_exception_handlers(app)
131
+
132
+ # Register API routes
133
+ register_routes(app)
134
+
135
+ # Static files for uploads (debug/dev only). In production, use authenticated download endpoints
136
+ # or signed object storage URLs instead of exposing a filesystem-backed directory.
137
+ # Anchor all static dirs to the backend root (derived from __file__) so they
138
+ # resolve correctly even when CWD is mangled (apostrophe-stripped shell).
139
+ _app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
140
+
141
+ if settings.serve_uploads_in_debug and os.path.exists(settings.upload_dir):
142
+ app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads")
143
+
144
+ # Serve generated clips (highlights)
145
+ clips_dir = os.path.join(_app_dir, "output_videos", "clips")
146
+ os.makedirs(clips_dir, exist_ok=True)
147
+ app.mount("/clips", StaticFiles(directory=clips_dir), name="clips")
148
+
149
+ # Serve personal analysis output videos — always mounted so results are
150
+ # accessible regardless of the DEBUG flag.
151
+ personal_out_dir = os.path.join(_app_dir, "uploads", "personal_output")
152
+ os.makedirs(personal_out_dir, exist_ok=True)
153
+ app.mount("/personal-output", StaticFiles(directory=personal_out_dir), name="personal-output")
154
+
155
+ return app
156
+
157
+
158
+ def register_exception_handlers(app: FastAPI) -> None:
159
+ """Register custom exception handlers."""
160
+
161
+ @app.exception_handler(BasketballAPIException)
162
+ async def basketball_exception_handler(
163
+ request: Request, exc: BasketballAPIException
164
+ ) -> JSONResponse:
165
+ return JSONResponse(
166
+ status_code=exc.status_code,
167
+ content={
168
+ "error": exc.message,
169
+ "details": exc.details,
170
+ },
171
+ )
172
+
173
+ from fastapi.exceptions import RequestValidationError
174
+ @app.exception_handler(RequestValidationError)
175
+ async def validation_exception_handler(request: Request, exc: RequestValidationError):
176
+ print(f"Validation Error: {exc.errors()}")
177
+ return JSONResponse(
178
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
179
+ content={"detail": exc.errors()},
180
+ )
181
+
182
+ @app.exception_handler(RateLimitExceeded)
183
+ async def rate_limit_handler(
184
+ request: Request, exc: RateLimitExceeded
185
+ ) -> JSONResponse:
186
+ return JSONResponse(
187
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
188
+ content={
189
+ "error": "Too many requests",
190
+ "details": {"message": str(exc)},
191
+ },
192
+ )
193
+
194
+ @app.exception_handler(Exception)
195
+ async def general_exception_handler(
196
+ request: Request, exc: Exception
197
+ ) -> JSONResponse:
198
+ # Log the error without leaking internals to clients
199
+ settings = get_settings()
200
+ # Use standard logging so deployments can aggregate logs
201
+ import logging
202
+
203
+ logger = logging.getLogger("basketball_api")
204
+ logger.exception("Unhandled error", exc_info=exc)
205
+
206
+ return JSONResponse(
207
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
208
+ content={
209
+ "error": "An unexpected error occurred",
210
+ # Only expose minimal type information; no stack traces or messages.
211
+ "details": {"type": type(exc).__name__} if settings.debug else {},
212
+ },
213
+ )
214
+
215
+
216
+ def register_routes(app: FastAPI) -> None:
217
+ """Register all API routers."""
218
+
219
+ # Health check endpoint
220
+ @app.get("/api/health", tags=["Health"])
221
+ async def health_check() -> Dict[str, Any]:
222
+ """Check API health status."""
223
+ settings = get_settings()
224
+ return {
225
+ "status": "healthy",
226
+ "version": settings.app_version,
227
+ "gpu_enabled": settings.gpu_enabled,
228
+ }
229
+
230
+ # Root endpoint
231
+ @app.get("/", tags=["Root"])
232
+ async def root() -> Dict[str, str]:
233
+ """API root endpoint."""
234
+ settings = get_settings()
235
+ return {
236
+ "message": f"Welcome to {settings.app_name}",
237
+ "docs": "/docs",
238
+ "health": "/api/health",
239
+ }
240
+
241
+ @app.get("/api/test-proxy")
242
+ async def test_proxy():
243
+ return {"status": "proxy-ok"}
244
+
245
+ # Register API routers
246
+ app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
247
+ app.include_router(videos.router, prefix="/api/videos", tags=["Videos"])
248
+ app.include_router(analysis.router, prefix="/api/analysis", tags=["Analysis"])
249
+ app.include_router(teams.router, prefix="/api/teams", tags=["Teams"])
250
+ app.include_router(players.router, prefix="/api/players", tags=["Players"])
251
+ app.include_router(analytics.router, prefix="/api/analytics", tags=["Analytics"])
252
+ app.include_router(advanced_analytics.router, prefix="/api/analytics/advanced", tags=["Advanced Analytics"])
253
+ app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
254
+ app.include_router(player_routes.router, prefix="/api/player", tags=["Player Portal"])
255
+ app.include_router(personal_analysis.router, prefix="/api/player", tags=["Personal Analysis"])
256
+ app.include_router(communications.router, prefix="/api/communications", tags=["Communication"])
257
+ app.include_router(stat_import.router, prefix="/api/stat-import", tags=["Stat Sheet Import"])
258
+
259
+
260
+ # Create the application instance
261
+ app = create_app()
262
+
263
+
264
+ if __name__ == "__main__":
265
+ import uvicorn
266
+ settings = get_settings()
267
+ uvicorn.run(
268
+ "app.main:app",
269
+ host=settings.host,
270
+ port=settings.port,
271
+ reload=settings.debug,
272
+ )
app/middleware/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
app/middleware/timeout.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import anyio
2
+ from starlette.middleware.base import BaseHTTPMiddleware
3
+ from starlette.requests import Request
4
+ from starlette.responses import JSONResponse, Response
5
+
6
+
7
+ class TimeoutMiddleware(BaseHTTPMiddleware):
8
+ def __init__(self, app, timeout_seconds: int):
9
+ super().__init__(app)
10
+ self.timeout_seconds = timeout_seconds
11
+
12
+ async def dispatch(self, request: Request, call_next) -> Response:
13
+ try:
14
+ with anyio.fail_after(self.timeout_seconds):
15
+ return await call_next(request)
16
+ except TimeoutError:
17
+ return JSONResponse(
18
+ status_code=504,
19
+ content={"error": "Request timed out", "details": {"timeout_seconds": self.timeout_seconds}},
20
+ )
app/models/__init__.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic models package.
3
+ """
4
+ from app.models.user import (
5
+ AccountType,
6
+ UserCreate,
7
+ UserLogin,
8
+ UserUpdate,
9
+ User,
10
+ UserInDB,
11
+ TokenResponse,
12
+ TokenPayload,
13
+ )
14
+ from app.models.video import (
15
+ VideoStatus,
16
+ AnalysisMode,
17
+ VideoUpload,
18
+ Video,
19
+ VideoStatusResponse,
20
+ VideoListResponse,
21
+ )
22
+ from app.models.team import (
23
+ OrganizationCreate,
24
+ OrganizationUpdate,
25
+ Organization,
26
+ OrganizationWithStats,
27
+ OrganizationListResponse,
28
+ )
29
+ from app.models.player import (
30
+ PlayerCreate,
31
+ PlayerUpdate,
32
+ Player,
33
+ PlayerWithStats,
34
+ PlayerListResponse,
35
+ )
36
+ from app.models.analysis import (
37
+ Detection,
38
+ DetectionBatch,
39
+ AnalysisRequest,
40
+ AnalysisEvent,
41
+ AnalysisResult,
42
+ PersonalAnalysisResult,
43
+ )
44
+ from app.models.analytics import (
45
+ PlayerMetric,
46
+ PlayerAnalyticsSummary,
47
+ TeamAnalyticsSummary,
48
+ SkillSummary,
49
+ ProgressData,
50
+ ProgressReport,
51
+ )
52
+ from app.models.communication import (
53
+ AnnouncementCreate,
54
+ Announcement,
55
+ AnnouncementListResponse,
56
+ )
57
+
58
+ __all__ = [
59
+ # User models
60
+ "AccountType",
61
+ "UserCreate",
62
+ "UserLogin",
63
+ "UserUpdate",
64
+ "User",
65
+ "UserInDB",
66
+ "TokenResponse",
67
+ "TokenPayload",
68
+ # Video models
69
+ "VideoStatus",
70
+ "AnalysisMode",
71
+ "VideoUpload",
72
+ "Video",
73
+ "VideoStatusResponse",
74
+ "VideoListResponse",
75
+ # Team models
76
+ "OrganizationCreate",
77
+ "OrganizationUpdate",
78
+ "Organization",
79
+ "OrganizationWithStats",
80
+ "OrganizationListResponse",
81
+ # Player models
82
+ "PlayerCreate",
83
+ "PlayerUpdate",
84
+ "Player",
85
+ "PlayerWithStats",
86
+ "PlayerListResponse",
87
+ # Analysis models
88
+ "Detection",
89
+ "DetectionBatch",
90
+ "AnalysisRequest",
91
+ "AnalysisEvent",
92
+ "AnalysisResult",
93
+ "PersonalAnalysisResult",
94
+ # Analytics models
95
+ "PlayerMetric",
96
+ "PlayerAnalyticsSummary",
97
+ "TeamAnalyticsSummary",
98
+ "SkillSummary",
99
+ "ProgressData",
100
+ "ProgressReport",
101
+ # Communication models
102
+ "AnnouncementCreate",
103
+ "Announcement",
104
+ "AnnouncementListResponse",
105
+ ]