Okidi Norbert commited on
Commit Β·
45dcdd6
1
Parent(s): 391c939
Final production push with CORS, model, and video fixes
Browse files- .gitattributes +7 -0
- DEPLOYMENT.md +39 -0
- app/api/admin.py +19 -19
- app/dependencies.py +1 -1
- app/main.py +23 -4
- debug_path.py +14 -1
- download_models.py +34 -42
- personal_analysis/pipeline.py +44 -35
- personal_analysis/test_resolve.py +13 -10
.gitattributes
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
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.
|
app/api/admin.py
CHANGED
|
@@ -59,7 +59,7 @@ async def update_organization(
|
|
| 59 |
async def update_user_role(
|
| 60 |
user_id: str,
|
| 61 |
role_data: dict,
|
| 62 |
-
current_user: dict = Depends(
|
| 63 |
supabase: SupabaseService = Depends(get_supabase),
|
| 64 |
):
|
| 65 |
"""
|
|
@@ -83,7 +83,7 @@ async def update_user_role(
|
|
| 83 |
@router.get("/users")
|
| 84 |
async def get_users(
|
| 85 |
role: Optional[str] = None,
|
| 86 |
-
current_user: dict = Depends(
|
| 87 |
supabase: SupabaseService = Depends(get_supabase),
|
| 88 |
):
|
| 89 |
"""
|
|
@@ -146,7 +146,7 @@ async def get_users(
|
|
| 146 |
@router.post("/players")
|
| 147 |
async def create_player(
|
| 148 |
player_data: PlayerCreate,
|
| 149 |
-
current_user: dict = Depends(
|
| 150 |
supabase: SupabaseService = Depends(get_supabase),
|
| 151 |
):
|
| 152 |
"""
|
|
@@ -177,7 +177,7 @@ async def create_player(
|
|
| 177 |
async def update_player(
|
| 178 |
player_id: str,
|
| 179 |
player_data: PlayerUpdate,
|
| 180 |
-
current_user: dict = Depends(
|
| 181 |
supabase: SupabaseService = Depends(get_supabase),
|
| 182 |
):
|
| 183 |
"""
|
|
@@ -210,7 +210,7 @@ async def update_player(
|
|
| 210 |
|
| 211 |
@router.get("/players")
|
| 212 |
async def get_roster(
|
| 213 |
-
current_user: dict = Depends(
|
| 214 |
supabase: SupabaseService = Depends(get_supabase),
|
| 215 |
):
|
| 216 |
"""
|
|
@@ -222,7 +222,7 @@ async def get_roster(
|
|
| 222 |
async def update_player_status(
|
| 223 |
player_id: str,
|
| 224 |
status_data: dict,
|
| 225 |
-
current_user: dict = Depends(
|
| 226 |
supabase: SupabaseService = Depends(get_supabase),
|
| 227 |
):
|
| 228 |
"""
|
|
@@ -253,7 +253,7 @@ async def update_player_status(
|
|
| 253 |
@router.delete("/players/{player_id}")
|
| 254 |
async def delete_player(
|
| 255 |
player_id: str,
|
| 256 |
-
current_user: dict = Depends(
|
| 257 |
supabase: SupabaseService = Depends(get_supabase),
|
| 258 |
):
|
| 259 |
"""
|
|
@@ -294,7 +294,7 @@ async def delete_player(
|
|
| 294 |
async def link_player_account(
|
| 295 |
player_id: str,
|
| 296 |
link_data: dict,
|
| 297 |
-
current_user: dict = Depends(
|
| 298 |
supabase: SupabaseService = Depends(get_supabase),
|
| 299 |
):
|
| 300 |
"""
|
|
@@ -438,7 +438,7 @@ async def link_player_account(
|
|
| 438 |
|
| 439 |
@router.get("/staff")
|
| 440 |
async def get_staff(
|
| 441 |
-
current_user: dict = Depends(
|
| 442 |
supabase: SupabaseService = Depends(get_supabase),
|
| 443 |
):
|
| 444 |
"""
|
|
@@ -462,8 +462,8 @@ async def get_staff(
|
|
| 462 |
@router.post("/staff-link")
|
| 463 |
@router.post("/staff-link/")
|
| 464 |
async def link_staff_member(
|
| 465 |
-
link_data:
|
| 466 |
-
current_user: dict = Depends(
|
| 467 |
supabase: SupabaseService = Depends(get_supabase),
|
| 468 |
):
|
| 469 |
"""
|
|
@@ -583,7 +583,7 @@ async def remove_staff_member(
|
|
| 583 |
|
| 584 |
@router.get("/schedule")
|
| 585 |
async def get_schedule(
|
| 586 |
-
current_user: dict = Depends(
|
| 587 |
supabase: SupabaseService = Depends(get_supabase),
|
| 588 |
):
|
| 589 |
"""Get team schedule."""
|
|
@@ -663,7 +663,7 @@ async def delete_schedule_event(
|
|
| 663 |
|
| 664 |
@router.get("/matches")
|
| 665 |
async def get_matches(
|
| 666 |
-
current_user: dict = Depends(
|
| 667 |
supabase: SupabaseService = Depends(get_supabase),
|
| 668 |
):
|
| 669 |
"""Get matches for the current user's organization (Owner or Staff)."""
|
|
@@ -733,7 +733,7 @@ async def delete_match(
|
|
| 733 |
@router.get("/matches/{match_id}/player-stats")
|
| 734 |
async def get_match_player_stats(
|
| 735 |
match_id: str,
|
| 736 |
-
current_user: dict = Depends(
|
| 737 |
supabase: SupabaseService = Depends(get_supabase),
|
| 738 |
):
|
| 739 |
"""Get all player stats for a specific match, enriched with player name/jersey info."""
|
|
@@ -795,7 +795,7 @@ async def get_notifications(
|
|
| 795 |
@router.post("/notifications")
|
| 796 |
async def create_notification(
|
| 797 |
notif_data: NotificationCreate,
|
| 798 |
-
current_user: dict = Depends(
|
| 799 |
supabase: SupabaseService = Depends(get_supabase),
|
| 800 |
):
|
| 801 |
notif_dict = notif_data.model_dump()
|
|
@@ -828,7 +828,7 @@ async def mark_all_notifications_read(
|
|
| 828 |
async def update_notification(
|
| 829 |
notification_id: str,
|
| 830 |
notification_data: NotificationCreate,
|
| 831 |
-
current_user: dict = Depends(
|
| 832 |
supabase: SupabaseService = Depends(get_supabase),
|
| 833 |
):
|
| 834 |
update_dict = notification_data.model_dump(exclude_unset=True)
|
|
@@ -847,7 +847,7 @@ async def delete_notification(
|
|
| 847 |
|
| 848 |
@router.get("/stats")
|
| 849 |
async def get_stats(
|
| 850 |
-
current_user: dict = Depends(
|
| 851 |
supabase: SupabaseService = Depends(get_supabase),
|
| 852 |
):
|
| 853 |
# Mock aggregation or real counts
|
|
@@ -894,7 +894,7 @@ async def get_stats(
|
|
| 894 |
|
| 895 |
@router.get("/profile")
|
| 896 |
async def get_profile(
|
| 897 |
-
current_user: dict = Depends(
|
| 898 |
supabase: SupabaseService = Depends(get_supabase),
|
| 899 |
):
|
| 900 |
# Get user info and associated organization
|
|
@@ -915,7 +915,7 @@ async def get_profile(
|
|
| 915 |
@router.put("/profile")
|
| 916 |
async def update_profile(
|
| 917 |
profile_data: dict,
|
| 918 |
-
current_user: dict = Depends(
|
| 919 |
supabase: SupabaseService = Depends(get_supabase),
|
| 920 |
):
|
| 921 |
# Update user or org
|
|
|
|
| 59 |
async def update_user_role(
|
| 60 |
user_id: str,
|
| 61 |
role_data: dict,
|
| 62 |
+
current_user: dict = Depends(require_staff_member),
|
| 63 |
supabase: SupabaseService = Depends(get_supabase),
|
| 64 |
):
|
| 65 |
"""
|
|
|
|
| 83 |
@router.get("/users")
|
| 84 |
async def get_users(
|
| 85 |
role: Optional[str] = None,
|
| 86 |
+
current_user: dict = Depends(require_staff_member),
|
| 87 |
supabase: SupabaseService = Depends(get_supabase),
|
| 88 |
):
|
| 89 |
"""
|
|
|
|
| 146 |
@router.post("/players")
|
| 147 |
async def create_player(
|
| 148 |
player_data: PlayerCreate,
|
| 149 |
+
current_user: dict = Depends(require_staff_member),
|
| 150 |
supabase: SupabaseService = Depends(get_supabase),
|
| 151 |
):
|
| 152 |
"""
|
|
|
|
| 177 |
async def update_player(
|
| 178 |
player_id: str,
|
| 179 |
player_data: PlayerUpdate,
|
| 180 |
+
current_user: dict = Depends(require_staff_member),
|
| 181 |
supabase: SupabaseService = Depends(get_supabase),
|
| 182 |
):
|
| 183 |
"""
|
|
|
|
| 210 |
|
| 211 |
@router.get("/players")
|
| 212 |
async def get_roster(
|
| 213 |
+
current_user: dict = Depends(require_staff_member),
|
| 214 |
supabase: SupabaseService = Depends(get_supabase),
|
| 215 |
):
|
| 216 |
"""
|
|
|
|
| 222 |
async def update_player_status(
|
| 223 |
player_id: str,
|
| 224 |
status_data: dict,
|
| 225 |
+
current_user: dict = Depends(require_staff_member),
|
| 226 |
supabase: SupabaseService = Depends(get_supabase),
|
| 227 |
):
|
| 228 |
"""
|
|
|
|
| 253 |
@router.delete("/players/{player_id}")
|
| 254 |
async def delete_player(
|
| 255 |
player_id: str,
|
| 256 |
+
current_user: dict = Depends(require_staff_member),
|
| 257 |
supabase: SupabaseService = Depends(get_supabase),
|
| 258 |
):
|
| 259 |
"""
|
|
|
|
| 294 |
async def link_player_account(
|
| 295 |
player_id: str,
|
| 296 |
link_data: dict,
|
| 297 |
+
current_user: dict = Depends(require_staff_member),
|
| 298 |
supabase: SupabaseService = Depends(get_supabase),
|
| 299 |
):
|
| 300 |
"""
|
|
|
|
| 438 |
|
| 439 |
@router.get("/staff")
|
| 440 |
async def get_staff(
|
| 441 |
+
current_user: dict = Depends(require_staff_member),
|
| 442 |
supabase: SupabaseService = Depends(get_supabase),
|
| 443 |
):
|
| 444 |
"""
|
|
|
|
| 462 |
@router.post("/staff-link")
|
| 463 |
@router.post("/staff-link/")
|
| 464 |
async def link_staff_member(
|
| 465 |
+
link_data: StaffLinkRequest,
|
| 466 |
+
current_user: dict = Depends(require_team_account),
|
| 467 |
supabase: SupabaseService = Depends(get_supabase),
|
| 468 |
):
|
| 469 |
"""
|
|
|
|
| 583 |
|
| 584 |
@router.get("/schedule")
|
| 585 |
async def get_schedule(
|
| 586 |
+
current_user: dict = Depends(require_staff_member),
|
| 587 |
supabase: SupabaseService = Depends(get_supabase),
|
| 588 |
):
|
| 589 |
"""Get team schedule."""
|
|
|
|
| 663 |
|
| 664 |
@router.get("/matches")
|
| 665 |
async def get_matches(
|
| 666 |
+
current_user: dict = Depends(require_staff_member),
|
| 667 |
supabase: SupabaseService = Depends(get_supabase),
|
| 668 |
):
|
| 669 |
"""Get matches for the current user's organization (Owner or Staff)."""
|
|
|
|
| 733 |
@router.get("/matches/{match_id}/player-stats")
|
| 734 |
async def get_match_player_stats(
|
| 735 |
match_id: str,
|
| 736 |
+
current_user: dict = Depends(require_staff_member),
|
| 737 |
supabase: SupabaseService = Depends(get_supabase),
|
| 738 |
):
|
| 739 |
"""Get all player stats for a specific match, enriched with player name/jersey info."""
|
|
|
|
| 795 |
@router.post("/notifications")
|
| 796 |
async def create_notification(
|
| 797 |
notif_data: NotificationCreate,
|
| 798 |
+
current_user: dict = Depends(require_staff_member),
|
| 799 |
supabase: SupabaseService = Depends(get_supabase),
|
| 800 |
):
|
| 801 |
notif_dict = notif_data.model_dump()
|
|
|
|
| 828 |
async def update_notification(
|
| 829 |
notification_id: str,
|
| 830 |
notification_data: NotificationCreate,
|
| 831 |
+
current_user: dict = Depends(require_staff_member),
|
| 832 |
supabase: SupabaseService = Depends(get_supabase),
|
| 833 |
):
|
| 834 |
update_dict = notification_data.model_dump(exclude_unset=True)
|
|
|
|
| 847 |
|
| 848 |
@router.get("/stats")
|
| 849 |
async def get_stats(
|
| 850 |
+
current_user: dict = Depends(require_staff_member),
|
| 851 |
supabase: SupabaseService = Depends(get_supabase),
|
| 852 |
):
|
| 853 |
# Mock aggregation or real counts
|
|
|
|
| 894 |
|
| 895 |
@router.get("/profile")
|
| 896 |
async def get_profile(
|
| 897 |
+
current_user: dict = Depends(require_staff_member),
|
| 898 |
supabase: SupabaseService = Depends(get_supabase),
|
| 899 |
):
|
| 900 |
# Get user info and associated organization
|
|
|
|
| 915 |
@router.put("/profile")
|
| 916 |
async def update_profile(
|
| 917 |
profile_data: dict,
|
| 918 |
+
current_user: dict = Depends(require_staff_member),
|
| 919 |
supabase: SupabaseService = Depends(get_supabase),
|
| 920 |
):
|
| 921 |
# Update user or org
|
app/dependencies.py
CHANGED
|
@@ -192,7 +192,7 @@ async def require_linked_account(
|
|
| 192 |
|
| 193 |
|
| 194 |
async def require_staff_member(
|
| 195 |
-
current_user: dict = Depends(
|
| 196 |
) -> dict:
|
| 197 |
"""
|
| 198 |
Dependency that requires the user to be a COACH or TEAM owner who is linked to an organization.
|
|
|
|
| 192 |
|
| 193 |
|
| 194 |
async def require_staff_member(
|
| 195 |
+
current_user: dict = Depends(get_current_user_with_db),
|
| 196 |
) -> dict:
|
| 197 |
"""
|
| 198 |
Dependency that requires the user to be a COACH or TEAM owner who is linked to an organization.
|
app/main.py
CHANGED
|
@@ -54,6 +54,26 @@ async def lifespan(app: FastAPI):
|
|
| 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:
|
|
@@ -106,12 +126,11 @@ def create_app() -> FastAPI:
|
|
| 106 |
app.add_middleware(SlowAPIMiddleware)
|
| 107 |
|
| 108 |
# Configure CORS
|
| 109 |
-
# - In production, require explicit origins (settings.cors_origins)
|
| 110 |
-
# - In debug, allow "*" but disable credentials to avoid insecure/invalid combo
|
| 111 |
cors_origins = settings.cors_origins_list
|
| 112 |
-
if
|
|
|
|
| 113 |
allow_origins = ["*"]
|
| 114 |
-
allow_credentials = False
|
| 115 |
else:
|
| 116 |
allow_origins = cors_origins
|
| 117 |
allow_credentials = True
|
|
|
|
| 54 |
# Ensure upload directory exists
|
| 55 |
os.makedirs(settings.upload_dir, exist_ok=True)
|
| 56 |
|
| 57 |
+
# Check for required models
|
| 58 |
+
required_models = [
|
| 59 |
+
settings.player_detector_path,
|
| 60 |
+
settings.ball_detector_path,
|
| 61 |
+
settings.court_keypoint_detector_path,
|
| 62 |
+
settings.swish_ball_rim_model,
|
| 63 |
+
settings.swish_pose_model
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
missing_models = []
|
| 67 |
+
for model_path in required_models:
|
| 68 |
+
if not os.path.exists(model_path):
|
| 69 |
+
missing_models.append(model_path)
|
| 70 |
+
|
| 71 |
+
if missing_models:
|
| 72 |
+
print(f"β CRITICAL: Missing models: {', '.join(missing_models)}")
|
| 73 |
+
print(" Please run 'python download_models.py' to fetch them.")
|
| 74 |
+
else:
|
| 75 |
+
print("β
All required models are present.")
|
| 76 |
+
|
| 77 |
# Check GPU availability
|
| 78 |
if settings.gpu_enabled:
|
| 79 |
try:
|
|
|
|
| 126 |
app.add_middleware(SlowAPIMiddleware)
|
| 127 |
|
| 128 |
# Configure CORS
|
|
|
|
|
|
|
| 129 |
cors_origins = settings.cors_origins_list
|
| 130 |
+
if not cors_origins:
|
| 131 |
+
# Permissive for development/unconfigured production
|
| 132 |
allow_origins = ["*"]
|
| 133 |
+
allow_credentials = False # Cannot use credentials with "*"
|
| 134 |
else:
|
| 135 |
allow_origins = cors_origins
|
| 136 |
allow_credentials = True
|
debug_path.py
CHANGED
|
@@ -10,7 +10,20 @@ def _resolve_model_path(path: str) -> str:
|
|
| 10 |
"""Safely resolve model path relative to backend root."""
|
| 11 |
if os.path.isabs(path):
|
| 12 |
return path
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
res_rim = _resolve_model_path(settings.swish_ball_rim_model)
|
| 16 |
res_pose = _resolve_model_path(settings.swish_pose_model)
|
|
|
|
| 10 |
"""Safely resolve model path relative to backend root."""
|
| 11 |
if os.path.isabs(path):
|
| 12 |
return path
|
| 13 |
+
|
| 14 |
+
# Anchor to the backend root (directory containing this script)
|
| 15 |
+
candidate = os.path.abspath(os.path.join(_BACKEND_ROOT, path))
|
| 16 |
+
|
| 17 |
+
if os.path.exists(candidate):
|
| 18 |
+
return candidate
|
| 19 |
+
|
| 20 |
+
# Robust fallback for 'DON'T TOUCH' mangled environments
|
| 21 |
+
if "OKIDI-DONT TOUCH" in candidate:
|
| 22 |
+
healed = candidate.replace("OKIDI-DONT TOUCH", "OKIDI-DON'T TOUCH")
|
| 23 |
+
if os.path.exists(healed):
|
| 24 |
+
return healed
|
| 25 |
+
|
| 26 |
+
return candidate
|
| 27 |
|
| 28 |
res_rim = _resolve_model_path(settings.swish_ball_rim_model)
|
| 29 |
res_pose = _resolve_model_path(settings.swish_pose_model)
|
download_models.py
CHANGED
|
@@ -1,49 +1,41 @@
|
|
| 1 |
import os
|
| 2 |
-
import
|
| 3 |
-
from tqdm import tqdm
|
| 4 |
|
| 5 |
-
def
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
| 11 |
|
| 12 |
-
|
| 13 |
-
if "drive.google.com" in url:
|
| 14 |
-
if "id=" in url:
|
| 15 |
-
file_id = url.split("id=")[1].split("&")[0]
|
| 16 |
-
else:
|
| 17 |
-
file_id = url.split("/")[-2]
|
| 18 |
-
url = f"https://drive.google.com/uc?export=download&id={file_id}"
|
| 19 |
-
|
| 20 |
-
response = requests.get(url, stream=True)
|
| 21 |
-
total_size = int(response.headers.get('content-length', 0))
|
| 22 |
-
|
| 23 |
-
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
for data in response.iter_content(chunk_size=1024):
|
| 33 |
-
size = file.write(data)
|
| 34 |
-
bar.update(size)
|
| 35 |
-
|
| 36 |
-
if __name__ == "__main__":
|
| 37 |
-
MODELS = {
|
| 38 |
-
"models/sam2.1_hiera_tiny.pt": "https://github.com/ultralytics/assets/releases/download/v8.3.0/sam2.1_hiera_tiny.pt",
|
| 39 |
-
"models/player_detector.pt": "https://drive.google.com/uc?export=download&id=1fVBLZtPy9Yu6Tf186oS4siotkioHBLHy",
|
| 40 |
-
"models/ball_detector.pt": "https://drive.google.com/uc?export=download&id=1KejdrcEnto2AKjdgdo1U1syr5gODp6EL",
|
| 41 |
-
"models/court_keypoint_detector.pt": "https://drive.google.com/uc?export=download&id=1nGoG-pUkSg4bWAUIeQ8aN6n7O1fOkXU0",
|
| 42 |
-
"models/yolov8n-pose.pt": "https://github.com/ultralytics/assets/releases/download/v8.2.0/yolov8n-pose.pt"
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
for path, url in MODELS.items():
|
| 46 |
try:
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
except Exception as e:
|
| 49 |
-
print(f"β Failed to download {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
+
from huggingface_hub import hf_hub_download
|
|
|
|
| 3 |
|
| 4 |
+
def download_models():
|
| 5 |
+
"""Download all models from the dedicated Model Repo using huggingface_hub."""
|
| 6 |
+
repo_id = "icanedit2/bakoai-models"
|
| 7 |
+
models_to_download = [
|
| 8 |
+
"player_detector.pt",
|
| 9 |
+
"ball_detector.pt",
|
| 10 |
+
"court_keypoint_detector.pt",
|
| 11 |
+
"swish_ball_rim.pt",
|
| 12 |
+
"swish_pose.pt",
|
| 13 |
+
"yolov8n-pose.pt"
|
| 14 |
+
]
|
| 15 |
|
| 16 |
+
os.makedirs("models", exist_ok=True)
|
| 17 |
|
| 18 |
+
token = os.environ.get("HF_TOKEN")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
for model_file in models_to_download:
|
| 21 |
+
dest_path = os.path.join("models", model_file)
|
| 22 |
+
if os.path.exists(dest_path):
|
| 23 |
+
print(f"β
{dest_path} already exists.")
|
| 24 |
+
continue
|
| 25 |
+
|
| 26 |
+
print(f"β³ Downloading {model_file} from {repo_id}...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
try:
|
| 28 |
+
# hf_hub_download automatically handles LFS, Xet, and auth
|
| 29 |
+
path = hf_hub_download(
|
| 30 |
+
repo_id=repo_id,
|
| 31 |
+
filename=model_file,
|
| 32 |
+
local_dir="models",
|
| 33 |
+
token=token
|
| 34 |
+
)
|
| 35 |
+
print(f"β
Successfully downloaded {model_file} to {path}")
|
| 36 |
except Exception as e:
|
| 37 |
+
print(f"β Failed to download {model_file}: {e}")
|
| 38 |
+
raise
|
| 39 |
+
|
| 40 |
+
if __name__ == "__main__":
|
| 41 |
+
download_models()
|
personal_analysis/pipeline.py
CHANGED
|
@@ -30,50 +30,58 @@ from app.api.videos import get_video_info
|
|
| 30 |
# ββ Model paths ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
settings = get_settings()
|
| 32 |
|
| 33 |
-
# ---------------------------------------------------------------------------
|
| 34 |
-
# Backend root β computed from __file__ then immediately healed.
|
| 35 |
-
#
|
| 36 |
-
# When the FastAPI process is launched from a shell that strips the apostrophe
|
| 37 |
-
# in "DON'T TOUCH", Python's __file__ itself arrives mangled (without the ').
|
| 38 |
-
# We detect that case and swap in the correct character so that every
|
| 39 |
-
# subsequent model path built from _BACKEND_ROOT is already correct.
|
| 40 |
-
# ---------------------------------------------------------------------------
|
| 41 |
-
def _heal_path(p: str) -> str:
|
| 42 |
-
"""Return the real on-disk path by fixing the apostrophe-mangling."""
|
| 43 |
-
if os.path.exists(p):
|
| 44 |
-
return p
|
| 45 |
-
fixed = p.replace("OKIDI-DONT TOUCH", "OKIDI-DON'T TOUCH")
|
| 46 |
-
if os.path.exists(fixed):
|
| 47 |
-
return fixed
|
| 48 |
-
return p # give up β let the caller handle it
|
| 49 |
-
|
| 50 |
-
_BACKEND_ROOT = _heal_path(
|
| 51 |
-
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 52 |
-
)
|
| 53 |
-
print(f"DEBUG: _BACKEND_ROOT = {_BACKEND_ROOT}", file=sys.stderr)
|
| 54 |
-
|
| 55 |
-
|
| 56 |
def _resolve_model_path(path: str) -> str:
|
| 57 |
"""
|
| 58 |
-
Resolve a model path relative to the
|
| 59 |
-
|
| 60 |
-
β’ Absolute paths are used as-is
|
| 61 |
-
β’ Relative paths are anchored to
|
| 62 |
"""
|
| 63 |
if os.path.isabs(path):
|
| 64 |
-
return
|
| 65 |
|
| 66 |
-
# Anchor to the
|
|
|
|
| 67 |
candidate = os.path.abspath(os.path.join(_BACKEND_ROOT, path))
|
|
|
|
| 68 |
if os.path.exists(candidate):
|
| 69 |
return candidate
|
| 70 |
|
| 71 |
-
#
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
return candidate
|
| 78 |
|
| 79 |
BALL_RIM_MODEL = _resolve_model_path(settings.swish_ball_rim_model)
|
|
@@ -133,6 +141,7 @@ def _write_video(frames: list, out_path: str, fps: float = 30.0):
|
|
| 133 |
os.rename(out_path, tmp_path)
|
| 134 |
result = subprocess.run(
|
| 135 |
["ffmpeg", "-y", "-i", tmp_path,
|
|
|
|
| 136 |
"-c:v", "libx264", "-preset", "fast", "-movflags", "+faststart",
|
| 137 |
"-pix_fmt", "yuv420p", "-an", out_path],
|
| 138 |
capture_output=True, timeout=300
|
|
|
|
| 30 |
# ββ Model paths ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
settings = get_settings()
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
def _resolve_model_path(path: str) -> str:
|
| 34 |
"""
|
| 35 |
+
Resolve a model path relative to the backend root.
|
| 36 |
+
|
| 37 |
+
β’ Absolute paths are used as-is.
|
| 38 |
+
β’ Relative paths are anchored to the directory containing 'app/'.
|
| 39 |
"""
|
| 40 |
if os.path.isabs(path):
|
| 41 |
+
return path
|
| 42 |
|
| 43 |
+
# Anchor to the backend root (parent of 'personal_analysis/')
|
| 44 |
+
backend_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 45 |
candidate = os.path.abspath(os.path.join(_BACKEND_ROOT, path))
|
| 46 |
+
|
| 47 |
if os.path.exists(candidate):
|
| 48 |
return candidate
|
| 49 |
|
| 50 |
+
# --- DIAGNOSTIC LOGGING ---
|
| 51 |
+
logger.error(f"β Model file MISSING: {candidate}")
|
| 52 |
+
logger.info(f"DEBUG: Current Working Directory: {os.getcwd()}")
|
| 53 |
+
logger.info(f"DEBUG: Backend Root: {_BACKEND_ROOT}")
|
| 54 |
+
|
| 55 |
+
models_dir = os.path.join(_BACKEND_ROOT, "models")
|
| 56 |
+
if os.path.exists(models_dir):
|
| 57 |
+
try:
|
| 58 |
+
files = os.listdir(models_dir)
|
| 59 |
+
logger.info(f"DEBUG: Contents of {models_dir}: {files}")
|
| 60 |
+
except Exception as e:
|
| 61 |
+
logger.error(f"DEBUG: Could not list {models_dir}: {e}")
|
| 62 |
+
else:
|
| 63 |
+
logger.error(f"DEBUG: Directory {models_dir} does not even exist!")
|
| 64 |
+
|
| 65 |
+
# Search for the file anywhere in /home/user/app
|
| 66 |
+
logger.info(f"DEBUG: Searching for {os.path.basename(path)} in /home/user/app...")
|
| 67 |
+
found_paths = []
|
| 68 |
+
for root, dirs, files in os.walk("/home/user/app"):
|
| 69 |
+
if os.path.basename(path) in files:
|
| 70 |
+
found_paths.append(os.path.join(root, os.path.basename(path)))
|
| 71 |
+
|
| 72 |
+
if found_paths:
|
| 73 |
+
logger.info(f"β
FOUND at alternative locations: {found_paths}")
|
| 74 |
+
return found_paths[0]
|
| 75 |
+
# ---------------------------
|
| 76 |
+
|
| 77 |
+
# Robust fallback: check if we are in a 'DON'T TOUCH' mangled path environment
|
| 78 |
+
# but only if the candidate doesn't exist.
|
| 79 |
+
if "OKIDI-DONT TOUCH" in candidate:
|
| 80 |
+
healed = candidate.replace("OKIDI-DONT TOUCH", "OKIDI-DON'T TOUCH")
|
| 81 |
+
if os.path.exists(healed):
|
| 82 |
+
return healed
|
| 83 |
+
|
| 84 |
+
logger.warning(f"Model file not found at {candidate!r} β ensure 'python download_models.py' has been run.")
|
| 85 |
return candidate
|
| 86 |
|
| 87 |
BALL_RIM_MODEL = _resolve_model_path(settings.swish_ball_rim_model)
|
|
|
|
| 141 |
os.rename(out_path, tmp_path)
|
| 142 |
result = subprocess.run(
|
| 143 |
["ffmpeg", "-y", "-i", tmp_path,
|
| 144 |
+
"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2",
|
| 145 |
"-c:v", "libx264", "-preset", "fast", "-movflags", "+faststart",
|
| 146 |
"-pix_fmt", "yuv420p", "-an", out_path],
|
| 147 |
capture_output=True, timeout=300
|
personal_analysis/test_resolve.py
CHANGED
|
@@ -12,17 +12,20 @@ print(f"DEBUG: _BACKEND_ROOT = {_BACKEND_ROOT}")
|
|
| 12 |
def _resolve_model_path(path: str) -> str:
|
| 13 |
if os.path.isabs(path):
|
| 14 |
return path
|
| 15 |
-
full_path = os.path.abspath(os.path.join(_BACKEND_ROOT, path))
|
| 16 |
-
print(f"DEBUG: checking {full_path} -> {os.path.exists(full_path)}")
|
| 17 |
-
if os.path.exists(full_path):
|
| 18 |
-
return full_path
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
if os.path.exists(
|
| 24 |
-
return
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
rim = _resolve_model_path(settings.swish_ball_rim_model)
|
| 28 |
print(f"FINAL RESOLVED: {rim}")
|
|
|
|
| 12 |
def _resolve_model_path(path: str) -> str:
|
| 13 |
if os.path.isabs(path):
|
| 14 |
return path
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
# Anchor to the backend root (parent of personal_analysis/)
|
| 17 |
+
candidate = os.path.abspath(os.path.join(_BACKEND_ROOT, path))
|
| 18 |
+
|
| 19 |
+
if os.path.exists(candidate):
|
| 20 |
+
return candidate
|
| 21 |
+
|
| 22 |
+
# Robust fallback for 'DON'T TOUCH' mangled environments
|
| 23 |
+
if "OKIDI-DONT TOUCH" in candidate:
|
| 24 |
+
healed = candidate.replace("OKIDI-DONT TOUCH", "OKIDI-DON'T TOUCH")
|
| 25 |
+
if os.path.exists(healed):
|
| 26 |
+
return healed
|
| 27 |
+
|
| 28 |
+
return candidate
|
| 29 |
|
| 30 |
rim = _resolve_model_path(settings.swish_ball_rim_model)
|
| 31 |
print(f"FINAL RESOLVED: {rim}")
|