FUT-HEROS match mode: SAM3 goal detection + RF-DETR ball tracking + goal events + per-strike coaching (ZeroGPU)
Browse files- .gitignore +2 -0
- README.md +25 -4
- app.py +216 -0
- futheros/__init__.py +18 -0
- futheros/adapters/__init__.py +6 -0
- futheros/adapters/coach_llamacpp.py +79 -0
- futheros/adapters/coach_rule.py +12 -0
- futheros/adapters/detector_base.py +79 -0
- futheros/adapters/detector_null.py +14 -0
- futheros/adapters/detector_rfdetr.py +54 -0
- futheros/adapters/detector_yolo.py +33 -0
- futheros/adapters/goal_annotations.py +47 -0
- futheros/adapters/goal_detector_sam3.py +71 -0
- futheros/adapters/infra_config.py +25 -0
- futheros/adapters/pose_mediapipe.py +65 -0
- futheros/adapters/renderer_opencv.py +130 -0
- futheros/adapters/subject_tracking.py +69 -0
- futheros/adapters/tracker_sam3_video.py +142 -0
- futheros/adapters/video_opencv.py +132 -0
- futheros/application/__init__.py +5 -0
- futheros/application/factory.py +65 -0
- futheros/application/match_service.py +231 -0
- futheros/application/service.py +104 -0
- futheros/domain/__init__.py +1 -0
- futheros/domain/biomechanics.py +133 -0
- futheros/domain/coaching.py +66 -0
- futheros/domain/config.py +113 -0
- futheros/domain/contact.py +82 -0
- futheros/domain/geometry.py +25 -0
- futheros/domain/goal.py +328 -0
- futheros/domain/models.py +166 -0
- futheros/domain/trajectory.py +56 -0
- futheros/ports/__init__.py +14 -0
- futheros/ports/coach.py +15 -0
- futheros/ports/detector.py +17 -0
- futheros/ports/goal_detector.py +18 -0
- futheros/ports/pose.py +20 -0
- futheros/ports/renderer.py +26 -0
- futheros/ports/video.py +23 -0
- requirements.txt +9 -0
.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
README.md
CHANGED
|
@@ -1,14 +1,35 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.18.0
|
| 8 |
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
| 11 |
short_description: check if you have the skill to make it the worldcup
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: FUT-HEROS
|
| 3 |
+
emoji: ⚽
|
| 4 |
+
colorFrom: green
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.18.0
|
| 8 |
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
+
license: apache-2.0
|
| 12 |
short_description: check if you have the skill to make it the worldcup
|
| 13 |
---
|
| 14 |
|
| 15 |
+
# ⚽ FUT-HEROS
|
| 16 |
+
|
| 17 |
+
**They measure your shot. You fix it.**
|
| 18 |
+
|
| 19 |
+
Upload a match or highlight clip. FUT-HEROS:
|
| 20 |
+
|
| 21 |
+
1. **Finds the goal post** — zero-shot, SAM3 with the text prompt "goal post"
|
| 22 |
+
2. **Tracks the ball** — RF-DETR Nano (30M params, Apache-2.0), every frame
|
| 23 |
+
3. **Detects your goals** — football-rules event logic: the ball's path crossing the
|
| 24 |
+
goal plane, with kickoff-return suppression (you can't score twice in 5 seconds —
|
| 25 |
+
there's a kickoff in between)
|
| 26 |
+
4. **Finds your scoring kick** — tracks the ball backwards from each goal to the
|
| 27 |
+
leg-ball contact
|
| 28 |
+
5. **Coaches the strike** — biomechanics scorecard (knee bend, trunk lean, plant foot,
|
| 29 |
+
hip drive, follow-through, launch angle) + cues and drills
|
| 30 |
+
|
| 31 |
+
Everything is tiny + open: SAM3 (one image call), RF-DETR Nano, MediaPipe Pose.
|
| 32 |
+
The numbers are coaching-grade bands, not lab degrees — honest by design.
|
| 33 |
+
|
| 34 |
+
Built for the **Build Small Hackathon** (Backyard AI track) by a Sunday-league player
|
| 35 |
+
who wanted to know if his strikes have World Cup form.
|
app.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FUT-HEROS — upload your match clip, it finds your goals and coaches your strikes.
|
| 2 |
+
|
| 3 |
+
ZeroGPU Space: heavy vision (SAM3 goal detection + RF-DETR ball tracking) runs inside
|
| 4 |
+
the @spaces.GPU window; pose, event logic, coaching and rendering run on CPU.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
|
| 11 |
+
import cv2
|
| 12 |
+
import gradio as gr
|
| 13 |
+
import numpy as np
|
| 14 |
+
import spaces
|
| 15 |
+
|
| 16 |
+
from futheros.adapters.pose_mediapipe import MediaPipePose
|
| 17 |
+
from futheros.adapters.renderer_opencv import OpenCvRenderer
|
| 18 |
+
from futheros.adapters.video_opencv import OpenCvVideoSink, OpenCvVideoSource
|
| 19 |
+
from futheros.adapters.coach_rule import RuleCoach
|
| 20 |
+
from futheros.domain import biomechanics, goal as goal_mod, trajectory as trajectory_mod
|
| 21 |
+
from futheros.domain.goal import GoalRegion
|
| 22 |
+
from futheros.domain.models import Box, ContactResult
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# --- startup asset: CLIP BPE vocab for SAM3 (downloaded once, cached) ---
|
| 26 |
+
import urllib.request
|
| 27 |
+
_BPE_LOCAL = "/tmp/bpe_simple_vocab_16e6.txt.gz"
|
| 28 |
+
if not os.path.exists(_BPE_LOCAL):
|
| 29 |
+
urllib.request.urlretrieve(
|
| 30 |
+
"https://github.com/openai/CLIP/raw/main/clip/bpe_simple_vocab_16e6.txt.gz", _BPE_LOCAL)
|
| 31 |
+
os.environ["SAM3_BPE_PATH"] = _BPE_LOCAL
|
| 32 |
+
|
| 33 |
+
MAX_FRAMES = 900 # ~30-40s of footage per analysis (ZeroGPU time budget)
|
| 34 |
+
|
| 35 |
+
_DETECTOR = None
|
| 36 |
+
_GOAL_DET = None
|
| 37 |
+
_POSE = MediaPipePose()
|
| 38 |
+
_COACH = RuleCoach()
|
| 39 |
+
_RENDER = OpenCvRenderer()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _get_models():
|
| 43 |
+
"""Create the GPU models lazily inside the GPU context (ZeroGPU pattern)."""
|
| 44 |
+
global _DETECTOR, _GOAL_DET
|
| 45 |
+
if _DETECTOR is None:
|
| 46 |
+
from futheros.adapters.detector_rfdetr import RFDetrDetector
|
| 47 |
+
_DETECTOR = RFDetrDetector(model_cls="nano")
|
| 48 |
+
if _GOAL_DET is None:
|
| 49 |
+
try:
|
| 50 |
+
from futheros.adapters.goal_detector_sam3 import Sam3GoalDetector
|
| 51 |
+
_GOAL_DET = Sam3GoalDetector(prompt="goal post")
|
| 52 |
+
_GOAL_DET._ensure_loaded()
|
| 53 |
+
except Exception as e: # noqa: BLE001
|
| 54 |
+
print("SAM3 goal detector unavailable:", e)
|
| 55 |
+
_GOAL_DET = False
|
| 56 |
+
return _DETECTOR, _GOAL_DET
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@spaces.GPU(duration=120)
|
| 60 |
+
def gpu_detect(frames: list[np.ndarray]):
|
| 61 |
+
"""Everything that needs the GPU, in one allocation window:
|
| 62 |
+
goal boxes (SAM3, 3 frames) + per-frame ball/person detections (RF-DETR)."""
|
| 63 |
+
det, goal_det = _get_models()
|
| 64 |
+
goal_boxes = []
|
| 65 |
+
if goal_det:
|
| 66 |
+
for fi in {len(frames) // 4, len(frames) // 2, (3 * len(frames)) // 4}:
|
| 67 |
+
try:
|
| 68 |
+
goal_boxes.extend(goal_det.detect_goals(frames[fi], conf=0.45))
|
| 69 |
+
except Exception as e: # noqa: BLE001
|
| 70 |
+
print("goal det err:", e)
|
| 71 |
+
dets = det.detect(frames)
|
| 72 |
+
ball = [[d.ball.cx, d.ball.cy, d.ball.conf, d.ball.radius] if d.ball else None for d in dets]
|
| 73 |
+
return goal_boxes, ball
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def analyze(video_path, progress=gr.Progress()):
|
| 77 |
+
if not video_path:
|
| 78 |
+
raise gr.Error("Upload a clip of your match / highlights first.")
|
| 79 |
+
t0 = time.time()
|
| 80 |
+
|
| 81 |
+
progress(0.05, desc="Loading video…")
|
| 82 |
+
clip = OpenCvVideoSource().load(video_path, max_frames=MAX_FRAMES)
|
| 83 |
+
fps = clip.fps
|
| 84 |
+
|
| 85 |
+
progress(0.15, desc="GPU: locating goal + tracking ball…")
|
| 86 |
+
goal_boxes, ball_raw = gpu_detect(clip.frames)
|
| 87 |
+
|
| 88 |
+
# goal regions (dedupe by location, keep top-2)
|
| 89 |
+
kept = []
|
| 90 |
+
for b in sorted(goal_boxes, key=lambda x: -x.conf):
|
| 91 |
+
if all(abs(b.cx - k.cx) > 40 or abs(b.cy - k.cy) > 40 for k in kept):
|
| 92 |
+
kept.append(b)
|
| 93 |
+
regions = [GoalRegion.from_box(b) for b in kept[:2]]
|
| 94 |
+
if not regions:
|
| 95 |
+
raise gr.Error("No goal post found in this clip — film so a goal is visible.")
|
| 96 |
+
|
| 97 |
+
# rebuild Detection stream
|
| 98 |
+
from futheros.domain.models import Detection
|
| 99 |
+
dets = []
|
| 100 |
+
for bb in ball_raw:
|
| 101 |
+
ball = None
|
| 102 |
+
if bb is not None:
|
| 103 |
+
x, y, c, r = bb
|
| 104 |
+
ball = Box(x - r, y - r, x + r, y + r, c)
|
| 105 |
+
dets.append(Detection(person=None, ball=ball))
|
| 106 |
+
|
| 107 |
+
progress(0.55, desc="Finding your goals…")
|
| 108 |
+
cuts = goal_mod.detect_cuts(clip.frames)
|
| 109 |
+
per_region = [goal_mod.find_goal_events(dets, r, min_gap=int(fps * 2), cuts=cuts, fps=fps)
|
| 110 |
+
for r in regions]
|
| 111 |
+
events = goal_mod.merge_goal_events(per_region, fps=fps, refractory_s=5.0)
|
| 112 |
+
if not events:
|
| 113 |
+
raise gr.Error("No goals detected in this clip. Tip: include the moment the ball "
|
| 114 |
+
"crosses the line, filmed from behind/side of the attack.")
|
| 115 |
+
|
| 116 |
+
progress(0.65, desc=f"{len(events)} goal(s)! Analyzing your strikes…")
|
| 117 |
+
poses = _POSE.estimate(clip.frames)
|
| 118 |
+
|
| 119 |
+
cards = []
|
| 120 |
+
kick_frames = []
|
| 121 |
+
for gi, e in enumerate(events):
|
| 122 |
+
ge = goal_mod.attribute_scorer(dets, poses, e, lookback=int(fps * 4), cuts=cuts)
|
| 123 |
+
head = f"### ⚽ GOAL #{gi + 1} — at {clip.time_of(e):.1f}s"
|
| 124 |
+
if ge is None:
|
| 125 |
+
cards.append(head + "\n\n*Couldn't isolate the kick for this one.*")
|
| 126 |
+
continue
|
| 127 |
+
ci = ge.contact_frame
|
| 128 |
+
kick_frames.append(ci)
|
| 129 |
+
contact = ContactResult(frame_idx=ci, kicking_side=ge.kicking_side,
|
| 130 |
+
method="goal_backtrack", confidence=ge.confidence,
|
| 131 |
+
ball_track=ge.ball_track)
|
| 132 |
+
fs = biomechanics.compute_features(poses, dets, contact)
|
| 133 |
+
traj = trajectory_mod.compute_trajectory(dets, contact)
|
| 134 |
+
if traj.valid:
|
| 135 |
+
fs.features.append(trajectory_mod.launch_angle_feature(traj))
|
| 136 |
+
rep = _COACH.coach(fs)
|
| 137 |
+
rows = "\n".join(
|
| 138 |
+
f"| {f.label} | {'—' if f.value is None else f'{f.value:.0f} {f.unit}'} | "
|
| 139 |
+
f"{ {'good':'🟢','amber':'🟡','poor':'🔴','na':'⚪'}[f.band] } {f.band.upper()} |"
|
| 140 |
+
for f in fs.features)
|
| 141 |
+
cards.append(
|
| 142 |
+
f"{head} · kick at {clip.time_of(ci):.1f}s · **{rep.score}/100**\n\n"
|
| 143 |
+
f"**{rep.summary}**\n\n"
|
| 144 |
+
f"| Feature | Measured | Band |\n|---|---|---|\n{rows}\n\n"
|
| 145 |
+
f"**Cues:** " + " · ".join(rep.cues[:3]) + "\n\n"
|
| 146 |
+
f"**Drills:** " + " · ".join(rep.drills[:2]))
|
| 147 |
+
|
| 148 |
+
progress(0.9, desc="Rendering your highlight video…")
|
| 149 |
+
out_path = _render(clip, dets, regions, events, kick_frames, fps)
|
| 150 |
+
|
| 151 |
+
md = (f"## 🏆 {len(events)} goal(s) found in {time.time()-t0:.0f}s\n\n"
|
| 152 |
+
+ "\n\n---\n\n".join(cards)
|
| 153 |
+
+ "\n\n<sub>FUT-HEROS · zero-shot vision (SAM3 goal + RF-DETR ball) + "
|
| 154 |
+
"football logic + biomechanics. Bands not lab-degrees — honest coaching.</sub>")
|
| 155 |
+
return out_path, md
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _render(clip, dets, regions, events, kick_frames_list, fps):
|
| 159 |
+
kick_frames = set()
|
| 160 |
+
for c in kick_frames_list:
|
| 161 |
+
kick_frames.update(range(max(0, c - int(fps * 0.15)), c + int(fps * 0.35)))
|
| 162 |
+
H, W = clip.frames[0].shape[:2]
|
| 163 |
+
banner = int(fps * 1.6)
|
| 164 |
+
max_jump = 90.0 * max(1.0, 60.0 / fps)
|
| 165 |
+
ball_xy = np.array([[d.ball.cx, d.ball.cy] if d.ball else [np.nan, np.nan]
|
| 166 |
+
for d in dets], np.float32)
|
| 167 |
+
frames_out = []
|
| 168 |
+
trail = []
|
| 169 |
+
for i, f in enumerate(clip.frames):
|
| 170 |
+
v = f.copy()
|
| 171 |
+
for r in regions:
|
| 172 |
+
cv2.polylines(v, [r.poly.astype(np.int32)], True, (0, 0, 255), 2)
|
| 173 |
+
if not np.isnan(ball_xy[i][0]):
|
| 174 |
+
if trail and (i - trail[-1][0] > 5 or np.linalg.norm(ball_xy[i] - trail[-1][1]) > max_jump):
|
| 175 |
+
trail = []
|
| 176 |
+
trail.append((i, ball_xy[i]))
|
| 177 |
+
trail = [(j, q) for j, q in trail if i - j <= int(fps)]
|
| 178 |
+
for k in range(1, len(trail)):
|
| 179 |
+
cv2.line(v, tuple(trail[k - 1][1].astype(int)), tuple(trail[k][1].astype(int)),
|
| 180 |
+
(0, 255, 255), 2)
|
| 181 |
+
if not np.isnan(ball_xy[i][0]):
|
| 182 |
+
cv2.circle(v, tuple(ball_xy[i].astype(int)), 10, (0, 255, 0), 2)
|
| 183 |
+
if i in kick_frames:
|
| 184 |
+
cv2.putText(v, "KICK", (W // 2 - 70, 90), cv2.FONT_HERSHEY_SIMPLEX, 1.6, (0, 140, 255), 4)
|
| 185 |
+
for gidx, e in enumerate(events):
|
| 186 |
+
if e <= i < e + banner:
|
| 187 |
+
cv2.rectangle(v, (0, 0), (W - 1, H - 1), (0, 200, 0), 10)
|
| 188 |
+
cv2.putText(v, f"GOAL #{gidx + 1}", (W // 2 - 130, 60),
|
| 189 |
+
cv2.FONT_HERSHEY_SIMPLEX, 1.6, (0, 230, 0), 4)
|
| 190 |
+
frames_out.append(v)
|
| 191 |
+
out = f"/tmp/futheros_{int(time.time())}.mp4"
|
| 192 |
+
OpenCvVideoSink().write(frames_out, out, fps)
|
| 193 |
+
return out
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
with gr.Blocks(title="FUT-HEROS", theme=gr.themes.Soft(primary_hue="green")) as demo:
|
| 197 |
+
gr.Markdown(
|
| 198 |
+
"# ⚽ FUT-HEROS\n"
|
| 199 |
+
"**Upload your match or highlight clip → it finds the goal post, tracks the ball, "
|
| 200 |
+
"detects YOUR goals, and coaches every scoring strike.**\n\n"
|
| 201 |
+
"Zero-shot vision (SAM3 + RF-DETR Nano) · football-rules event logic · "
|
| 202 |
+
"biomechanics scorecard. Do you have World Cup form?"
|
| 203 |
+
)
|
| 204 |
+
with gr.Row():
|
| 205 |
+
with gr.Column(scale=1):
|
| 206 |
+
vid_in = gr.Video(label="Your clip (≤ ~40s analyzed)", sources=["upload"])
|
| 207 |
+
go = gr.Button("⚽ Find my goals & coach me", variant="primary", size="lg")
|
| 208 |
+
gr.Markdown("*Works best when the goal and the ball are visible — side or corner "
|
| 209 |
+
"view, fixed camera. Highlight montages with cuts are fine.*")
|
| 210 |
+
with gr.Column(scale=2):
|
| 211 |
+
report = gr.Markdown()
|
| 212 |
+
out_vid = gr.Video(label="Your annotated highlights (ball trail · KICK · GOAL)", autoplay=True)
|
| 213 |
+
|
| 214 |
+
go.click(analyze, inputs=[vid_in], outputs=[out_vid, report])
|
| 215 |
+
|
| 216 |
+
demo.queue().launch()
|
futheros/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FUT-HEROS — football kick form coach (hexagonal architecture).
|
| 2 |
+
|
| 3 |
+
Layers:
|
| 4 |
+
domain/ pure analysis (geometry, contact, biomechanics, coaching rules) — no I/O
|
| 5 |
+
ports/ abstract interfaces (Protocols) the application depends on
|
| 6 |
+
adapters/ concrete backends (RF-DETR, YOLO, MediaPipe, llama.cpp, OpenCV)
|
| 7 |
+
application/ the use-case service + composition root (wires adapters to ports)
|
| 8 |
+
|
| 9 |
+
Public API:
|
| 10 |
+
from futheros import build_service, Settings
|
| 11 |
+
svc = build_service(Settings(detector="rfdetr", use_llm=True))
|
| 12 |
+
result = svc.analyze("clip.mp4")
|
| 13 |
+
"""
|
| 14 |
+
from .application import AnalyzeKickService, build_service
|
| 15 |
+
from .application.factory import Settings
|
| 16 |
+
|
| 17 |
+
__version__ = "0.2.0"
|
| 18 |
+
__all__ = ["AnalyzeKickService", "build_service", "Settings"]
|
futheros/adapters/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adapters — concrete implementations of ports (driven side of the hexagon).
|
| 2 |
+
|
| 3 |
+
Each adapter wraps one external dependency (RF-DETR, YOLO, MediaPipe, llama.cpp,
|
| 4 |
+
OpenCV) behind a port. Imports are kept inside constructors so a missing optional
|
| 5 |
+
backend doesn't break the package import; the composition root handles fallback.
|
| 6 |
+
"""
|
futheros/adapters/coach_llamacpp.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""llama-cpp-python coach adapter — runs a tiny GGUF (Nemotron-3-Nano-4B primary,
|
| 2 |
+
Qwen3-4B fallback). The LLM only ever sees the feature numbers, never the video.
|
| 3 |
+
On malformed output it falls back to the pure rule-based report.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
|
| 9 |
+
from ..domain.biomechanics import overall_score
|
| 10 |
+
from ..domain.coaching import SYSTEM_PROMPT, build_user_prompt, rule_based_report
|
| 11 |
+
from ..domain.models import CoachReport, FeatureSet
|
| 12 |
+
from .infra_config import LLM_CANDIDATES, LLM_N_CTX, LLM_N_GPU_LAYERS
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class LlamaCppCoach:
|
| 16 |
+
def __init__(self, llm, name: str):
|
| 17 |
+
self._llm = llm
|
| 18 |
+
self.name = name
|
| 19 |
+
|
| 20 |
+
@classmethod
|
| 21 |
+
def load(cls, candidates=LLM_CANDIDATES) -> "LlamaCppCoach":
|
| 22 |
+
"""Try each GGUF candidate in order; raise if none load."""
|
| 23 |
+
from llama_cpp import Llama
|
| 24 |
+
last_err: Exception | None = None
|
| 25 |
+
for c in candidates:
|
| 26 |
+
try:
|
| 27 |
+
llm = Llama.from_pretrained(
|
| 28 |
+
repo_id=c["repo_id"], filename=c["filename"],
|
| 29 |
+
n_gpu_layers=LLM_N_GPU_LAYERS, n_ctx=LLM_N_CTX, verbose=False,
|
| 30 |
+
)
|
| 31 |
+
return cls(llm, c["name"])
|
| 32 |
+
except Exception as e: # noqa: BLE001
|
| 33 |
+
last_err = e
|
| 34 |
+
raise RuntimeError(f"No coaching LLM could be loaded: {last_err}")
|
| 35 |
+
|
| 36 |
+
def coach(self, feature_set: FeatureSet) -> CoachReport:
|
| 37 |
+
score = overall_score(feature_set)
|
| 38 |
+
try:
|
| 39 |
+
out = self._llm.create_chat_completion(
|
| 40 |
+
messages=[
|
| 41 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 42 |
+
{"role": "user", "content": build_user_prompt(feature_set)},
|
| 43 |
+
],
|
| 44 |
+
temperature=0.4, max_tokens=512,
|
| 45 |
+
response_format={"type": "json_object"},
|
| 46 |
+
)
|
| 47 |
+
data = _parse_json(out["choices"][0]["message"]["content"])
|
| 48 |
+
except Exception:
|
| 49 |
+
data = None
|
| 50 |
+
|
| 51 |
+
if not data or "summary" not in data:
|
| 52 |
+
rep = rule_based_report(feature_set)
|
| 53 |
+
rep.engine = f"{self.name}+rule-fallback"
|
| 54 |
+
return rep
|
| 55 |
+
|
| 56 |
+
return CoachReport(
|
| 57 |
+
summary=str(data.get("summary", "")).strip(),
|
| 58 |
+
cues=[str(c).strip() for c in data.get("cues", []) if str(c).strip()][:4],
|
| 59 |
+
drills=[str(d).strip() for d in data.get("drills", []) if str(d).strip()][:3],
|
| 60 |
+
score=score, engine=self.name,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _parse_json(text: str):
|
| 65 |
+
text = (text or "").strip()
|
| 66 |
+
if text.startswith("```"):
|
| 67 |
+
text = text.strip("`")
|
| 68 |
+
if text.lower().startswith("json"):
|
| 69 |
+
text = text[4:]
|
| 70 |
+
try:
|
| 71 |
+
return json.loads(text)
|
| 72 |
+
except Exception:
|
| 73 |
+
a, b = text.find("{"), text.rfind("}")
|
| 74 |
+
if a != -1 and b > a:
|
| 75 |
+
try:
|
| 76 |
+
return json.loads(text[a:b + 1])
|
| 77 |
+
except Exception:
|
| 78 |
+
return None
|
| 79 |
+
return None
|
futheros/adapters/coach_rule.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rule-based coach adapter — wraps the pure domain coaching logic. Always available."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from ..domain.coaching import rule_based_report
|
| 5 |
+
from ..domain.models import CoachReport, FeatureSet
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class RuleCoach:
|
| 9 |
+
name = "rule"
|
| 10 |
+
|
| 11 |
+
def coach(self, feature_set: FeatureSet) -> CoachReport:
|
| 12 |
+
return rule_based_report(feature_set)
|
futheros/adapters/detector_base.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared detection post-processing: pick the subject person, pick the ball, and
|
| 2 |
+
temporally interpolate the ball track. Concrete backends supply only raw per-frame
|
| 3 |
+
boxes; this module turns them into the Detection stream the domain expects.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
from ..domain.config import BALL_MAX_INTERP_GAP
|
| 10 |
+
from ..domain.models import Box, Detection
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def pick_person(persons: list[Box], prev: Box | None) -> Box | None:
|
| 14 |
+
if not persons:
|
| 15 |
+
return None
|
| 16 |
+
if prev is not None:
|
| 17 |
+
return min(persons, key=lambda b: (b.cx - prev.cx) ** 2 + (b.cy - prev.cy) ** 2)
|
| 18 |
+
return max(persons, key=lambda b: b.area)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def pick_ball(balls: list[Box], person: Box | None) -> Box | None:
|
| 22 |
+
if not balls:
|
| 23 |
+
return None
|
| 24 |
+
if person is not None:
|
| 25 |
+
diag = ((person.x2 - person.x1) ** 2 + (person.y2 - person.y1) ** 2) ** 0.5 + 1e-6
|
| 26 |
+
|
| 27 |
+
def score(b: Box) -> float:
|
| 28 |
+
d = ((b.cx - person.cx) ** 2 + (b.cy - person.cy) ** 2) ** 0.5
|
| 29 |
+
return b.conf - 0.15 * (d / diag)
|
| 30 |
+
return max(balls, key=score)
|
| 31 |
+
return max(balls, key=lambda b: b.conf)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def interpolate_balls(dets: list[Detection], max_gap: int = BALL_MAX_INTERP_GAP) -> None:
|
| 35 |
+
idxs = [i for i, d in enumerate(dets) if d.ball is not None]
|
| 36 |
+
for a, b in zip(idxs, idxs[1:]):
|
| 37 |
+
gap = b - a
|
| 38 |
+
if 1 < gap <= max_gap + 1:
|
| 39 |
+
ba, bb = dets[a].ball, dets[b].ball
|
| 40 |
+
for k in range(1, gap):
|
| 41 |
+
t = k / gap
|
| 42 |
+
dets[a + k].ball = Box(
|
| 43 |
+
ba.x1 + t * (bb.x1 - ba.x1), ba.y1 + t * (bb.y1 - ba.y1),
|
| 44 |
+
ba.x2 + t * (bb.x2 - ba.x2), ba.y2 + t * (bb.y2 - ba.y2),
|
| 45 |
+
min(ba.conf, bb.conf) * 0.5,
|
| 46 |
+
)
|
| 47 |
+
dets[a + k].ball_interpolated = True
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def assemble(per_frame_boxes, track: bool = True) -> list[Detection]:
|
| 51 |
+
"""per_frame_boxes: iterable of (persons:list[Box], balls:list[Box]).
|
| 52 |
+
|
| 53 |
+
When `track`, lock onto the kicker via ByteTrack (robust to crowds); otherwise
|
| 54 |
+
fall back to greedy nearest/largest person selection.
|
| 55 |
+
"""
|
| 56 |
+
frames = [(list(p), list(b)) for p, b in per_frame_boxes]
|
| 57 |
+
# best ball per frame, independent of person (needed before subject selection)
|
| 58 |
+
best_balls = [max(b, key=lambda x: x.conf) if b else None for _, b in frames]
|
| 59 |
+
|
| 60 |
+
subject_boxes = None
|
| 61 |
+
if track:
|
| 62 |
+
try:
|
| 63 |
+
from .subject_tracking import lock_subject
|
| 64 |
+
subject_boxes = lock_subject([p for p, _ in frames], best_balls)
|
| 65 |
+
except Exception:
|
| 66 |
+
subject_boxes = None
|
| 67 |
+
|
| 68 |
+
dets: list[Detection] = []
|
| 69 |
+
prev_person: Box | None = None
|
| 70 |
+
for i, (persons, balls) in enumerate(frames):
|
| 71 |
+
if subject_boxes is not None:
|
| 72 |
+
person = subject_boxes[i]
|
| 73 |
+
else:
|
| 74 |
+
person = pick_person(persons, prev_person)
|
| 75 |
+
if person is not None:
|
| 76 |
+
prev_person = person
|
| 77 |
+
dets.append(Detection(person=person, ball=pick_ball(balls, person)))
|
| 78 |
+
interpolate_balls(dets)
|
| 79 |
+
return dets
|
futheros/adapters/detector_null.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Null detector — used when no detection backend is available. The pipeline then
|
| 2 |
+
runs pose-only; ball-dependent features degrade to 'na' (honest, not crashing)."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from ..domain.models import Detection
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class NullDetector:
|
| 11 |
+
name = "none"
|
| 12 |
+
|
| 13 |
+
def detect(self, frames_bgr: list[np.ndarray]) -> list[Detection]:
|
| 14 |
+
return [Detection() for _ in frames_bgr]
|
futheros/adapters/detector_rfdetr.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RF-DETR detector adapter (primary). COCO person+ball, Apache-2.0, DINOv2.
|
| 2 |
+
|
| 3 |
+
RF-DETR emits 91-class COCO label ids (person=1, sports ball=37) — NOT the 80-class
|
| 4 |
+
contiguous ids YOLO uses (person=0, ball=32). We resolve the ids from RF-DETR's own
|
| 5 |
+
label map by name so the adapter is correct regardless of the indexing convention.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from ..domain.config import BALL_CONF, PERSON_CONF
|
| 12 |
+
from ..domain.models import Box, Detection
|
| 13 |
+
from .detector_base import assemble
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _resolve_ids():
|
| 17 |
+
"""Map 'person' and 'sports ball' -> RF-DETR class ids via its COCO label map."""
|
| 18 |
+
from rfdetr.util.coco_classes import COCO_CLASSES
|
| 19 |
+
items = COCO_CLASSES.items() if isinstance(COCO_CLASSES, dict) else enumerate(COCO_CLASSES)
|
| 20 |
+
name_to_id = {n: i for i, n in items}
|
| 21 |
+
return name_to_id.get("person", 1), name_to_id.get("sports ball", 37)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class RFDetrDetector:
|
| 25 |
+
name = "rfdetr"
|
| 26 |
+
|
| 27 |
+
def __init__(self, model_cls: str = "nano", resolution: int | None = None):
|
| 28 |
+
import rfdetr
|
| 29 |
+
registry = {"nano": rfdetr.RFDETRNano, "small": rfdetr.RFDETRSmall,
|
| 30 |
+
"medium": rfdetr.RFDETRMedium, "large": rfdetr.RFDETRLarge}
|
| 31 |
+
if model_cls in ("xlarge", "2xlarge"):
|
| 32 |
+
# XL/2XL ship in the rfdetr-plus extra (PML 1.0 license, not Apache)
|
| 33 |
+
import rfdetr_plus
|
| 34 |
+
registry["xlarge"] = rfdetr_plus.RFDETRXLarge
|
| 35 |
+
registry["2xlarge"] = rfdetr_plus.RFDETR2XLarge
|
| 36 |
+
cls = registry[model_cls]
|
| 37 |
+
self._model = cls(resolution=resolution) if resolution else cls()
|
| 38 |
+
self._person_id, self._ball_id = _resolve_ids()
|
| 39 |
+
self.name = f"rfdetr-{model_cls}"
|
| 40 |
+
|
| 41 |
+
def _frame_boxes(self, frame_rgb: np.ndarray):
|
| 42 |
+
det = self._model.predict(frame_rgb, threshold=BALL_CONF)
|
| 43 |
+
persons, balls = [], []
|
| 44 |
+
for box, c, cf in zip(det.xyxy, det.class_id, det.confidence):
|
| 45 |
+
b = Box(float(box[0]), float(box[1]), float(box[2]), float(box[3]), float(cf))
|
| 46 |
+
if int(c) == self._person_id and cf >= PERSON_CONF:
|
| 47 |
+
persons.append(b)
|
| 48 |
+
elif int(c) == self._ball_id and cf >= BALL_CONF:
|
| 49 |
+
balls.append(b)
|
| 50 |
+
return persons, balls
|
| 51 |
+
|
| 52 |
+
def detect(self, frames_bgr: list[np.ndarray]) -> list[Detection]:
|
| 53 |
+
# contiguous copy: torchvision.to_tensor rejects the negative stride from [..., ::-1]
|
| 54 |
+
return assemble(self._frame_boxes(np.ascontiguousarray(f[:, :, ::-1])) for f in frames_bgr)
|
futheros/adapters/detector_yolo.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ultralytics YOLO11 detector adapter (fallback). COCO person+ball."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
|
| 6 |
+
from ..domain.config import BALL_CONF, COCO_PERSON, COCO_SPORTS_BALL, PERSON_CONF
|
| 7 |
+
from ..domain.models import Box, Detection
|
| 8 |
+
from .detector_base import assemble
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class YoloDetector:
|
| 12 |
+
name = "yolo"
|
| 13 |
+
|
| 14 |
+
def __init__(self, weights: str = "yolo11n.pt"):
|
| 15 |
+
from ultralytics import YOLO
|
| 16 |
+
self._model = YOLO(weights)
|
| 17 |
+
|
| 18 |
+
def _frame_boxes(self, frame_rgb: np.ndarray):
|
| 19 |
+
res = self._model.predict(frame_rgb, conf=BALL_CONF,
|
| 20 |
+
classes=[COCO_PERSON, COCO_SPORTS_BALL], verbose=False)[0]
|
| 21 |
+
persons, balls = [], []
|
| 22 |
+
for box in res.boxes:
|
| 23 |
+
c = int(box.cls[0]); cf = float(box.conf[0])
|
| 24 |
+
xy = box.xyxy[0].tolist()
|
| 25 |
+
b = Box(xy[0], xy[1], xy[2], xy[3], cf)
|
| 26 |
+
if c == COCO_PERSON and cf >= PERSON_CONF:
|
| 27 |
+
persons.append(b)
|
| 28 |
+
elif c == COCO_SPORTS_BALL:
|
| 29 |
+
balls.append(b)
|
| 30 |
+
return persons, balls
|
| 31 |
+
|
| 32 |
+
def detect(self, frames_bgr: list[np.ndarray]) -> list[Detection]:
|
| 33 |
+
return assemble(self._frame_boxes(f[:, :, ::-1]) for f in frames_bgr)
|
futheros/adapters/goal_annotations.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persistent goal-mouth annotations.
|
| 2 |
+
|
| 3 |
+
The cameras are fixed, so the goal mouth is annotated ONCE per video (a 4-point
|
| 4 |
+
polygon = the goal plane as projected in the image) and recorded in
|
| 5 |
+
annotations/goals.json keyed by the video's basename. Every later run reuses it —
|
| 6 |
+
no re-annotation, no SAM3 dependency for annotated videos.
|
| 7 |
+
|
| 8 |
+
Schema:
|
| 9 |
+
{
|
| 10 |
+
"<video basename>": [
|
| 11 |
+
{"name": "goal-1", "polygon": [[x,y],[x,y],[x,y],[x,y]]},
|
| 12 |
+
...
|
| 13 |
+
]
|
| 14 |
+
}
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
|
| 24 |
+
ANNOT_FILE = Path(__file__).resolve().parents[2] / "annotations" / "goals.json"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _load_all() -> dict:
|
| 28 |
+
if ANNOT_FILE.exists():
|
| 29 |
+
return json.loads(ANNOT_FILE.read_text())
|
| 30 |
+
return {}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def load_goal_polygons(video_path: str) -> list[np.ndarray]:
|
| 34 |
+
"""Polygons annotated for this video (possibly empty)."""
|
| 35 |
+
key = os.path.basename(video_path)
|
| 36 |
+
entries = _load_all().get(key, [])
|
| 37 |
+
return [np.array(e["polygon"], dtype=np.float32) for e in entries]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def save_goal_polygons(video_path: str, polygons: list[np.ndarray]) -> str:
|
| 41 |
+
key = os.path.basename(video_path)
|
| 42 |
+
data = _load_all()
|
| 43 |
+
data[key] = [{"name": f"goal-{i+1}", "polygon": np.asarray(p).astype(float).tolist()}
|
| 44 |
+
for i, p in enumerate(polygons)]
|
| 45 |
+
ANNOT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 46 |
+
ANNOT_FILE.write_text(json.dumps(data, indent=2))
|
| 47 |
+
return str(ANNOT_FILE)
|
futheros/adapters/goal_detector_sam3.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SAM3 zero-shot goal detector (facebook/sam3 via Meta's `sam3` package).
|
| 2 |
+
|
| 3 |
+
Text-prompted: prompt "goal post" returns the goal-mouth box(es) with no annotation
|
| 4 |
+
or fine-tuning. Validated on indoor (0.72) and outdoor (0.65–0.87) footage. The model
|
| 5 |
+
is heavy (~1.3GB, slow first load) so we load it lazily and, since the match camera is
|
| 6 |
+
fixed, the caller runs it once per clip.
|
| 7 |
+
|
| 8 |
+
Weights download from HF — needs HUGGINGFACE_TOKEN in the environment / .env.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
from PIL import Image
|
| 16 |
+
|
| 17 |
+
from ..domain.models import Box
|
| 18 |
+
|
| 19 |
+
def _find_bpe() -> str | None:
|
| 20 |
+
"""Locate the CLIP BPE vocab SAM3 needs (its own default asset path is missing).
|
| 21 |
+
Order: SAM3_BPE_PATH env -> assets/ next to the package root -> `clip` package."""
|
| 22 |
+
p = os.environ.get("SAM3_BPE_PATH")
|
| 23 |
+
if p and os.path.exists(p):
|
| 24 |
+
return p
|
| 25 |
+
here = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 26 |
+
p = os.path.join(here, "assets", "bpe_simple_vocab_16e6.txt.gz")
|
| 27 |
+
if os.path.exists(p):
|
| 28 |
+
return p
|
| 29 |
+
try:
|
| 30 |
+
import clip
|
| 31 |
+
p = os.path.join(os.path.dirname(clip.__file__), "bpe_simple_vocab_16e6.txt.gz")
|
| 32 |
+
return p if os.path.exists(p) else None
|
| 33 |
+
except Exception:
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class Sam3GoalDetector:
|
| 38 |
+
name = "sam3-goal"
|
| 39 |
+
|
| 40 |
+
def __init__(self, prompt: str = "goal post", device: str = "cuda"):
|
| 41 |
+
self.prompt = prompt
|
| 42 |
+
self._device = device
|
| 43 |
+
self._processor = None
|
| 44 |
+
|
| 45 |
+
def _ensure_loaded(self):
|
| 46 |
+
if self._processor is not None:
|
| 47 |
+
return
|
| 48 |
+
# surface HF token for the weight download
|
| 49 |
+
tok = os.environ.get("HUGGINGFACE_TOKEN") or os.environ.get("HF_TOKEN")
|
| 50 |
+
if tok:
|
| 51 |
+
os.environ.setdefault("HF_TOKEN", tok)
|
| 52 |
+
os.environ.setdefault("HUGGING_FACE_HUB_TOKEN", tok)
|
| 53 |
+
from sam3.model_builder import build_sam3_image_model
|
| 54 |
+
from sam3.model.sam3_image_processor import Sam3Processor
|
| 55 |
+
model = build_sam3_image_model(bpe_path=_find_bpe(), device=self._device, load_from_HF=True)
|
| 56 |
+
self._processor = Sam3Processor(model)
|
| 57 |
+
|
| 58 |
+
def detect_goals(self, frame_bgr: np.ndarray, conf: float = 0.4) -> list[Box]:
|
| 59 |
+
self._ensure_loaded()
|
| 60 |
+
image = Image.fromarray(frame_bgr[:, :, ::-1])
|
| 61 |
+
state = self._processor.set_image(image)
|
| 62 |
+
out = self._processor.set_text_prompt(state=state, prompt=self.prompt)
|
| 63 |
+
boxes = out["boxes"]; scores = out["scores"]
|
| 64 |
+
boxes = boxes.cpu().numpy() if hasattr(boxes, "cpu") else np.asarray(boxes)
|
| 65 |
+
scores = scores.cpu().numpy() if hasattr(scores, "cpu") else np.asarray(scores)
|
| 66 |
+
result: list[Box] = []
|
| 67 |
+
for b, s in zip(boxes, scores):
|
| 68 |
+
if float(s) >= conf:
|
| 69 |
+
result.append(Box(float(b[0]), float(b[1]), float(b[2]), float(b[3]), float(s)))
|
| 70 |
+
result.sort(key=lambda bx: bx.conf, reverse=True)
|
| 71 |
+
return result
|
futheros/adapters/infra_config.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Infrastructure config — model repos, download URLs, GPU settings.
|
| 2 |
+
|
| 3 |
+
Kept in the adapters layer (not the domain) because these are deployment concerns.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
MODELS_DIR = Path(__file__).resolve().parents[2] / "models"
|
| 10 |
+
|
| 11 |
+
# MediaPipe Pose Landmarker (Tasks API) .task bundle
|
| 12 |
+
POSE_TASK_URL = (
|
| 13 |
+
"https://storage.googleapis.com/mediapipe-models/pose_landmarker/"
|
| 14 |
+
"pose_landmarker_full/float16/latest/pose_landmarker_full.task"
|
| 15 |
+
)
|
| 16 |
+
POSE_TASK_FILE = MODELS_DIR / "pose_landmarker_full.task"
|
| 17 |
+
|
| 18 |
+
# Coaching LLM GGUF candidates (tried in order). Primary = Nemotron (Quest+Tiny Titan).
|
| 19 |
+
LLM_CANDIDATES = [
|
| 20 |
+
{"repo_id": "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF", "filename": "*Q4_K_M.gguf", "name": "Nemotron-3-Nano-4B"},
|
| 21 |
+
{"repo_id": "unsloth/Qwen3-4B-Instruct-2507-GGUF", "filename": "*Q4_K_M.gguf", "name": "Qwen3-4B-Instruct"},
|
| 22 |
+
{"repo_id": "Qwen/Qwen2.5-3B-Instruct-GGUF", "filename": "*q4_k_m.gguf", "name": "Qwen2.5-3B-Instruct"},
|
| 23 |
+
]
|
| 24 |
+
LLM_N_CTX = 4096
|
| 25 |
+
LLM_N_GPU_LAYERS = -1 # offload all layers to the GPU
|
futheros/adapters/pose_mediapipe.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MediaPipe Pose adapter using the current Tasks API (PoseLandmarker).
|
| 2 |
+
|
| 3 |
+
mediapipe >=0.10.x dropped the legacy `mp.solutions.pose` module in favour of the
|
| 4 |
+
Tasks API, which needs a `.task` model bundle. We auto-download it once to models/.
|
| 5 |
+
We use only the 2D (x,y) image-plane landmarks + visibility — never the Z axis.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import urllib.request
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
from ..domain.models import FramePose
|
| 14 |
+
from .infra_config import POSE_TASK_FILE, POSE_TASK_URL
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _ensure_model() -> str:
|
| 18 |
+
if not POSE_TASK_FILE.exists():
|
| 19 |
+
POSE_TASK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 20 |
+
urllib.request.urlretrieve(POSE_TASK_URL, POSE_TASK_FILE)
|
| 21 |
+
return str(POSE_TASK_FILE)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class MediaPipePose:
|
| 25 |
+
name = "mediapipe-pose"
|
| 26 |
+
|
| 27 |
+
def __init__(self):
|
| 28 |
+
import mediapipe as mp
|
| 29 |
+
from mediapipe.tasks import python
|
| 30 |
+
from mediapipe.tasks.python import vision
|
| 31 |
+
|
| 32 |
+
self._mp = mp
|
| 33 |
+
model_path = _ensure_model()
|
| 34 |
+
options = vision.PoseLandmarkerOptions(
|
| 35 |
+
base_options=python.BaseOptions(model_asset_path=model_path),
|
| 36 |
+
running_mode=vision.RunningMode.IMAGE,
|
| 37 |
+
num_poses=1,
|
| 38 |
+
min_pose_detection_confidence=0.5,
|
| 39 |
+
min_pose_presence_confidence=0.5,
|
| 40 |
+
min_tracking_confidence=0.5,
|
| 41 |
+
output_segmentation_masks=False,
|
| 42 |
+
)
|
| 43 |
+
self._landmarker = vision.PoseLandmarker.create_from_options(options)
|
| 44 |
+
|
| 45 |
+
def estimate(self, frames_bgr: list[np.ndarray]) -> list[FramePose]:
|
| 46 |
+
out: list[FramePose] = []
|
| 47 |
+
for f in frames_bgr:
|
| 48 |
+
h, w = f.shape[:2]
|
| 49 |
+
rgb = np.ascontiguousarray(f[:, :, ::-1])
|
| 50 |
+
mp_image = self._mp.Image(image_format=self._mp.ImageFormat.SRGB, data=rgb)
|
| 51 |
+
res = self._landmarker.detect(mp_image)
|
| 52 |
+
if not res.pose_landmarks:
|
| 53 |
+
out.append(FramePose(None, None))
|
| 54 |
+
continue
|
| 55 |
+
lms = res.pose_landmarks[0]
|
| 56 |
+
xy = np.array([[lm.x * w, lm.y * h] for lm in lms], dtype=np.float32)
|
| 57 |
+
vis = np.array([lm.visibility for lm in lms], dtype=np.float32)
|
| 58 |
+
out.append(FramePose(xy=xy, vis=vis))
|
| 59 |
+
return out
|
| 60 |
+
|
| 61 |
+
def close(self) -> None:
|
| 62 |
+
try:
|
| 63 |
+
self._landmarker.close()
|
| 64 |
+
except Exception:
|
| 65 |
+
pass
|
futheros/adapters/renderer_opencv.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenCV renderer adapter: skeleton overlay, ball marker, contact flash, hero
|
| 2 |
+
freeze-frame, scorecard. Pure cv2/numpy — no extra deps.
|
| 3 |
+
|
| 4 |
+
A SAM 3D Body mesh (optional "wow" hero shot) composites in via hero(..., mesh_img=).
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import cv2
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from ..domain.config import LM
|
| 12 |
+
from ..domain.models import (Clip, ContactResult, Detection, FeatureSet, FramePose,
|
| 13 |
+
Trajectory)
|
| 14 |
+
|
| 15 |
+
_EDGES = [
|
| 16 |
+
("l_shoulder", "r_shoulder"), ("l_shoulder", "l_hip"), ("r_shoulder", "r_hip"),
|
| 17 |
+
("l_hip", "r_hip"),
|
| 18 |
+
("l_shoulder", "l_elbow"), ("r_shoulder", "r_elbow"),
|
| 19 |
+
("l_hip", "l_knee"), ("l_knee", "l_ankle"), ("l_ankle", "l_foot"),
|
| 20 |
+
("r_hip", "r_knee"), ("r_knee", "r_ankle"), ("r_ankle", "r_foot"),
|
| 21 |
+
]
|
| 22 |
+
_BAND_BGR = {"good": (90, 200, 90), "amber": (40, 190, 240),
|
| 23 |
+
"poor": (60, 60, 235), "na": (150, 150, 150)}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _p(pose: FramePose, name: str):
|
| 27 |
+
if pose.xy is None:
|
| 28 |
+
return None
|
| 29 |
+
i = LM[name]
|
| 30 |
+
if pose.vis is not None and pose.vis[i] < 0.2:
|
| 31 |
+
return None
|
| 32 |
+
return tuple(int(v) for v in pose.xy[i])
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class OpenCvRenderer:
|
| 36 |
+
def _draw(self, frame, pose, det, kicking_side, is_contact=False):
|
| 37 |
+
img = frame.copy()
|
| 38 |
+
if pose.ok():
|
| 39 |
+
for a, b in _EDGES:
|
| 40 |
+
pa, pb = _p(pose, a), _p(pose, b)
|
| 41 |
+
if pa and pb:
|
| 42 |
+
on_kick = a.startswith(kicking_side) or b.startswith(kicking_side)
|
| 43 |
+
cv2.line(img, pa, pb, (0, 215, 255) if on_kick else (235, 235, 235), 2, cv2.LINE_AA)
|
| 44 |
+
for name in LM:
|
| 45 |
+
pt = _p(pose, name)
|
| 46 |
+
if pt:
|
| 47 |
+
cv2.circle(img, pt, 3, (0, 140, 255), -1, cv2.LINE_AA)
|
| 48 |
+
if det.ball is not None:
|
| 49 |
+
c = (int(det.ball.cx), int(det.ball.cy))
|
| 50 |
+
r = max(4, int(det.ball.radius))
|
| 51 |
+
cv2.circle(img, c, r, (0, 255, 0) if not det.ball_interpolated else (0, 200, 120), 2, cv2.LINE_AA)
|
| 52 |
+
if is_contact:
|
| 53 |
+
h, w = img.shape[:2]
|
| 54 |
+
cv2.rectangle(img, (0, 0), (w - 1, h - 1), (0, 0, 255), 6)
|
| 55 |
+
cv2.putText(img, "CONTACT", (12, 40), cv2.FONT_HERSHEY_SIMPLEX, 1.1, (0, 0, 255), 3, cv2.LINE_AA)
|
| 56 |
+
return img
|
| 57 |
+
|
| 58 |
+
def _draw_trajectory(self, img, trajectory: Trajectory | None, upto: int | None = None):
|
| 59 |
+
"""Draw the ball path as a fading polyline. `upto` limits to frames <= upto
|
| 60 |
+
(so the trail grows over the video); None draws the whole post-contact arc."""
|
| 61 |
+
if trajectory is None or not trajectory.valid:
|
| 62 |
+
return
|
| 63 |
+
pts = [(i, x, y) for (i, x, y) in trajectory.post_points if upto is None or i <= upto]
|
| 64 |
+
if len(pts) < 2:
|
| 65 |
+
return
|
| 66 |
+
for j in range(1, len(pts)):
|
| 67 |
+
p0 = (int(pts[j - 1][1]), int(pts[j - 1][2]))
|
| 68 |
+
p1 = (int(pts[j][1]), int(pts[j][2]))
|
| 69 |
+
t = j / len(pts)
|
| 70 |
+
col = (0, int(120 + 135 * t), 255) # orange→yellow trail
|
| 71 |
+
cv2.line(img, p0, p1, col, max(2, int(2 + 3 * t)), cv2.LINE_AA)
|
| 72 |
+
cv2.circle(img, (int(pts[-1][1]), int(pts[-1][2])), 5, (0, 255, 255), -1, cv2.LINE_AA)
|
| 73 |
+
|
| 74 |
+
def annotate(self, clip: Clip, poses, dets, fs: FeatureSet, contact: ContactResult,
|
| 75 |
+
trajectory: Trajectory | None = None):
|
| 76 |
+
out = []
|
| 77 |
+
for i, f in enumerate(clip.frames):
|
| 78 |
+
img = self._draw(f, poses[i], dets[i], fs.kicking_side, is_contact=(i == contact.frame_idx))
|
| 79 |
+
if i >= contact.frame_idx:
|
| 80 |
+
self._draw_trajectory(img, trajectory, upto=i)
|
| 81 |
+
out.append(img)
|
| 82 |
+
return out
|
| 83 |
+
|
| 84 |
+
def hero(self, clip: Clip, poses, dets, fs: FeatureSet, contact: ContactResult,
|
| 85 |
+
trajectory: Trajectory | None = None, mesh_img: np.ndarray | None = None):
|
| 86 |
+
ci = contact.frame_idx
|
| 87 |
+
base = self._draw(clip.frames[ci], poses[ci], dets[ci], fs.kicking_side, is_contact=True)
|
| 88 |
+
self._draw_trajectory(base, trajectory, upto=None)
|
| 89 |
+
if trajectory is not None and trajectory.valid:
|
| 90 |
+
cv2.putText(base, f"Launch: {trajectory.launch_angle_deg:.0f} deg {trajectory.direction}",
|
| 91 |
+
(12, base.shape[0] - 18), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 0), 4, cv2.LINE_AA)
|
| 92 |
+
cv2.putText(base, f"Launch: {trajectory.launch_angle_deg:.0f} deg {trajectory.direction}",
|
| 93 |
+
(12, base.shape[0] - 18), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 1, cv2.LINE_AA)
|
| 94 |
+
y0 = 70
|
| 95 |
+
for f in fs.features[:3]:
|
| 96 |
+
col = _BAND_BGR.get(f.band, (200, 200, 200))
|
| 97 |
+
txt = f"{f.label}: {f.band.upper()}"
|
| 98 |
+
if f.value is not None:
|
| 99 |
+
txt += f" ({f.value:.0f}{'' if f.unit in ('shank', 'leg') else 'deg'})"
|
| 100 |
+
cv2.putText(base, txt, (12, y0), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 4, cv2.LINE_AA)
|
| 101 |
+
cv2.putText(base, txt, (12, y0), cv2.FONT_HERSHEY_SIMPLEX, 0.6, col, 1, cv2.LINE_AA)
|
| 102 |
+
y0 += 28
|
| 103 |
+
if mesh_img is not None:
|
| 104 |
+
h = base.shape[0]
|
| 105 |
+
mesh_r = cv2.resize(mesh_img, (int(mesh_img.shape[1] * h / mesh_img.shape[0]), h))
|
| 106 |
+
base = np.hstack([base, mesh_r])
|
| 107 |
+
return base
|
| 108 |
+
|
| 109 |
+
def scorecard(self, fs: FeatureSet, score: int, width: int = 720):
|
| 110 |
+
rows, row_h, head_h = len(fs.features), 64, 110
|
| 111 |
+
h = head_h + rows * row_h + 24
|
| 112 |
+
img = np.full((h, width, 3), 250, np.uint8)
|
| 113 |
+
cv2.rectangle(img, (0, 0), (width, head_h), (38, 28, 22), -1)
|
| 114 |
+
cv2.putText(img, "FUT-HEROS Form Scorecard", (24, 46),
|
| 115 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2, cv2.LINE_AA)
|
| 116 |
+
sc_col = (90, 200, 90) if score >= 80 else (40, 190, 240) if score >= 55 else (60, 60, 235)
|
| 117 |
+
cv2.putText(img, f"{score}", (width - 150, 64), cv2.FONT_HERSHEY_SIMPLEX, 1.8, sc_col, 4, cv2.LINE_AA)
|
| 118 |
+
cv2.putText(img, "/100", (width - 70, 64), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (200, 200, 200), 2, cv2.LINE_AA)
|
| 119 |
+
y = head_h + 12
|
| 120 |
+
for f in fs.features:
|
| 121 |
+
col = _BAND_BGR.get(f.band, (150, 150, 150))
|
| 122 |
+
cv2.putText(img, f.label, (24, y + 24), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (40, 40, 40), 1, cv2.LINE_AA)
|
| 123 |
+
fill = {"good": 1.0, "amber": 0.55, "poor": 0.2, "na": 0.0}[f.band]
|
| 124 |
+
bx, bw = 24, width - 48
|
| 125 |
+
cv2.rectangle(img, (bx, y + 34), (bx + bw, y + 50), (225, 225, 225), -1)
|
| 126 |
+
cv2.rectangle(img, (bx, y + 34), (bx + int(bw * fill), y + 50), col, -1)
|
| 127 |
+
label = f.band.upper() if f.band != "na" else "NOT VISIBLE"
|
| 128 |
+
cv2.putText(img, label, (bx + bw - 130, y + 47), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (30, 30, 30), 1, cv2.LINE_AA)
|
| 129 |
+
y += row_h
|
| 130 |
+
return img
|
futheros/adapters/subject_tracking.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Subject lock via ByteTrack (supervision).
|
| 2 |
+
|
| 3 |
+
In a crowded frame, "which person is the kicker?" can't be answered by size alone.
|
| 4 |
+
We give every person a stable track id with ByteTrack, then lock onto the track whose
|
| 5 |
+
foot gets *closest to the ball* across the whole clip — that's the striker. The chosen
|
| 6 |
+
track's box is returned per frame, so pose/features follow one person, not whoever is
|
| 7 |
+
biggest. Falls back cleanly if supervision is unavailable.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
from ..domain.models import Box
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def lock_subject(persons_per_frame: list[list[Box]],
|
| 17 |
+
balls_per_frame: list[Box | None]) -> list[Box | None] | None:
|
| 18 |
+
"""Return the subject's Box per frame (or None where absent). None if tracking
|
| 19 |
+
can't run (caller then falls back to greedy selection)."""
|
| 20 |
+
import supervision as sv
|
| 21 |
+
|
| 22 |
+
n = len(persons_per_frame)
|
| 23 |
+
tracker = sv.ByteTrack()
|
| 24 |
+
frame_tracks: list[dict[int, Box]] = []
|
| 25 |
+
|
| 26 |
+
for persons in persons_per_frame:
|
| 27 |
+
if persons:
|
| 28 |
+
det = sv.Detections(
|
| 29 |
+
xyxy=np.array([[b.x1, b.y1, b.x2, b.y2] for b in persons], dtype=float),
|
| 30 |
+
confidence=np.array([b.conf for b in persons], dtype=float),
|
| 31 |
+
class_id=np.zeros(len(persons), dtype=int),
|
| 32 |
+
)
|
| 33 |
+
else:
|
| 34 |
+
det = sv.Detections.empty()
|
| 35 |
+
det = tracker.update_with_detections(det)
|
| 36 |
+
|
| 37 |
+
tracks: dict[int, Box] = {}
|
| 38 |
+
if det.tracker_id is not None:
|
| 39 |
+
for k in range(len(det)):
|
| 40 |
+
tid = int(det.tracker_id[k])
|
| 41 |
+
x1, y1, x2, y2 = det.xyxy[k]
|
| 42 |
+
cf = float(det.confidence[k]) if det.confidence is not None else 0.0
|
| 43 |
+
tracks[tid] = Box(float(x1), float(y1), float(x2), float(y2), cf)
|
| 44 |
+
frame_tracks.append(tracks)
|
| 45 |
+
|
| 46 |
+
# subject = track whose foot (box bottom-centre) gets nearest the ball
|
| 47 |
+
track_min: dict[int, float] = {}
|
| 48 |
+
for i, tracks in enumerate(frame_tracks):
|
| 49 |
+
ball = balls_per_frame[i]
|
| 50 |
+
if ball is None:
|
| 51 |
+
continue
|
| 52 |
+
for tid, box in tracks.items():
|
| 53 |
+
foot = np.array([(box.x1 + box.x2) / 2, box.y2])
|
| 54 |
+
d = float(np.linalg.norm(foot - ball.center))
|
| 55 |
+
track_min[tid] = min(track_min.get(tid, 1e18), d)
|
| 56 |
+
|
| 57 |
+
if track_min:
|
| 58 |
+
subject_tid = min(track_min, key=track_min.get)
|
| 59 |
+
else:
|
| 60 |
+
# no ball anywhere -> the track closest to camera (largest cumulative area)
|
| 61 |
+
area: dict[int, float] = {}
|
| 62 |
+
for tracks in frame_tracks:
|
| 63 |
+
for tid, box in tracks.items():
|
| 64 |
+
area[tid] = area.get(tid, 0.0) + box.area
|
| 65 |
+
if not area:
|
| 66 |
+
return None
|
| 67 |
+
subject_tid = max(area, key=area.get)
|
| 68 |
+
|
| 69 |
+
return [frame_tracks[i].get(subject_tid) for i in range(n)]
|
futheros/adapters/tracker_sam3_video.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SAM3 VIDEO tracker — text-prompted, zero-shot, with temporal memory.
|
| 2 |
+
|
| 3 |
+
Prompt once (e.g. "ball"); SAM3 segments matching objects and TRACKS each with a
|
| 4 |
+
stable object id across the whole clip, surviving blur/occlusion via its memory.
|
| 5 |
+
This natively provides what per-frame detectors need heuristics for: identity over
|
| 6 |
+
time. The real match ball is then simply the object that moves the most.
|
| 7 |
+
|
| 8 |
+
Heavy model: built lazily, weights from HF (needs HUGGINGFACE_TOKEN).
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _find_bpe() -> str | None:
|
| 18 |
+
try:
|
| 19 |
+
import clip
|
| 20 |
+
p = os.path.join(os.path.dirname(clip.__file__), "bpe_simple_vocab_16e6.txt.gz")
|
| 21 |
+
return p if os.path.exists(p) else None
|
| 22 |
+
except Exception:
|
| 23 |
+
return None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class Sam3VideoTracker:
|
| 27 |
+
name = "sam3-video"
|
| 28 |
+
|
| 29 |
+
def __init__(self):
|
| 30 |
+
self._predictor = None
|
| 31 |
+
|
| 32 |
+
def _ensure_loaded(self):
|
| 33 |
+
if self._predictor is not None:
|
| 34 |
+
return
|
| 35 |
+
tok = os.environ.get("HUGGINGFACE_TOKEN") or os.environ.get("HF_TOKEN")
|
| 36 |
+
if tok:
|
| 37 |
+
os.environ.setdefault("HF_TOKEN", tok)
|
| 38 |
+
os.environ.setdefault("HUGGING_FACE_HUB_TOKEN", tok)
|
| 39 |
+
from sam3.model_builder import build_sam3_video_predictor
|
| 40 |
+
self._predictor = build_sam3_video_predictor(bpe_path=_find_bpe())
|
| 41 |
+
|
| 42 |
+
# SAM3 (this release) copies the WHOLE video to GPU regardless of its
|
| 43 |
+
# offload_video_to_cpu flag (_construct_initial_input_batch sends the full
|
| 44 |
+
# img_batch to device). ~1000 frames ≈ 12GB+, OOMing a 24GB card. So long
|
| 45 |
+
# clips are tracked in CHUNKS with overlap: SAM3 suppresses unconfirmed
|
| 46 |
+
# objects for its first ~1-2s (hotstart), so each chunk overlaps the previous
|
| 47 |
+
# one and the warm-up region is discarded.
|
| 48 |
+
CHUNK = 600
|
| 49 |
+
OVERLAP = 100
|
| 50 |
+
|
| 51 |
+
def track(self, video_path: str, prompt: str = "ball",
|
| 52 |
+
prompt_frame: int = 0) -> np.ndarray:
|
| 53 |
+
"""Track all objects matching `prompt` through the video.
|
| 54 |
+
|
| 55 |
+
Returns an (N, 7) float32 array: [frame_idx, obj_id, cx, cy, prob, w, h]
|
| 56 |
+
(cx, cy, w, h normalized 0..1).
|
| 57 |
+
"""
|
| 58 |
+
import cv2
|
| 59 |
+
cap = cv2.VideoCapture(str(video_path))
|
| 60 |
+
n = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 61 |
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
| 62 |
+
cap.release()
|
| 63 |
+
|
| 64 |
+
if n <= self.CHUNK + self.OVERLAP:
|
| 65 |
+
return self._track_one(str(video_path), prompt, prompt_frame, frame_offset=0,
|
| 66 |
+
keep_from=0)
|
| 67 |
+
|
| 68 |
+
import subprocess
|
| 69 |
+
import tempfile
|
| 70 |
+
rows_all = []
|
| 71 |
+
with tempfile.TemporaryDirectory(prefix="sam3chunks_") as tmp:
|
| 72 |
+
for s in range(0, n, self.CHUNK):
|
| 73 |
+
cs = max(0, s - self.OVERLAP) if s > 0 else 0
|
| 74 |
+
ce = min(n, s + self.CHUNK)
|
| 75 |
+
chunk_path = os.path.join(tmp, f"chunk_{s}.mp4")
|
| 76 |
+
subprocess.run(
|
| 77 |
+
["ffmpeg", "-y", "-loglevel", "error",
|
| 78 |
+
"-ss", f"{cs / fps:.4f}", "-i", str(video_path),
|
| 79 |
+
"-frames:v", str(ce - cs),
|
| 80 |
+
"-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
|
| 81 |
+
"-pix_fmt", "yuv420p", chunk_path],
|
| 82 |
+
check=True)
|
| 83 |
+
part = self._track_one(chunk_path, prompt, prompt_frame=0,
|
| 84 |
+
frame_offset=cs, keep_from=s)
|
| 85 |
+
if len(part):
|
| 86 |
+
rows_all.append(part)
|
| 87 |
+
return (np.concatenate(rows_all, axis=0) if rows_all
|
| 88 |
+
else np.zeros((0, 7), dtype=np.float32))
|
| 89 |
+
|
| 90 |
+
def _track_one(self, video_path: str, prompt: str, prompt_frame: int,
|
| 91 |
+
frame_offset: int, keep_from: int) -> np.ndarray:
|
| 92 |
+
self._ensure_loaded()
|
| 93 |
+
p = self._predictor
|
| 94 |
+
resp = p.handle_request(request=dict(type="start_session", resource_path=video_path))
|
| 95 |
+
sid = resp["session_id"]
|
| 96 |
+
try:
|
| 97 |
+
p.handle_request(request=dict(type="add_prompt", session_id=sid,
|
| 98 |
+
frame_index=prompt_frame, text=prompt))
|
| 99 |
+
rows = []
|
| 100 |
+
for item in p.handle_stream_request(dict(type="propagate_in_video", session_id=sid)):
|
| 101 |
+
out = item["outputs"]
|
| 102 |
+
if out is None:
|
| 103 |
+
continue
|
| 104 |
+
fi = frame_offset + item["frame_index"]
|
| 105 |
+
if fi < keep_from:
|
| 106 |
+
continue # warm-up overlap region, covered by previous chunk
|
| 107 |
+
for oid, prob, box in zip(out["out_obj_ids"], out["out_probs"], out["out_boxes_xywh"]):
|
| 108 |
+
x, y, w, h = [float(v) for v in box]
|
| 109 |
+
rows.append([fi, int(oid), x + w / 2, y + h / 2, float(prob), w, h])
|
| 110 |
+
finally:
|
| 111 |
+
try:
|
| 112 |
+
p.handle_request(request=dict(type="close_session", session_id=sid))
|
| 113 |
+
except Exception:
|
| 114 |
+
pass
|
| 115 |
+
return np.array(rows, dtype=np.float32) if rows else np.zeros((0, 7), dtype=np.float32)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def pick_moving_object(track: np.ndarray, top_k: int = 1) -> list[int]:
|
| 119 |
+
"""Object id(s) with the largest total displacement — the real ball moves,
|
| 120 |
+
static lookalikes (pitch logos, goal frames) do not."""
|
| 121 |
+
scores = {}
|
| 122 |
+
for oid in set(track[:, 1].astype(int)) if len(track) else []:
|
| 123 |
+
sub = track[track[:, 1] == oid]
|
| 124 |
+
if len(sub) < 2:
|
| 125 |
+
continue
|
| 126 |
+
scores[oid] = float(np.abs(np.diff(sub[:, 2])).sum() + np.abs(np.diff(sub[:, 3])).sum())
|
| 127 |
+
return [oid for oid, _ in sorted(scores.items(), key=lambda kv: -kv[1])[:top_k]]
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def to_ball_xy(track: np.ndarray, n_frames: int, obj_ids: list[int] | None = None) -> np.ndarray:
|
| 131 |
+
"""Collapse the multi-object track to a per-frame ball position (n_frames, 2).
|
| 132 |
+
If obj_ids given, only those objects are considered; highest prob wins per frame."""
|
| 133 |
+
ball = np.full((n_frames, 2), np.nan, dtype=np.float32)
|
| 134 |
+
best = np.full(n_frames, -1.0, dtype=np.float32)
|
| 135 |
+
for r in track:
|
| 136 |
+
fi, oid, cx, cy, prob = int(r[0]), int(r[1]), r[2], r[3], r[4]
|
| 137 |
+
if obj_ids is not None and oid not in obj_ids:
|
| 138 |
+
continue
|
| 139 |
+
if 0 <= fi < n_frames and prob > best[fi]:
|
| 140 |
+
best[fi] = prob
|
| 141 |
+
ball[fi] = [cx, cy]
|
| 142 |
+
return ball
|
futheros/adapters/video_opencv.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenCV/ffmpeg video I/O adapter: load+auto-rotate to upright frames, write mp4.
|
| 2 |
+
|
| 3 |
+
Phone clips often carry a rotation in container metadata (and WhatsApp re-encodes
|
| 4 |
+
sideways). We read the rotation via ffprobe and apply it so the pose math sees an
|
| 5 |
+
upright person; a UI control can override it.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import shutil
|
| 11 |
+
import subprocess
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import cv2
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
from ..domain.config import MAX_FRAMES
|
| 18 |
+
from ..domain.models import Clip
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class OpenCvVideoSource:
|
| 22 |
+
def load(self, path: str, force_rotation: int | None = None,
|
| 23 |
+
window: tuple[float, float] | None = None,
|
| 24 |
+
max_frames: int = MAX_FRAMES) -> Clip:
|
| 25 |
+
"""Load a clip into upright BGR frames.
|
| 26 |
+
|
| 27 |
+
window=(start_s, end_s) loads only that time span at FULL fps (used when the
|
| 28 |
+
user pins their kick moment) — accurate and fast. Without a window we load the
|
| 29 |
+
whole clip, subsampling only if it exceeds max_frames.
|
| 30 |
+
"""
|
| 31 |
+
path = str(path)
|
| 32 |
+
rot = force_rotation if force_rotation is not None else _ffprobe_rotation(path)
|
| 33 |
+
|
| 34 |
+
cap = cv2.VideoCapture(path)
|
| 35 |
+
if not cap.isOpened():
|
| 36 |
+
raise RuntimeError(f"Could not open video: {path}")
|
| 37 |
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
| 38 |
+
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
|
| 39 |
+
|
| 40 |
+
if window is not None:
|
| 41 |
+
start_s, end_s = max(0.0, window[0]), window[1]
|
| 42 |
+
start_idx = int(start_s * fps)
|
| 43 |
+
end_idx = int(end_s * fps)
|
| 44 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, start_idx)
|
| 45 |
+
frames: list[np.ndarray] = []
|
| 46 |
+
idx = start_idx
|
| 47 |
+
while idx <= end_idx and len(frames) < max_frames:
|
| 48 |
+
ok, frame = cap.read()
|
| 49 |
+
if not ok:
|
| 50 |
+
break
|
| 51 |
+
frames.append(_apply_rotation(frame, rot))
|
| 52 |
+
idx += 1
|
| 53 |
+
cap.release()
|
| 54 |
+
if not frames:
|
| 55 |
+
raise RuntimeError(f"No frames decoded from {path} in window {window}")
|
| 56 |
+
h, w = frames[0].shape[:2]
|
| 57 |
+
return Clip(frames=frames, fps=fps, width=w, height=h, t0_s=start_idx / fps)
|
| 58 |
+
|
| 59 |
+
step = int(np.ceil(total / max_frames)) if total and total > max_frames else 1
|
| 60 |
+
frames = []
|
| 61 |
+
idx = 0
|
| 62 |
+
while True:
|
| 63 |
+
ok, frame = cap.read()
|
| 64 |
+
if not ok:
|
| 65 |
+
break
|
| 66 |
+
if idx % step == 0:
|
| 67 |
+
frames.append(_apply_rotation(frame, rot))
|
| 68 |
+
idx += 1
|
| 69 |
+
if len(frames) >= max_frames:
|
| 70 |
+
break
|
| 71 |
+
cap.release()
|
| 72 |
+
if not frames:
|
| 73 |
+
raise RuntimeError(f"No frames decoded from {path}")
|
| 74 |
+
h, w = frames[0].shape[:2]
|
| 75 |
+
return Clip(frames=frames, fps=fps / step, width=w, height=h)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class OpenCvVideoSink:
|
| 79 |
+
def write(self, frames_bgr: list[np.ndarray], out_path: str, fps: float) -> str:
|
| 80 |
+
out_path = str(out_path)
|
| 81 |
+
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
| 82 |
+
h, w = frames_bgr[0].shape[:2]
|
| 83 |
+
|
| 84 |
+
if shutil.which("ffmpeg"):
|
| 85 |
+
cmd = ["ffmpeg", "-y", "-loglevel", "error", "-f", "rawvideo",
|
| 86 |
+
"-pix_fmt", "bgr24", "-s", f"{w}x{h}", "-r", f"{fps:.4f}", "-i", "-",
|
| 87 |
+
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", out_path]
|
| 88 |
+
try:
|
| 89 |
+
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
| 90 |
+
for f in frames_bgr:
|
| 91 |
+
proc.stdin.write(np.ascontiguousarray(f).tobytes())
|
| 92 |
+
proc.stdin.close()
|
| 93 |
+
proc.wait()
|
| 94 |
+
if proc.returncode == 0 and Path(out_path).exists():
|
| 95 |
+
return out_path
|
| 96 |
+
except Exception:
|
| 97 |
+
pass
|
| 98 |
+
|
| 99 |
+
vw = cv2.VideoWriter(out_path, cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h))
|
| 100 |
+
for f in frames_bgr:
|
| 101 |
+
vw.write(f)
|
| 102 |
+
vw.release()
|
| 103 |
+
return out_path
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _ffprobe_rotation(path: str) -> int:
|
| 107 |
+
if not shutil.which("ffprobe"):
|
| 108 |
+
return 0
|
| 109 |
+
try:
|
| 110 |
+
out = subprocess.run(
|
| 111 |
+
["ffprobe", "-v", "quiet", "-print_format", "json",
|
| 112 |
+
"-show_streams", "-select_streams", "v:0", path],
|
| 113 |
+
capture_output=True, text=True, timeout=20).stdout
|
| 114 |
+
st = (json.loads(out).get("streams") or [{}])[0]
|
| 115 |
+
rot = int((st.get("tags") or {}).get("rotate", 0) or 0)
|
| 116 |
+
for sd in st.get("side_data_list", []) or []:
|
| 117 |
+
if "rotation" in sd:
|
| 118 |
+
rot = int(sd["rotation"])
|
| 119 |
+
return rot % 360
|
| 120 |
+
except Exception:
|
| 121 |
+
return 0
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _apply_rotation(frame: np.ndarray, rot: int) -> np.ndarray:
|
| 125 |
+
rot = rot % 360
|
| 126 |
+
if rot in (90, -270):
|
| 127 |
+
return cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)
|
| 128 |
+
if rot in (180, -180):
|
| 129 |
+
return cv2.rotate(frame, cv2.ROTATE_180)
|
| 130 |
+
if rot in (270, -90):
|
| 131 |
+
return cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE)
|
| 132 |
+
return frame
|
futheros/application/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application layer: the use-case service + the composition root that wires it."""
|
| 2 |
+
from .factory import build_service
|
| 3 |
+
from .service import AnalyzeKickService
|
| 4 |
+
|
| 5 |
+
__all__ = ["AnalyzeKickService", "build_service"]
|
futheros/application/factory.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Composition root — the only place that knows about concrete adapters.
|
| 2 |
+
|
| 3 |
+
Picks backends from settings, handles graceful fallback (RF-DETR→YOLO→null,
|
| 4 |
+
LLM→rule), and injects everything into AnalyzeKickService. The rest of the app
|
| 5 |
+
talks to ports only.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
|
| 12 |
+
from .service import AnalyzeKickService
|
| 13 |
+
|
| 14 |
+
log = logging.getLogger("futheros")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class Settings:
|
| 19 |
+
detector: str = "rfdetr" # rfdetr | yolo
|
| 20 |
+
use_llm: bool = True
|
| 21 |
+
out_dir: str | None = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _build_detector(prefer: str):
|
| 25 |
+
order = ["rfdetr", "yolo"] if prefer == "rfdetr" else ["yolo", "rfdetr"]
|
| 26 |
+
for name in order:
|
| 27 |
+
try:
|
| 28 |
+
if name == "rfdetr":
|
| 29 |
+
from ..adapters.detector_rfdetr import RFDetrDetector
|
| 30 |
+
return RFDetrDetector()
|
| 31 |
+
from ..adapters.detector_yolo import YoloDetector
|
| 32 |
+
return YoloDetector()
|
| 33 |
+
except Exception as e: # noqa: BLE001
|
| 34 |
+
log.warning("detector %s unavailable: %s", name, e)
|
| 35 |
+
from ..adapters.detector_null import NullDetector
|
| 36 |
+
log.warning("no detector available — running pose-only")
|
| 37 |
+
return NullDetector()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _build_coach(use_llm: bool):
|
| 41 |
+
if use_llm:
|
| 42 |
+
try:
|
| 43 |
+
from ..adapters.coach_llamacpp import LlamaCppCoach
|
| 44 |
+
return LlamaCppCoach.load()
|
| 45 |
+
except Exception as e: # noqa: BLE001
|
| 46 |
+
log.warning("LLM coach unavailable, using rule coach: %s", e)
|
| 47 |
+
from ..adapters.coach_rule import RuleCoach
|
| 48 |
+
return RuleCoach()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def build_service(settings: Settings | None = None) -> AnalyzeKickService:
|
| 52 |
+
s = settings or Settings()
|
| 53 |
+
from ..adapters.pose_mediapipe import MediaPipePose
|
| 54 |
+
from ..adapters.renderer_opencv import OpenCvRenderer
|
| 55 |
+
from ..adapters.video_opencv import OpenCvVideoSink, OpenCvVideoSource
|
| 56 |
+
|
| 57 |
+
return AnalyzeKickService(
|
| 58 |
+
source=OpenCvVideoSource(),
|
| 59 |
+
sink=OpenCvVideoSink(),
|
| 60 |
+
detector=_build_detector(s.detector),
|
| 61 |
+
pose=MediaPipePose(),
|
| 62 |
+
coach=_build_coach(s.use_llm),
|
| 63 |
+
renderer=OpenCvRenderer(),
|
| 64 |
+
out_dir=s.out_dir,
|
| 65 |
+
)
|
futheros/application/match_service.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Match-mode use case: a match/highlight clip in → goals found → each scoring kick
|
| 2 |
+
analyzed and coached.
|
| 3 |
+
|
| 4 |
+
The validated detection recipe (8/8 on the test clips):
|
| 5 |
+
RF-DETR ball+player per frame → static-FP cleaning → cut-aware continuous runs →
|
| 6 |
+
goal planes (user annotation if present, else SAM3 zero-shot) → path-crossing +
|
| 7 |
+
flight-extrapolation → kickoff-return suppression → goal events →
|
| 8 |
+
backward walk to the leg-ball contact → pose on a BALL-CENTERED CROP at contact
|
| 9 |
+
(so the skeleton lands on the kicker, not the most prominent player) →
|
| 10 |
+
biomechanics features → coaching report per goal.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
import tempfile
|
| 16 |
+
import time
|
| 17 |
+
from dataclasses import dataclass, field
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Callable
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
from ..domain import biomechanics, goal as goal_mod, trajectory as trajectory_mod
|
| 24 |
+
from ..domain.goal import GoalRegion
|
| 25 |
+
from ..domain.models import (Box, CoachReport, ContactResult, FeatureSet, FramePose)
|
| 26 |
+
|
| 27 |
+
log = logging.getLogger("futheros")
|
| 28 |
+
Progress = Callable[[float, str], None]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class GoalAnalysis:
|
| 33 |
+
goal_time_s: float
|
| 34 |
+
kick_time_s: float | None
|
| 35 |
+
feature_set: FeatureSet | None
|
| 36 |
+
report: CoachReport | None
|
| 37 |
+
kick_frame_img: np.ndarray | None = None # annotated contact frame (BGR)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class MatchResult:
|
| 42 |
+
n_goals: int
|
| 43 |
+
goals: list[GoalAnalysis] = field(default_factory=list)
|
| 44 |
+
annotated_video_path: str | None = None
|
| 45 |
+
diagnostics: dict = field(default_factory=dict)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class MatchAnalysisService:
|
| 49 |
+
"""Composed like AnalyzeKickService but for whole-match/highlight clips."""
|
| 50 |
+
|
| 51 |
+
def __init__(self, *, source, sink, detector, pose, coach, renderer,
|
| 52 |
+
goal_detector=None, out_dir: str | None = None):
|
| 53 |
+
self.source = source
|
| 54 |
+
self.sink = sink
|
| 55 |
+
self.detector = detector
|
| 56 |
+
self.pose = pose
|
| 57 |
+
self.coach = coach
|
| 58 |
+
self.renderer = renderer
|
| 59 |
+
self.goal_detector = goal_detector # SAM3 image-mode (optional)
|
| 60 |
+
self.out_dir = Path(out_dir or tempfile.mkdtemp(prefix="futheros_match_"))
|
| 61 |
+
self.out_dir.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
|
| 63 |
+
# ---------- goal regions ----------
|
| 64 |
+
def _goal_regions(self, clip, video_path: str) -> list[GoalRegion]:
|
| 65 |
+
from ..adapters.goal_annotations import load_goal_polygons
|
| 66 |
+
polys = load_goal_polygons(video_path)
|
| 67 |
+
if polys:
|
| 68 |
+
return [GoalRegion(p) for p in polys]
|
| 69 |
+
if self.goal_detector is None:
|
| 70 |
+
return []
|
| 71 |
+
boxes: list[Box] = []
|
| 72 |
+
for fi in {clip.n // 4, clip.n // 2, (3 * clip.n) // 4}:
|
| 73 |
+
try:
|
| 74 |
+
boxes.extend(self.goal_detector.detect_goals(clip.frames[fi], conf=0.45))
|
| 75 |
+
except Exception as e: # noqa: BLE001
|
| 76 |
+
log.warning("goal detection failed on frame %s: %s", fi, e)
|
| 77 |
+
# dedupe by IoU-ish proximity, keep best-conf per location
|
| 78 |
+
kept: list[Box] = []
|
| 79 |
+
for b in sorted(boxes, key=lambda x: -x.conf):
|
| 80 |
+
if all(abs(b.cx - k.cx) > 40 or abs(b.cy - k.cy) > 40 for k in kept):
|
| 81 |
+
kept.append(b)
|
| 82 |
+
return [GoalRegion.from_box(b) for b in kept[:2]]
|
| 83 |
+
|
| 84 |
+
# ---------- kicker pose on ball-centered crop ----------
|
| 85 |
+
def _kicker_pose(self, frame: np.ndarray, ball: Box | None) -> FramePose:
|
| 86 |
+
"""Run pose on a crop around the ball so the skeleton lands on the kicker.
|
| 87 |
+
Falls back to full-frame pose when no ball is known."""
|
| 88 |
+
h, w = frame.shape[:2]
|
| 89 |
+
if ball is None:
|
| 90 |
+
return self.pose.estimate([frame])[0]
|
| 91 |
+
half = int(max(120, ball.radius * 14))
|
| 92 |
+
x0 = max(0, int(ball.cx) - half); x1 = min(w, int(ball.cx) + half)
|
| 93 |
+
y0 = max(0, int(ball.cy) - int(half * 1.3)); y1 = min(h, int(ball.cy) + half // 2)
|
| 94 |
+
crop = np.ascontiguousarray(frame[y0:y1, x0:x1])
|
| 95 |
+
if crop.size == 0:
|
| 96 |
+
return self.pose.estimate([frame])[0]
|
| 97 |
+
p = self.pose.estimate([crop])[0]
|
| 98 |
+
if p.xy is not None:
|
| 99 |
+
p.xy[:, 0] += x0
|
| 100 |
+
p.xy[:, 1] += y0
|
| 101 |
+
return p
|
| 102 |
+
|
| 103 |
+
# ---------- main ----------
|
| 104 |
+
def analyze(self, clip_path: str, *, progress: Progress = lambda f, m: None,
|
| 105 |
+
max_frames: int = 2400) -> MatchResult:
|
| 106 |
+
t0 = time.time()
|
| 107 |
+
diag = {"detector": getattr(self.detector, "name", "?")}
|
| 108 |
+
|
| 109 |
+
progress(0.05, "Loading video…")
|
| 110 |
+
clip = self.source.load(clip_path, max_frames=max_frames)
|
| 111 |
+
fps = clip.fps
|
| 112 |
+
diag["n_frames"], diag["fps"] = clip.n, round(fps, 1)
|
| 113 |
+
|
| 114 |
+
progress(0.12, "Locating the goal…")
|
| 115 |
+
regions = self._goal_regions(clip, clip_path)
|
| 116 |
+
diag["goal_regions"] = len(regions)
|
| 117 |
+
if not regions:
|
| 118 |
+
return MatchResult(n_goals=0, diagnostics={**diag, "error": "no goal found"})
|
| 119 |
+
|
| 120 |
+
progress(0.2, f"Tracking ball + players ({diag['detector']})…")
|
| 121 |
+
dets = self.detector.detect(clip.frames)
|
| 122 |
+
diag["ball_frames"] = sum(1 for d in dets if d.ball is not None)
|
| 123 |
+
|
| 124 |
+
progress(0.55, "Finding your goals…")
|
| 125 |
+
cuts = goal_mod.detect_cuts(clip.frames)
|
| 126 |
+
per_region = [goal_mod.find_goal_events(dets, r, min_gap=int(fps * 2),
|
| 127 |
+
cuts=cuts, fps=fps) for r in regions]
|
| 128 |
+
events = goal_mod.merge_goal_events(per_region, fps=fps, refractory_s=5.0)
|
| 129 |
+
diag["goals"] = [round(e / fps, 1) for e in events]
|
| 130 |
+
|
| 131 |
+
progress(0.65, "Analyzing each scoring kick…")
|
| 132 |
+
# full-frame pose pass once (for kick attribution along the timeline)
|
| 133 |
+
poses = self.pose.estimate(clip.frames)
|
| 134 |
+
|
| 135 |
+
goals: list[GoalAnalysis] = []
|
| 136 |
+
for e in events:
|
| 137 |
+
ge = goal_mod.attribute_scorer(dets, poses, e, lookback=int(fps * 4), cuts=cuts)
|
| 138 |
+
ga = GoalAnalysis(goal_time_s=clip.time_of(e), kick_time_s=None,
|
| 139 |
+
feature_set=None, report=None)
|
| 140 |
+
if ge is not None:
|
| 141 |
+
ci = ge.contact_frame
|
| 142 |
+
ga.kick_time_s = clip.time_of(ci)
|
| 143 |
+
contact = ContactResult(frame_idx=ci, kicking_side=ge.kicking_side,
|
| 144 |
+
method="goal_backtrack", confidence=ge.confidence,
|
| 145 |
+
ball_track=ge.ball_track)
|
| 146 |
+
# precise pose on the kicker (ball-centered crop) for the features
|
| 147 |
+
kick_pose = self._kicker_pose(clip.frames[ci], dets[ci].ball)
|
| 148 |
+
poses_for_features = list(poses)
|
| 149 |
+
poses_for_features[ci] = kick_pose
|
| 150 |
+
fs = biomechanics.compute_features(poses_for_features, dets, contact)
|
| 151 |
+
traj = trajectory_mod.compute_trajectory(dets, contact)
|
| 152 |
+
if traj.valid:
|
| 153 |
+
fs.features.append(trajectory_mod.launch_angle_feature(traj))
|
| 154 |
+
ga.feature_set = fs
|
| 155 |
+
ga.report = self.coach.coach(fs)
|
| 156 |
+
ga.kick_frame_img = self.renderer.hero(clip, poses_for_features, dets, fs, contact)
|
| 157 |
+
goals.append(ga)
|
| 158 |
+
|
| 159 |
+
progress(0.9, "Rendering annotated video…")
|
| 160 |
+
ann = self._render_match(clip, dets, regions, events,
|
| 161 |
+
[int(g.kick_time_s * fps) for g in goals
|
| 162 |
+
if g.kick_time_s is not None], fps)
|
| 163 |
+
out_video = str(self.out_dir / f"match_{int(t0)}.mp4")
|
| 164 |
+
self.sink.write(ann, out_video, fps)
|
| 165 |
+
|
| 166 |
+
diag["elapsed_s"] = round(time.time() - t0, 1)
|
| 167 |
+
progress(1.0, "Done")
|
| 168 |
+
return MatchResult(n_goals=len(events), goals=goals,
|
| 169 |
+
annotated_video_path=out_video, diagnostics=diag)
|
| 170 |
+
|
| 171 |
+
def _render_match(self, clip, dets, regions, events, kick_frames_list, fps):
|
| 172 |
+
import cv2
|
| 173 |
+
kick_frames = set()
|
| 174 |
+
for c in kick_frames_list:
|
| 175 |
+
kick_frames.update(range(max(0, c - int(fps * 0.15)), c + int(fps * 0.35)))
|
| 176 |
+
H, W = clip.frames[0].shape[:2]
|
| 177 |
+
banner = int(fps * 1.6)
|
| 178 |
+
max_jump = 90.0 * max(1.0, 60.0 / fps)
|
| 179 |
+
ball_xy = np.array([[d.ball.cx, d.ball.cy] if d.ball else [np.nan, np.nan]
|
| 180 |
+
for d in dets], np.float32)
|
| 181 |
+
out = []
|
| 182 |
+
trail: list[tuple[int, np.ndarray]] = []
|
| 183 |
+
for i, f in enumerate(clip.frames):
|
| 184 |
+
v = f.copy()
|
| 185 |
+
for r in regions:
|
| 186 |
+
cv2.polylines(v, [r.poly.astype(np.int32)], True, (0, 0, 255), 2)
|
| 187 |
+
if not np.isnan(ball_xy[i][0]):
|
| 188 |
+
if trail and (i - trail[-1][0] > 5 or
|
| 189 |
+
np.linalg.norm(ball_xy[i] - trail[-1][1]) > max_jump):
|
| 190 |
+
trail = []
|
| 191 |
+
trail.append((i, ball_xy[i]))
|
| 192 |
+
trail = [(j, q) for j, q in trail if i - j <= int(fps)]
|
| 193 |
+
for k in range(1, len(trail)):
|
| 194 |
+
cv2.line(v, tuple(trail[k - 1][1].astype(int)),
|
| 195 |
+
tuple(trail[k][1].astype(int)), (0, 255, 255), 2)
|
| 196 |
+
if not np.isnan(ball_xy[i][0]):
|
| 197 |
+
cv2.circle(v, tuple(ball_xy[i].astype(int)), 10, (0, 255, 0), 2)
|
| 198 |
+
if i in kick_frames:
|
| 199 |
+
cv2.putText(v, "KICK", (W // 2 - 70, 90), cv2.FONT_HERSHEY_SIMPLEX,
|
| 200 |
+
1.6, (0, 140, 255), 4)
|
| 201 |
+
for gidx, e in enumerate(events):
|
| 202 |
+
if e <= i < e + banner:
|
| 203 |
+
cv2.rectangle(v, (0, 0), (W - 1, H - 1), (0, 200, 0), 10)
|
| 204 |
+
cv2.putText(v, f"GOAL #{gidx + 1}", (W // 2 - 130, 60),
|
| 205 |
+
cv2.FONT_HERSHEY_SIMPLEX, 1.6, (0, 230, 0), 4)
|
| 206 |
+
out.append(v)
|
| 207 |
+
return out
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def build_match_service(detector_cls: str = "nano", use_llm: bool = False,
|
| 211 |
+
use_sam3_goal: bool = True, out_dir: str | None = None) -> MatchAnalysisService:
|
| 212 |
+
"""Composition root for match mode."""
|
| 213 |
+
from ..adapters.pose_mediapipe import MediaPipePose
|
| 214 |
+
from ..adapters.renderer_opencv import OpenCvRenderer
|
| 215 |
+
from ..adapters.video_opencv import OpenCvVideoSink, OpenCvVideoSource
|
| 216 |
+
from .factory import _build_coach, _build_detector
|
| 217 |
+
|
| 218 |
+
goal_detector = None
|
| 219 |
+
if use_sam3_goal:
|
| 220 |
+
try:
|
| 221 |
+
from ..adapters.goal_detector_sam3 import Sam3GoalDetector
|
| 222 |
+
goal_detector = Sam3GoalDetector(prompt="goal post")
|
| 223 |
+
except Exception as e: # noqa: BLE001
|
| 224 |
+
log.warning("SAM3 goal detector unavailable: %s", e)
|
| 225 |
+
|
| 226 |
+
return MatchAnalysisService(
|
| 227 |
+
source=OpenCvVideoSource(), sink=OpenCvVideoSink(),
|
| 228 |
+
detector=_build_detector("rfdetr"), pose=MediaPipePose(),
|
| 229 |
+
coach=_build_coach(use_llm), renderer=OpenCvRenderer(),
|
| 230 |
+
goal_detector=goal_detector, out_dir=out_dir,
|
| 231 |
+
)
|
futheros/application/service.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AnalyzeKickService — the core use case.
|
| 2 |
+
|
| 3 |
+
Depends ONLY on ports (DetectorPort, PosePort, CoachPort, VideoSource/Sink,
|
| 4 |
+
RendererPort) and the pure domain logic. It contains no knowledge of RF-DETR,
|
| 5 |
+
MediaPipe, llama.cpp, OpenCV, or Gradio — those are injected adapters. This is the
|
| 6 |
+
hexagon's inside; swapping any backend never touches this file.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import tempfile
|
| 11 |
+
import time
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Callable
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
from ..domain import biomechanics, contact as contact_mod, trajectory as trajectory_mod
|
| 18 |
+
from ..domain.models import AnalysisResult, Clip
|
| 19 |
+
from ..ports import (CoachPort, DetectorPort, PosePort, RendererPort,
|
| 20 |
+
VideoSinkPort, VideoSourcePort)
|
| 21 |
+
|
| 22 |
+
Progress = Callable[[float, str], None]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class AnalyzeKickService:
|
| 26 |
+
def __init__(self, *, source: VideoSourcePort, sink: VideoSinkPort,
|
| 27 |
+
detector: DetectorPort, pose: PosePort, coach: CoachPort,
|
| 28 |
+
renderer: RendererPort, out_dir: str | None = None):
|
| 29 |
+
self.source = source
|
| 30 |
+
self.sink = sink
|
| 31 |
+
self.detector = detector
|
| 32 |
+
self.pose = pose
|
| 33 |
+
self.coach = coach
|
| 34 |
+
self.renderer = renderer
|
| 35 |
+
self.out_dir = Path(out_dir or tempfile.mkdtemp(prefix="futheros_"))
|
| 36 |
+
self.out_dir.mkdir(parents=True, exist_ok=True)
|
| 37 |
+
|
| 38 |
+
def analyze(self, clip_path: str, *, force_rotation: int | None = None,
|
| 39 |
+
kick_time_s: float | None = None, window_s: float = 2.0,
|
| 40 |
+
progress: Progress = lambda f, m: None) -> AnalysisResult:
|
| 41 |
+
t0 = time.time()
|
| 42 |
+
diag = {"detector": getattr(self.detector, "name", "?"),
|
| 43 |
+
"pose": getattr(self.pose, "name", "?"),
|
| 44 |
+
"coach": getattr(self.coach, "name", "?")}
|
| 45 |
+
|
| 46 |
+
progress(0.05, "Loading video…")
|
| 47 |
+
# If the user pins their kick time, load a tight full-fps window around it so we
|
| 48 |
+
# analyse THEIR kick (and lock onto the player making that contact), not whatever
|
| 49 |
+
# other touch happens elsewhere in a multi-player clip.
|
| 50 |
+
window = (kick_time_s - window_s, kick_time_s + window_s) if kick_time_s is not None else None
|
| 51 |
+
clip = self.source.load(clip_path, window=window)
|
| 52 |
+
if force_rotation:
|
| 53 |
+
clip = _rotate_clip(clip, (-(int(force_rotation) // 90)) % 4)
|
| 54 |
+
diag["rotation"] = f"{int(force_rotation)}°"
|
| 55 |
+
if kick_time_s is not None:
|
| 56 |
+
diag["window_s"] = f"{max(0.0, kick_time_s - window_s):.1f}–{kick_time_s + window_s:.1f}s"
|
| 57 |
+
diag["n_frames"], diag["fps"] = clip.n, round(clip.fps, 1)
|
| 58 |
+
|
| 59 |
+
progress(0.25, f"Detecting player + ball ({diag['detector']})…")
|
| 60 |
+
dets = self.detector.detect(clip.frames)
|
| 61 |
+
diag["ball_frames"] = sum(1 for d in dets if d.ball is not None)
|
| 62 |
+
diag["person_frames"] = sum(1 for d in dets if d.person is not None)
|
| 63 |
+
|
| 64 |
+
progress(0.5, "Extracting body pose…")
|
| 65 |
+
poses = self.pose.estimate(clip.frames)
|
| 66 |
+
diag["pose_frames"] = sum(1 for p in poses if p.ok())
|
| 67 |
+
|
| 68 |
+
progress(0.65, "Finding ball-contact frame…")
|
| 69 |
+
contact = contact_mod.detect_contact(poses, dets)
|
| 70 |
+
diag.update(contact_idx=contact.frame_idx, contact_method=contact.method,
|
| 71 |
+
kicking_leg="right" if contact.kicking_side == "r" else "left")
|
| 72 |
+
|
| 73 |
+
progress(0.75, "Measuring technique…")
|
| 74 |
+
fs = biomechanics.compute_features(poses, dets, contact)
|
| 75 |
+
traj = trajectory_mod.compute_trajectory(dets, contact)
|
| 76 |
+
if traj.valid:
|
| 77 |
+
fs.features.append(trajectory_mod.launch_angle_feature(traj))
|
| 78 |
+
diag["launch_angle_deg"] = round(traj.launch_angle_deg, 1)
|
| 79 |
+
score = biomechanics.overall_score(fs)
|
| 80 |
+
|
| 81 |
+
progress(0.85, f"Coaching ({diag['coach']})…")
|
| 82 |
+
report = self.coach.coach(fs)
|
| 83 |
+
|
| 84 |
+
progress(0.92, "Rendering annotated video…")
|
| 85 |
+
ann = self.renderer.annotate(clip, poses, dets, fs, contact, traj)
|
| 86 |
+
out_video = str(self.out_dir / f"annotated_{int(t0)}.mp4")
|
| 87 |
+
self.sink.write(ann, out_video, clip.fps)
|
| 88 |
+
hero = self.renderer.hero(clip, poses, dets, fs, contact, traj)
|
| 89 |
+
scorecard = self.renderer.scorecard(fs, score)
|
| 90 |
+
|
| 91 |
+
diag["elapsed_s"] = round(time.time() - t0, 1)
|
| 92 |
+
progress(1.0, "Done")
|
| 93 |
+
return AnalysisResult(
|
| 94 |
+
feature_set=fs, report=report, contact=contact, score=score, fps=clip.fps,
|
| 95 |
+
diagnostics=diag, trajectory=traj, annotated_video_path=out_video,
|
| 96 |
+
hero_image=hero, scorecard_image=scorecard,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _rotate_clip(clip: Clip, k: int) -> Clip:
|
| 101 |
+
"""Rotate every frame by k×90° (np.rot90) and return a new Clip."""
|
| 102 |
+
frames = [np.ascontiguousarray(np.rot90(f, k)) for f in clip.frames]
|
| 103 |
+
h, w = frames[0].shape[:2]
|
| 104 |
+
return Clip(frames=frames, fps=clip.fps, width=w, height=h)
|
futheros/domain/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Domain core: pure analysis logic with no I/O or model-library dependencies."""
|
futheros/domain/biomechanics.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Biomechanics feature extraction + banded scoring — pure domain logic.
|
| 2 |
+
|
| 3 |
+
Computes the 5-feature shortlist at/after the contact frame from 2D landmarks and
|
| 4 |
+
the ball box, then scores each into good/amber/poor/na bands. Reports bands rather
|
| 5 |
+
than exact degrees because a 3/4 view foreshortens angles — honest coaching.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from .config import FEATURE_SPEC
|
| 12 |
+
from .geometry import angle_at, angle_to_vertical
|
| 13 |
+
from .models import ContactResult, Detection, Feature, FeatureSet, FramePose
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _band(key: str, value: float | None) -> tuple[str, str]:
|
| 17 |
+
spec = FEATURE_SPEC[key]
|
| 18 |
+
if value is None:
|
| 19 |
+
return "na", ""
|
| 20 |
+
lo, hi = spec["good"]
|
| 21 |
+
pad = spec["amber_pad"]
|
| 22 |
+
if lo <= value <= hi:
|
| 23 |
+
return "good", ""
|
| 24 |
+
note = spec["help_low"] if value < lo else spec["help_high"]
|
| 25 |
+
if (lo - pad) <= value <= (hi + pad):
|
| 26 |
+
return "amber", note
|
| 27 |
+
return "poor", note
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def make_feature(key: str, value: float | None) -> Feature:
|
| 31 |
+
spec = FEATURE_SPEC[key]
|
| 32 |
+
band, note = _band(key, value)
|
| 33 |
+
return Feature(key=key, label=spec["label"], value=value, unit=spec["unit"],
|
| 34 |
+
band=band, confidence=spec["confidence"], note=note)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
_mk = make_feature # internal alias
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _shank_len(pose: FramePose, side: str) -> float | None:
|
| 41 |
+
knee = pose.pt(f"{side}_knee"); ankle = pose.pt(f"{side}_ankle")
|
| 42 |
+
if knee is None or ankle is None:
|
| 43 |
+
return None
|
| 44 |
+
return float(np.linalg.norm(knee - ankle))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _leg_len(pose: FramePose, side: str) -> float | None:
|
| 48 |
+
hip = pose.pt(f"{side}_hip"); knee = pose.pt(f"{side}_knee"); ankle = pose.pt(f"{side}_ankle")
|
| 49 |
+
if hip is None or knee is None or ankle is None:
|
| 50 |
+
return None
|
| 51 |
+
return float(np.linalg.norm(hip - knee) + np.linalg.norm(knee - ankle))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def compute_features(poses: list[FramePose], dets: list[Detection],
|
| 55 |
+
contact: ContactResult) -> FeatureSet:
|
| 56 |
+
side = contact.kicking_side
|
| 57 |
+
plant = "l" if side == "r" else "r"
|
| 58 |
+
ci = contact.frame_idx
|
| 59 |
+
n = len(poses)
|
| 60 |
+
cp = poses[ci]
|
| 61 |
+
fs = FeatureSet(kicking_side=side, contact_idx=ci)
|
| 62 |
+
|
| 63 |
+
hip = cp.pt(f"{side}_hip"); knee = cp.pt(f"{side}_knee"); ankle = cp.pt(f"{side}_ankle")
|
| 64 |
+
|
| 65 |
+
# 1. knee flexion at strike (180 - interior angle)
|
| 66 |
+
knee_flex = None
|
| 67 |
+
if hip is not None and knee is not None and ankle is not None:
|
| 68 |
+
knee_flex = 180.0 - angle_at(hip, knee, ankle)
|
| 69 |
+
fs.features.append(_mk("knee_flexion_strike", knee_flex))
|
| 70 |
+
|
| 71 |
+
# 2. trunk lean
|
| 72 |
+
trunk = None
|
| 73 |
+
ls, rs = cp.pt("l_shoulder"), cp.pt("r_shoulder")
|
| 74 |
+
lh, rh = cp.pt("l_hip"), cp.pt("r_hip")
|
| 75 |
+
if ls is not None and rs is not None and lh is not None and rh is not None:
|
| 76 |
+
trunk = angle_to_vertical((ls + rs) / 2, (lh + rh) / 2)
|
| 77 |
+
fs.features.append(_mk("trunk_lean_strike", trunk))
|
| 78 |
+
|
| 79 |
+
# 3. plant-foot distance to ball / shank length
|
| 80 |
+
plant_dist = None
|
| 81 |
+
pf = cp.foot(plant)
|
| 82 |
+
ball = dets[ci].ball if ci < len(dets) else None
|
| 83 |
+
shank = _shank_len(cp, plant) or _shank_len(cp, side)
|
| 84 |
+
if pf is not None and ball is not None and shank:
|
| 85 |
+
plant_dist = float(np.linalg.norm(pf - ball.center)) / (shank + 1e-6)
|
| 86 |
+
fs.features.append(_mk("plant_foot_dist", plant_dist))
|
| 87 |
+
|
| 88 |
+
# 4. hip flexion (shoulder, hip, knee)
|
| 89 |
+
hip_flex = None
|
| 90 |
+
sh = cp.pt(f"{side}_shoulder")
|
| 91 |
+
if sh is not None and hip is not None and knee is not None:
|
| 92 |
+
hip_flex = angle_at(sh, hip, knee)
|
| 93 |
+
fs.features.append(_mk("hip_flexion_strike", hip_flex))
|
| 94 |
+
|
| 95 |
+
# 5. follow-through peak foot height after contact / leg length
|
| 96 |
+
ft_height = None
|
| 97 |
+
leg = _leg_len(cp, side)
|
| 98 |
+
contact_foot = cp.foot(side)
|
| 99 |
+
if leg and contact_foot is not None:
|
| 100 |
+
base_y = contact_foot[1]
|
| 101 |
+
rises = []
|
| 102 |
+
for j in range(ci, min(n, ci + 25)):
|
| 103 |
+
fp = poses[j].foot(side)
|
| 104 |
+
if fp is not None:
|
| 105 |
+
rises.append(base_y - fp[1])
|
| 106 |
+
if rises:
|
| 107 |
+
ft_height = float(max(rises)) / (leg + 1e-6)
|
| 108 |
+
fs.features.append(_mk("follow_through_height", ft_height))
|
| 109 |
+
|
| 110 |
+
return fs
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def overall_score(fs: FeatureSet) -> int:
|
| 114 |
+
pts = {"good": 1.0, "amber": 0.5, "poor": 0.0}
|
| 115 |
+
vals = [pts[f.band] for f in fs.features if f.band in pts]
|
| 116 |
+
return int(round(100 * sum(vals) / len(vals))) if vals else 0
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def features_to_dict(fs: FeatureSet) -> dict:
|
| 120 |
+
"""Serialise for the LLM coach (numbers + targets + bands)."""
|
| 121 |
+
return {
|
| 122 |
+
"kicking_leg": "right" if fs.kicking_side == "r" else "left",
|
| 123 |
+
"overall_score_0_100": overall_score(fs),
|
| 124 |
+
"features": [
|
| 125 |
+
{
|
| 126 |
+
"name": f.label, "key": f.key,
|
| 127 |
+
"value": None if f.value is None else round(f.value, 1),
|
| 128 |
+
"unit": f.unit, "band": f.band, "confidence": f.confidence,
|
| 129 |
+
"target_good_range": list(FEATURE_SPEC[f.key]["good"]),
|
| 130 |
+
}
|
| 131 |
+
for f in fs.features
|
| 132 |
+
],
|
| 133 |
+
}
|
futheros/domain/coaching.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rule-based coaching — pure domain logic shared as the always-available coach
|
| 2 |
+
and as the fallback when the LLM misbehaves. No external deps.
|
| 3 |
+
|
| 4 |
+
The LLM prompt strings also live here so the domain owns the coaching contract;
|
| 5 |
+
the llama-cpp adapter just executes them.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from .biomechanics import features_to_dict, overall_score
|
| 10 |
+
from .config import DRILL_BANK
|
| 11 |
+
from .models import CoachReport, FeatureSet
|
| 12 |
+
|
| 13 |
+
SYSTEM_PROMPT = (
|
| 14 |
+
"You are FUT-HEROS, a concise football (soccer) kicking-technique coach. "
|
| 15 |
+
"You are given MEASURED biomechanics from a single side/three-quarter phone clip "
|
| 16 |
+
"of one kick, already converted to good/amber/poor bands. The numbers are "
|
| 17 |
+
"coaching-grade, not lab-grade, so speak in tendencies, never exact degrees. "
|
| 18 |
+
"Encourage first, then fix the 1-2 weakest things. "
|
| 19 |
+
"Return STRICT JSON only, no markdown, with keys: "
|
| 20 |
+
"summary (1-2 sentences), cues (array of 2-4 short imperative coaching cues), "
|
| 21 |
+
"drills (array of 2-3 short drills to fix the weak points)."
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def build_user_prompt(fs: FeatureSet) -> str:
|
| 26 |
+
import json
|
| 27 |
+
return ("Here are the measured features for this kick:\n"
|
| 28 |
+
+ json.dumps(features_to_dict(fs), indent=2)
|
| 29 |
+
+ "\n\nWrite the coaching JSON now.")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def rule_based_report(fs: FeatureSet) -> CoachReport:
|
| 33 |
+
score = overall_score(fs)
|
| 34 |
+
goods = [f for f in fs.features if f.band == "good"]
|
| 35 |
+
weak = [f for f in fs.features if f.band in ("amber", "poor")]
|
| 36 |
+
weak.sort(key=lambda f: 0 if f.band == "poor" else 1)
|
| 37 |
+
|
| 38 |
+
if score >= 80:
|
| 39 |
+
summary = ("Strong, well-balanced strike — the fundamentals are there. "
|
| 40 |
+
"A couple of small tweaks will add power and consistency.")
|
| 41 |
+
elif score >= 55:
|
| 42 |
+
summary = ("Solid base with clear strengths. Tighten up the flagged areas "
|
| 43 |
+
"and the strike will jump up a level.")
|
| 44 |
+
else:
|
| 45 |
+
summary = ("Good effort — there's real raw material here. Let's fix the basics "
|
| 46 |
+
"one at a time and the power and accuracy will follow.")
|
| 47 |
+
if goods:
|
| 48 |
+
summary += f" Nice work on your {goods[0].label.lower()}."
|
| 49 |
+
|
| 50 |
+
cues = [f.note for f in weak if f.note][:4]
|
| 51 |
+
if not cues:
|
| 52 |
+
cues = ["Keep your eyes on the ball through contact.",
|
| 53 |
+
"Stay balanced over the plant foot and follow through to the target."]
|
| 54 |
+
|
| 55 |
+
drills: list[str] = []
|
| 56 |
+
for f in weak[:2]:
|
| 57 |
+
drills.extend(DRILL_BANK.get(f.key, []))
|
| 58 |
+
if not drills:
|
| 59 |
+
drills = ["Wall-pass reps: 20 firm side-foot passes each leg, focusing on a clean plant.",
|
| 60 |
+
"Approach-and-strike: 10 controlled shots from 5 paces, holding your finish."]
|
| 61 |
+
seen, uniq = set(), []
|
| 62 |
+
for d in drills:
|
| 63 |
+
if d not in seen:
|
| 64 |
+
seen.add(d); uniq.append(d)
|
| 65 |
+
|
| 66 |
+
return CoachReport(summary=summary, cues=cues, drills=uniq[:3], score=score, engine="rule")
|
futheros/domain/config.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Domain analysis parameters — pure data, no infrastructure concerns.
|
| 2 |
+
|
| 3 |
+
Model repos / GPU settings / download URLs live in the adapters layer, not here.
|
| 4 |
+
This file holds only the biomechanics knowledge: landmark indices, visibility
|
| 5 |
+
floor, COCO class ids, and the feature target bands.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
# COCO class ids (detection contract — domain knows what it wants found)
|
| 10 |
+
COCO_PERSON = 0
|
| 11 |
+
COCO_SPORTS_BALL = 32
|
| 12 |
+
|
| 13 |
+
# Detection thresholds (analysis policy)
|
| 14 |
+
PERSON_CONF = 0.35
|
| 15 |
+
BALL_CONF = 0.10 # ball is small+fast -> low threshold, lean on temporal smoothing
|
| 16 |
+
BALL_MAX_INTERP_GAP = 8
|
| 17 |
+
|
| 18 |
+
# MediaPipe BlazePose 33-landmark indices we rely on
|
| 19 |
+
LM = {
|
| 20 |
+
"nose": 0,
|
| 21 |
+
"l_shoulder": 11, "r_shoulder": 12,
|
| 22 |
+
"l_elbow": 13, "r_elbow": 14,
|
| 23 |
+
"l_hip": 23, "r_hip": 24,
|
| 24 |
+
"l_knee": 25, "r_knee": 26,
|
| 25 |
+
"l_ankle": 27, "r_ankle": 28,
|
| 26 |
+
"l_heel": 29, "r_heel": 30,
|
| 27 |
+
"l_foot": 31, "r_foot": 32,
|
| 28 |
+
}
|
| 29 |
+
POSE_MIN_VISIBILITY = 0.3
|
| 30 |
+
|
| 31 |
+
# 5-feature biomechanics shortlist + target bands (good range, amber pad, coaching notes)
|
| 32 |
+
FEATURE_SPEC = {
|
| 33 |
+
"knee_flexion_strike": {
|
| 34 |
+
"label": "Kicking-knee bend at strike",
|
| 35 |
+
"unit": "deg",
|
| 36 |
+
"good": (35, 55),
|
| 37 |
+
"amber_pad": 12,
|
| 38 |
+
"help_low": "Knee too straight at contact — bend the kicking knee more to load the strike.",
|
| 39 |
+
"help_high": "Knee very bent at contact — let the lower leg extend through the ball for more snap.",
|
| 40 |
+
"confidence": "high",
|
| 41 |
+
},
|
| 42 |
+
"trunk_lean_strike": {
|
| 43 |
+
"label": "Trunk lean at contact",
|
| 44 |
+
"unit": "deg",
|
| 45 |
+
"good": (3, 20),
|
| 46 |
+
"amber_pad": 10,
|
| 47 |
+
"help_low": "You're leaning forward over the ball — sit back slightly to get under it and drive through.",
|
| 48 |
+
"help_high": "Leaning back a lot — a touch more upright keeps the strike compact.",
|
| 49 |
+
"confidence": "high",
|
| 50 |
+
},
|
| 51 |
+
"plant_foot_dist": {
|
| 52 |
+
"label": "Plant-foot distance to ball",
|
| 53 |
+
"unit": "shank",
|
| 54 |
+
"good": (0.6, 1.3),
|
| 55 |
+
"amber_pad": 0.5,
|
| 56 |
+
"help_low": "Plant foot is right on top of the ball — plant 10-15cm further to the side for room to swing.",
|
| 57 |
+
"help_high": "Plant foot is too far from the ball — step in closer so you can strike cleanly with power.",
|
| 58 |
+
"confidence": "med",
|
| 59 |
+
},
|
| 60 |
+
"hip_flexion_strike": {
|
| 61 |
+
"label": "Hip drive (kicking leg)",
|
| 62 |
+
"unit": "deg",
|
| 63 |
+
"good": (95, 135),
|
| 64 |
+
"amber_pad": 20,
|
| 65 |
+
"help_low": "Limited hip drive — bring the thigh through harder from the hip for more power.",
|
| 66 |
+
"help_high": "Big hip swing — good drive; keep it controlled for accuracy.",
|
| 67 |
+
"confidence": "med",
|
| 68 |
+
},
|
| 69 |
+
"follow_through_height": {
|
| 70 |
+
"label": "Follow-through height",
|
| 71 |
+
"unit": "leg",
|
| 72 |
+
"good": (0.45, 1.1),
|
| 73 |
+
"amber_pad": 0.3,
|
| 74 |
+
"help_low": "Short follow-through — swing the leg up and through the target to finish the strike.",
|
| 75 |
+
"help_high": "Big follow-through — lots of swing; fine for power shots.",
|
| 76 |
+
"confidence": "med",
|
| 77 |
+
},
|
| 78 |
+
"launch_angle": {
|
| 79 |
+
"label": "Shot launch angle",
|
| 80 |
+
"unit": "deg", # 2D elevation of the ball path just after contact
|
| 81 |
+
"good": (8, 35),
|
| 82 |
+
"amber_pad": 20,
|
| 83 |
+
"help_low": "Flat shot — plant your foot beside the ball and lean back slightly to get under it and lift it.",
|
| 84 |
+
"help_high": "Ballooned high — stay over the ball and strike through its middle to keep it down.",
|
| 85 |
+
"confidence": "med",
|
| 86 |
+
},
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
# Drill bank keyed by feature (coaching domain knowledge)
|
| 90 |
+
DRILL_BANK = {
|
| 91 |
+
"knee_flexion_strike": [
|
| 92 |
+
"Slow-mo strikes: kick at half pace focusing on cocking the knee back before you swing through.",
|
| 93 |
+
"Resistance-band knee snaps: 3x12 to groove a sharper lower-leg whip.",
|
| 94 |
+
],
|
| 95 |
+
"trunk_lean_strike": [
|
| 96 |
+
"Lean-back finishes: practice 10 shots exaggerating sitting slightly back over the ball.",
|
| 97 |
+
"Plant-and-pause: strike, then freeze your finish for 2s to feel your upper-body angle.",
|
| 98 |
+
],
|
| 99 |
+
"plant_foot_dist": [
|
| 100 |
+
"Cone-spot plant: place a cone a boot's width to the side of the ball and hit 15 balls planting onto it.",
|
| 101 |
+
"Walk-up reps: rehearse the last two steps so the plant foot lands beside, not on top of, the ball.",
|
| 102 |
+
],
|
| 103 |
+
"hip_flexion_strike": [
|
| 104 |
+
"High-knee drive: march drills then strikes, leading with the thigh from the hip.",
|
| 105 |
+
"Power-step shots: explode the kicking thigh through 12 shots, prioritising hip drive over foot speed.",
|
| 106 |
+
],
|
| 107 |
+
"follow_through_height": [
|
| 108 |
+
"Full-finish shots: swing the kicking leg all the way up to chest height on 12 strikes.",
|
| 109 |
+
"Target-and-through: aim at a high target so the follow-through naturally extends.",
|
| 110 |
+
],
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
MAX_FRAMES = 600 # cap processing for long / slow-mo clips
|
futheros/domain/contact.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ball-contact frame detection — pure function over poses + detections.
|
| 2 |
+
|
| 3 |
+
All in the 2D image plane (no depth):
|
| 4 |
+
1. kicking leg = ankle with the largest peak speed
|
| 5 |
+
2. primary: local min of kicking-foot <-> ball-center distance
|
| 6 |
+
3. confirm: ball velocity onset (stationary -> moving) just after
|
| 7 |
+
4. fallback (no ball): peak kicking-foot speed
|
| 8 |
+
A 60fps clip can't resolve the true ~7-16ms impact, so accept +-1 frame.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
from .geometry import smooth
|
| 15 |
+
from .models import ContactResult, Detection, FramePose
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _ankle_series(poses: list[FramePose], side: str) -> np.ndarray:
|
| 19 |
+
pts = []
|
| 20 |
+
for p in poses:
|
| 21 |
+
a = p.pt(f"{side}_ankle") if p.ok() else None
|
| 22 |
+
pts.append(a if a is not None else [np.nan, np.nan])
|
| 23 |
+
return np.array(pts, dtype=np.float32)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def identify_kicking_leg(poses: list[FramePose]) -> str:
|
| 27 |
+
best, best_side = -1.0, "r"
|
| 28 |
+
for side in ("l", "r"):
|
| 29 |
+
s = _ankle_series(poses, side)
|
| 30 |
+
valid = ~np.isnan(s[:, 0])
|
| 31 |
+
if valid.sum() < 3:
|
| 32 |
+
continue
|
| 33 |
+
speed = np.linalg.norm(np.diff(s[valid], axis=0), axis=1)
|
| 34 |
+
score = float(np.nanmax(speed)) if len(speed) else 0.0
|
| 35 |
+
if score > best:
|
| 36 |
+
best, best_side = score, side
|
| 37 |
+
return best_side
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def detect_contact(poses: list[FramePose], dets: list[Detection]) -> ContactResult:
|
| 41 |
+
n = len(poses)
|
| 42 |
+
side = identify_kicking_leg(poses)
|
| 43 |
+
|
| 44 |
+
ball = np.full((n, 2), np.nan, dtype=np.float32)
|
| 45 |
+
for i, d in enumerate(dets):
|
| 46 |
+
if d.ball is not None:
|
| 47 |
+
ball[i] = [d.ball.cx, d.ball.cy]
|
| 48 |
+
has_ball = int((~np.isnan(ball[:, 0])).sum()) >= 3
|
| 49 |
+
|
| 50 |
+
foot = np.full((n, 2), np.nan, dtype=np.float32)
|
| 51 |
+
for i, p in enumerate(poses):
|
| 52 |
+
fp = p.foot(side) if p.ok() else None
|
| 53 |
+
if fp is not None:
|
| 54 |
+
foot[i] = fp
|
| 55 |
+
|
| 56 |
+
if has_ball:
|
| 57 |
+
dist = np.linalg.norm(foot - ball, axis=1)
|
| 58 |
+
valid = ~np.isnan(dist)
|
| 59 |
+
if valid.sum() >= 3:
|
| 60 |
+
d_s = dist.copy()
|
| 61 |
+
d_s[~valid] = np.nanmax(dist[valid]) * 2
|
| 62 |
+
d_s = smooth(d_s, 5)
|
| 63 |
+
cand = int(np.nanargmin(d_s))
|
| 64 |
+
|
| 65 |
+
bspeed = np.full(n, np.nan, dtype=np.float32)
|
| 66 |
+
bspeed[1:] = np.linalg.norm(np.diff(ball, axis=0), axis=1)
|
| 67 |
+
after = bspeed[cand:min(n, cand + 4)]
|
| 68 |
+
onset = float(np.nanmax(after)) if np.any(~np.isnan(after)) else 0.0
|
| 69 |
+
ref = float(np.nanmedian(bspeed[~np.isnan(bspeed)])) if np.any(~np.isnan(bspeed)) else 0.0
|
| 70 |
+
if onset > 2.5 * (ref + 1e-6):
|
| 71 |
+
return ContactResult(cand, side, "velocity_onset", 0.85, ball)
|
| 72 |
+
return ContactResult(cand, side, "foot_ball_min", 0.6, ball)
|
| 73 |
+
|
| 74 |
+
fseries = _ankle_series(poses, side)
|
| 75 |
+
if (~np.isnan(fseries[:, 0])).sum() >= 3:
|
| 76 |
+
speed = np.full(n, np.nan, dtype=np.float32)
|
| 77 |
+
speed[1:] = np.linalg.norm(np.diff(fseries, axis=0), axis=1)
|
| 78 |
+
speed = np.where(np.isnan(speed), -1, speed)
|
| 79 |
+
cand = int(np.argmax(smooth(np.maximum(speed, 0), 5)))
|
| 80 |
+
return ContactResult(cand, side, "foot_speed", 0.4, ball if has_ball else None)
|
| 81 |
+
|
| 82 |
+
return ContactResult(n // 2, side, "fallback_mid", 0.1, None)
|
futheros/domain/geometry.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pure 2D geometry helpers used by the biomechanics core."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def angle_at(a: np.ndarray, b: np.ndarray, c: np.ndarray) -> float:
|
| 8 |
+
"""Interior angle ABC in degrees, vertex at b."""
|
| 9 |
+
ba = a - b
|
| 10 |
+
bc = c - b
|
| 11 |
+
cosang = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc) + 1e-9)
|
| 12 |
+
return float(np.degrees(np.arccos(np.clip(cosang, -1.0, 1.0))))
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def angle_to_vertical(top: np.ndarray, bottom: np.ndarray) -> float:
|
| 16 |
+
"""Signed lean (deg) of vector bottom->top from vertical-up.
|
| 17 |
+
Image y grows downward, so 'up' is -y. +ve leans one way, -ve the other."""
|
| 18 |
+
v = top - bottom
|
| 19 |
+
return float(np.degrees(np.arctan2(v[0], -v[1])))
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def smooth(x: np.ndarray, k: int = 5) -> np.ndarray:
|
| 23 |
+
if len(x) < k:
|
| 24 |
+
return x
|
| 25 |
+
return np.convolve(x, np.ones(k) / k, mode="same")
|
futheros/domain/goal.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Goal-event detection + scorer attribution — pure domain logic.
|
| 2 |
+
|
| 3 |
+
The product's real anchor: don't guess which of many players to coach — find the
|
| 4 |
+
GOAL, then coach the strike that scored it.
|
| 5 |
+
|
| 6 |
+
1. goal event = the ball enters the goal-mouth region and stays/disappears there
|
| 7 |
+
2. scorer kick = the last foot-to-ball contact BEFORE the ball crossed in
|
| 8 |
+
3. that contact frame + that player = what we analyse
|
| 9 |
+
|
| 10 |
+
This module is detector-agnostic: it takes the goal-mouth box (from whatever goalpost
|
| 11 |
+
detector), the per-frame ball track, and per-frame all-person boxes. Everything is in
|
| 12 |
+
the 2D image plane.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
|
| 20 |
+
from .models import Box, Detection, FramePose
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class GoalEvent:
|
| 25 |
+
goal_frame: int # frame the ball entered the goal mouth
|
| 26 |
+
contact_frame: int # last foot-to-ball contact before the goal
|
| 27 |
+
scorer_box: Box | None # the scoring player's box at the contact frame
|
| 28 |
+
kicking_side: str # 'l' | 'r'
|
| 29 |
+
confidence: float
|
| 30 |
+
ball_track: np.ndarray = field(default_factory=lambda: np.empty((0, 2)))
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _ball_centers(dets: list[Detection]) -> np.ndarray:
|
| 34 |
+
n = len(dets)
|
| 35 |
+
pts = np.full((n, 2), np.nan, dtype=np.float32)
|
| 36 |
+
for i, d in enumerate(dets):
|
| 37 |
+
if d.ball is not None:
|
| 38 |
+
pts[i] = [d.ball.cx, d.ball.cy]
|
| 39 |
+
return pts
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _inside(box: Box, x: float, y: float, pad: float = 0.0) -> bool:
|
| 43 |
+
return (box.x1 - pad) <= x <= (box.x2 + pad) and (box.y1 - pad) <= y <= (box.y2 + pad)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class GoalRegion:
|
| 47 |
+
"""The goal mouth as a polygon (the projected goal PLANE).
|
| 48 |
+
|
| 49 |
+
A user-annotated 4-point polygon beats any detector box: it encodes exactly which
|
| 50 |
+
crossing counts as goal-ward. A detector Box converts to its 4 corners.
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
def __init__(self, polygon: np.ndarray):
|
| 54 |
+
self.poly = np.asarray(polygon, dtype=np.float32).reshape(-1, 2)
|
| 55 |
+
self.centre = self.poly.mean(axis=0)
|
| 56 |
+
d = self.poly - self.centre
|
| 57 |
+
self.radius = float(np.linalg.norm(d, axis=1).max())
|
| 58 |
+
|
| 59 |
+
@classmethod
|
| 60 |
+
def from_box(cls, b: Box) -> "GoalRegion":
|
| 61 |
+
return cls(np.array([[b.x1, b.y1], [b.x2, b.y1], [b.x2, b.y2], [b.x1, b.y2]]))
|
| 62 |
+
|
| 63 |
+
def contains(self, x: float, y: float, pad: float = 0.0) -> bool:
|
| 64 |
+
# ray cast (even-odd rule)
|
| 65 |
+
px, py = self.poly[:, 0], self.poly[:, 1]
|
| 66 |
+
n = len(px)
|
| 67 |
+
inside = False
|
| 68 |
+
j = n - 1
|
| 69 |
+
for i in range(n):
|
| 70 |
+
if (py[i] > y) != (py[j] > y):
|
| 71 |
+
xint = (px[j] - px[i]) * (y - py[i]) / (py[j] - py[i] + 1e-12) + px[i]
|
| 72 |
+
if x < xint:
|
| 73 |
+
inside = not inside
|
| 74 |
+
j = i
|
| 75 |
+
if inside:
|
| 76 |
+
return True
|
| 77 |
+
if pad <= 0:
|
| 78 |
+
return False
|
| 79 |
+
# near-miss: distance from point to the closest polygon edge
|
| 80 |
+
p = np.array([x, y], dtype=np.float32)
|
| 81 |
+
best = 1e18
|
| 82 |
+
for i in range(n):
|
| 83 |
+
a, b = self.poly[i], self.poly[(i + 1) % n]
|
| 84 |
+
ab = b - a
|
| 85 |
+
t = float(np.clip(np.dot(p - a, ab) / (np.dot(ab, ab) + 1e-12), 0, 1))
|
| 86 |
+
best = min(best, float(np.linalg.norm(p - (a + t * ab))))
|
| 87 |
+
return best <= pad
|
| 88 |
+
|
| 89 |
+
def crossed_by(self, p0: np.ndarray, p1: np.ndarray) -> bool:
|
| 90 |
+
"""Does the segment p0->p1 (ball path between two frames) cross this region?
|
| 91 |
+
|
| 92 |
+
Essential for goal planes annotated edge-on from a corner camera: the mouth
|
| 93 |
+
projects to a sliver only a few px wide, so a flying ball is sampled on one
|
| 94 |
+
side then the other and is NEVER inside — but its path segment crosses.
|
| 95 |
+
"""
|
| 96 |
+
if self.contains(*p0) or self.contains(*p1):
|
| 97 |
+
return True
|
| 98 |
+
|
| 99 |
+
def ccw(a, b, c):
|
| 100 |
+
return (c[1] - a[1]) * (b[0] - a[0]) > (b[1] - a[1]) * (c[0] - a[0])
|
| 101 |
+
|
| 102 |
+
def seg_intersect(a, b, c, d):
|
| 103 |
+
return (ccw(a, c, d) != ccw(b, c, d)) and (ccw(a, b, c) != ccw(a, b, d))
|
| 104 |
+
|
| 105 |
+
n = len(self.poly)
|
| 106 |
+
for i in range(n):
|
| 107 |
+
if seg_intersect(p0, p1, self.poly[i], self.poly[(i + 1) % n]):
|
| 108 |
+
return True
|
| 109 |
+
return False
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def clean_ball_track(ball: np.ndarray, grid: int = 10, max_frac: float = 0.05) -> np.ndarray:
|
| 113 |
+
"""Remove static false-positive 'balls'.
|
| 114 |
+
|
| 115 |
+
A real ball moves; white pitch markings / goal frames / scoreboard elements get
|
| 116 |
+
re-detected at the SAME pixel spot for a large share of the clip. Any grid cell
|
| 117 |
+
holding more than max_frac of all detections is static junk — NaN those frames.
|
| 118 |
+
"""
|
| 119 |
+
out = ball.copy()
|
| 120 |
+
valid = ~np.isnan(ball[:, 0])
|
| 121 |
+
if valid.sum() < 10:
|
| 122 |
+
return out
|
| 123 |
+
cells = (ball[valid] // grid).astype(np.int64)
|
| 124 |
+
keys = cells[:, 0] * 100003 + cells[:, 1]
|
| 125 |
+
uniq, counts = np.unique(keys, return_counts=True)
|
| 126 |
+
bad = set(uniq[counts > max(3, int(max_frac * valid.sum()))].tolist())
|
| 127 |
+
vi = np.where(valid)[0]
|
| 128 |
+
for k, i in enumerate(vi):
|
| 129 |
+
if keys[k] in bad:
|
| 130 |
+
out[i] = np.nan
|
| 131 |
+
return out
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def detect_cuts(frames: list[np.ndarray], min_gap: int = 5) -> list[int]:
|
| 135 |
+
"""Indices where a hard scene cut occurs (highlight montages jump between moments).
|
| 136 |
+
|
| 137 |
+
Fixed-camera montages are subtle: a cut keeps the identical pitch background and
|
| 138 |
+
only the (small) players + overlay jump, so absolute change is low. We therefore
|
| 139 |
+
use an ADAPTIVE threshold: the per-frame changed-pixel fraction is near-constant
|
| 140 |
+
during play; a cut is a statistical outlier spike above that baseline.
|
| 141 |
+
Tracking must never cross a cut, or it silently jumps into a different highlight.
|
| 142 |
+
"""
|
| 143 |
+
if len(frames) < 3:
|
| 144 |
+
return []
|
| 145 |
+
fracs = np.zeros(len(frames), dtype=np.float32)
|
| 146 |
+
prev = None
|
| 147 |
+
for i, f in enumerate(frames):
|
| 148 |
+
g = f[::8, ::8].mean(axis=2) # cheap thumbnail
|
| 149 |
+
if prev is not None:
|
| 150 |
+
fracs[i] = float((np.abs(g - prev) > 25).mean())
|
| 151 |
+
prev = g
|
| 152 |
+
base = float(np.median(fracs[1:]))
|
| 153 |
+
mad = float(np.median(np.abs(fracs[1:] - base))) + 1e-6
|
| 154 |
+
thresh = max(base + 8 * mad, 2.5 * base, 0.02)
|
| 155 |
+
cuts: list[int] = []
|
| 156 |
+
for i in range(1, len(frames)):
|
| 157 |
+
if fracs[i] > thresh:
|
| 158 |
+
if not cuts or (i - cuts[-1]) >= min_gap:
|
| 159 |
+
cuts.append(i)
|
| 160 |
+
return cuts
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def segment_of(idx: int, cuts: list[int], n: int) -> tuple[int, int]:
|
| 164 |
+
"""[start, end) of the montage segment containing frame idx."""
|
| 165 |
+
start = 0
|
| 166 |
+
end = n
|
| 167 |
+
for c in cuts:
|
| 168 |
+
if c <= idx:
|
| 169 |
+
start = c
|
| 170 |
+
else:
|
| 171 |
+
end = c
|
| 172 |
+
break
|
| 173 |
+
return start, end
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def find_goal_events(dets: list[Detection], goal_box: "Box | GoalRegion",
|
| 177 |
+
min_gap: int = 30, pad: float = 15.0,
|
| 178 |
+
approach_window: int = 6,
|
| 179 |
+
cuts: list[int] | None = None,
|
| 180 |
+
fps: float = 60.0) -> list[int]:
|
| 181 |
+
"""Frames where the ball flies INTO the goal mouth.
|
| 182 |
+
|
| 183 |
+
goal_box may be a detector Box or (better) a user-annotated GoalRegion polygon —
|
| 184 |
+
the projected goal plane, which encodes exactly which crossing counts.
|
| 185 |
+
On a cleaned track (static FPs removed), a goal = the ball reaches the region
|
| 186 |
+
(within `pad` px) at the end of a CONSISTENT approach: over the last few valid
|
| 187 |
+
points the ball must have moved toward the goal centre by a meaningful distance.
|
| 188 |
+
Detection noise teleporting near the goal has no such approach and is rejected.
|
| 189 |
+
"""
|
| 190 |
+
region = goal_box if isinstance(goal_box, GoalRegion) else GoalRegion.from_box(goal_box)
|
| 191 |
+
ball = clean_ball_track(_ball_centers(dets))
|
| 192 |
+
n = len(dets)
|
| 193 |
+
cut_set = set(cuts or [])
|
| 194 |
+
|
| 195 |
+
# Build continuous ball RUNS: consecutive valid points with small frame gaps, no
|
| 196 |
+
# teleports (picker jumping to another ball), and never spanning a montage cut.
|
| 197 |
+
# The teleport threshold scales with frame rate: a shot ball moves ~25px/frame at
|
| 198 |
+
# 60fps, so at lower fps the same ball legitimately jumps further between frames.
|
| 199 |
+
MAX_FRAME_GAP = 3
|
| 200 |
+
MAX_STEP_PX = max(80.0, 80.0 * (60.0 / max(10.0, fps)))
|
| 201 |
+
runs: list[list[int]] = []
|
| 202 |
+
cur: list[int] = []
|
| 203 |
+
for i in range(n):
|
| 204 |
+
if np.isnan(ball[i, 0]):
|
| 205 |
+
continue
|
| 206 |
+
if cur:
|
| 207 |
+
gap = i - cur[-1]
|
| 208 |
+
jump = float(np.linalg.norm(ball[i] - ball[cur[-1]]))
|
| 209 |
+
crosses_cut = any(c in cut_set for c in range(cur[-1] + 1, i + 1))
|
| 210 |
+
if gap > MAX_FRAME_GAP or jump > MAX_STEP_PX * gap or crosses_cut:
|
| 211 |
+
runs.append(cur)
|
| 212 |
+
cur = []
|
| 213 |
+
cur.append(i)
|
| 214 |
+
if cur:
|
| 215 |
+
runs.append(cur)
|
| 216 |
+
|
| 217 |
+
EXTRAP_FRAMES = 8 # how far past a lost track we project the flight
|
| 218 |
+
MIN_SHOT_SPEED = 5.0 # px/frame — only fast balls get extrapolated
|
| 219 |
+
|
| 220 |
+
events: list[int] = []
|
| 221 |
+
|
| 222 |
+
def add(i: int):
|
| 223 |
+
if not events or (i - events[-1]) >= min_gap:
|
| 224 |
+
events.append(i)
|
| 225 |
+
|
| 226 |
+
for run in runs:
|
| 227 |
+
# 1) direct crossing: any path segment within the run crosses the goal plane
|
| 228 |
+
fired = False
|
| 229 |
+
for a, b in zip(run, run[1:]):
|
| 230 |
+
if region.crossed_by(ball[a], ball[b]):
|
| 231 |
+
add(b)
|
| 232 |
+
fired = True
|
| 233 |
+
break
|
| 234 |
+
if fired:
|
| 235 |
+
continue
|
| 236 |
+
# 2) extrapolation: a fast goal-ward run that ends (blur kills detection at
|
| 237 |
+
# peak speed — exactly at the goal). Project the last velocity forward.
|
| 238 |
+
if len(run) >= 3:
|
| 239 |
+
tail = run[-3:]
|
| 240 |
+
dt = tail[-1] - tail[0]
|
| 241 |
+
v = (ball[tail[-1]] - ball[tail[0]]) / max(1, dt) # px/frame
|
| 242 |
+
speed = float(np.linalg.norm(v))
|
| 243 |
+
if speed >= MIN_SHOT_SPEED:
|
| 244 |
+
p_end = ball[run[-1]]
|
| 245 |
+
if region.crossed_by(p_end, p_end + EXTRAP_FRAMES * v):
|
| 246 |
+
add(run[-1])
|
| 247 |
+
|
| 248 |
+
return sorted(events)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def merge_goal_events(events_by_region: list[list[int]], fps: float,
|
| 252 |
+
refractory_s: float = 5.0) -> list[int]:
|
| 253 |
+
"""Merge per-goal event lists into one timeline, dropping kickoff returns.
|
| 254 |
+
|
| 255 |
+
Football rule, not a heuristic: you cannot score twice within a few seconds —
|
| 256 |
+
there's a kickoff in between. The ball being returned after a goal often crosses
|
| 257 |
+
a goal plane again, so any event within `refractory_s` of an accepted goal is
|
| 258 |
+
the return, not a new goal.
|
| 259 |
+
"""
|
| 260 |
+
all_events = sorted(e for evs in events_by_region for e in evs)
|
| 261 |
+
kept: list[int] = []
|
| 262 |
+
for e in all_events:
|
| 263 |
+
if kept and (e - kept[-1]) < refractory_s * fps:
|
| 264 |
+
continue
|
| 265 |
+
kept.append(e)
|
| 266 |
+
return kept
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def attribute_scorer(dets: list[Detection], poses: list[FramePose], goal_frame: int,
|
| 270 |
+
lookback: int = 90, cuts: list[int] | None = None) -> GoalEvent | None:
|
| 271 |
+
"""Track the ball BACKWARDS from the goal to the kick.
|
| 272 |
+
|
| 273 |
+
After the kick the ball travels goal-ward in one continuous fast run. Walking
|
| 274 |
+
backwards from the goal frame, the kick moment is where that run begins — ball
|
| 275 |
+
speed collapses from fast to slow (or the track starts). The leg-ball contact is
|
| 276 |
+
at/just before that onset; the nearest foot there identifies the scorer + side.
|
| 277 |
+
"""
|
| 278 |
+
ball = clean_ball_track(_ball_centers(dets))
|
| 279 |
+
n = len(ball)
|
| 280 |
+
floor = segment_of(goal_frame, cuts, n)[0] if cuts else 0
|
| 281 |
+
start = max(floor, goal_frame - lookback)
|
| 282 |
+
|
| 283 |
+
# per-frame ball speed (nan-safe)
|
| 284 |
+
speed = np.full(n, np.nan, dtype=np.float32)
|
| 285 |
+
speed[1:] = np.linalg.norm(np.diff(ball, axis=0), axis=1)
|
| 286 |
+
valid = ~np.isnan(speed[start:goal_frame + 1])
|
| 287 |
+
if valid.sum() < 3:
|
| 288 |
+
return None
|
| 289 |
+
fast = float(np.nanpercentile(speed[start:goal_frame + 1], 75))
|
| 290 |
+
slow_thresh = max(2.0, 0.35 * fast)
|
| 291 |
+
|
| 292 |
+
# walk back from the goal while the ball is in its fast goal-ward run;
|
| 293 |
+
# the kick onset = first frame (going backwards) where speed drops below slow_thresh
|
| 294 |
+
kick_frame = None
|
| 295 |
+
in_run = False
|
| 296 |
+
for i in range(goal_frame, start - 1, -1):
|
| 297 |
+
s = speed[i] if i < n else np.nan
|
| 298 |
+
if np.isnan(s):
|
| 299 |
+
continue
|
| 300 |
+
if s >= slow_thresh:
|
| 301 |
+
in_run = True
|
| 302 |
+
kick_frame = i # earliest fast frame seen so far
|
| 303 |
+
elif in_run:
|
| 304 |
+
break # run just started after this slow frame
|
| 305 |
+
if kick_frame is None:
|
| 306 |
+
return None
|
| 307 |
+
kick_frame = max(start, kick_frame - 1) # contact is the frame before launch
|
| 308 |
+
|
| 309 |
+
# nearest foot to the ball around the kick frame (+-2) -> scorer + kicking side
|
| 310 |
+
best_d, best_side, best_frame = 1e18, "r", kick_frame
|
| 311 |
+
best_box: Box | None = None
|
| 312 |
+
for i in range(max(start, kick_frame - 2), min(goal_frame, kick_frame + 3)):
|
| 313 |
+
if np.isnan(ball[i, 0]) or i >= len(poses) or not poses[i].ok():
|
| 314 |
+
continue
|
| 315 |
+
for side in ("l", "r"):
|
| 316 |
+
foot = poses[i].foot(side)
|
| 317 |
+
if foot is None:
|
| 318 |
+
continue
|
| 319 |
+
d = float(np.linalg.norm(foot - ball[i]))
|
| 320 |
+
if d < best_d:
|
| 321 |
+
best_d, best_side, best_frame = d, side, i
|
| 322 |
+
best_box = dets[i].person
|
| 323 |
+
|
| 324 |
+
r = dets[best_frame].ball.radius if dets[best_frame].ball is not None else 15.0
|
| 325 |
+
near = best_d < max(3.0 * r, 60.0)
|
| 326 |
+
conf = float(np.clip(1.0 - best_d / 150.0, 0.2, 0.95)) if near else 0.3
|
| 327 |
+
return GoalEvent(goal_frame=goal_frame, contact_frame=best_frame, scorer_box=best_box,
|
| 328 |
+
kicking_side=best_side, confidence=conf, ball_track=ball)
|
futheros/domain/models.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Domain value objects — the shared language of the core.
|
| 2 |
+
|
| 3 |
+
Pure data. No external model libs, no I/O. numpy is used only as the numeric
|
| 4 |
+
substrate for keypoint coordinates. Everything the application and adapters pass
|
| 5 |
+
around is defined here, so the domain never depends on a framework.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
from .config import LM, POSE_MIN_VISIBILITY
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# ---------- vision primitives ----------
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class Box:
|
| 20 |
+
x1: float
|
| 21 |
+
y1: float
|
| 22 |
+
x2: float
|
| 23 |
+
y2: float
|
| 24 |
+
conf: float
|
| 25 |
+
|
| 26 |
+
@property
|
| 27 |
+
def cx(self) -> float:
|
| 28 |
+
return (self.x1 + self.x2) / 2
|
| 29 |
+
|
| 30 |
+
@property
|
| 31 |
+
def cy(self) -> float:
|
| 32 |
+
return (self.y1 + self.y2) / 2
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def center(self) -> np.ndarray:
|
| 36 |
+
return np.array([self.cx, self.cy], dtype=np.float32)
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def area(self) -> float:
|
| 40 |
+
return max(0.0, self.x2 - self.x1) * max(0.0, self.y2 - self.y1)
|
| 41 |
+
|
| 42 |
+
@property
|
| 43 |
+
def radius(self) -> float:
|
| 44 |
+
return max(self.x2 - self.x1, self.y2 - self.y1) / 2
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class Detection:
|
| 49 |
+
"""Chosen subject + ball for a single frame."""
|
| 50 |
+
person: Box | None = None
|
| 51 |
+
ball: Box | None = None
|
| 52 |
+
ball_interpolated: bool = False
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@dataclass
|
| 56 |
+
class FramePose:
|
| 57 |
+
"""2D image-plane landmarks for one frame (pixels) + visibility.
|
| 58 |
+
|
| 59 |
+
The 3D/Z axis is intentionally absent: monocular depth from a side/3-4 view is
|
| 60 |
+
unreliable, so the domain refuses to model it.
|
| 61 |
+
"""
|
| 62 |
+
xy: np.ndarray | None # (33, 2) float32 pixels
|
| 63 |
+
vis: np.ndarray | None # (33,) float32
|
| 64 |
+
|
| 65 |
+
def ok(self) -> bool:
|
| 66 |
+
return self.xy is not None
|
| 67 |
+
|
| 68 |
+
def pt(self, name: str) -> np.ndarray | None:
|
| 69 |
+
if self.xy is None:
|
| 70 |
+
return None
|
| 71 |
+
i = LM[name]
|
| 72 |
+
if self.vis is not None and self.vis[i] < POSE_MIN_VISIBILITY:
|
| 73 |
+
return None
|
| 74 |
+
return self.xy[i]
|
| 75 |
+
|
| 76 |
+
def foot(self, side: str) -> np.ndarray | None:
|
| 77 |
+
for nm in (f"{side}_foot", f"{side}_heel", f"{side}_ankle"):
|
| 78 |
+
p = self.pt(nm)
|
| 79 |
+
if p is not None:
|
| 80 |
+
return p
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@dataclass
|
| 85 |
+
class Clip:
|
| 86 |
+
frames: list[np.ndarray] # upright BGR uint8
|
| 87 |
+
fps: float
|
| 88 |
+
width: int
|
| 89 |
+
height: int
|
| 90 |
+
t0_s: float = 0.0 # wall-clock time of frame 0 (for windowed loads)
|
| 91 |
+
|
| 92 |
+
@property
|
| 93 |
+
def n(self) -> int:
|
| 94 |
+
return len(self.frames)
|
| 95 |
+
|
| 96 |
+
def time_of(self, frame_idx: int) -> float:
|
| 97 |
+
return self.t0_s + frame_idx / (self.fps or 30.0)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ---------- analysis results ----------
|
| 101 |
+
|
| 102 |
+
@dataclass
|
| 103 |
+
class ContactResult:
|
| 104 |
+
frame_idx: int
|
| 105 |
+
kicking_side: str # 'l' | 'r'
|
| 106 |
+
method: str # how contact was found
|
| 107 |
+
confidence: float # 0..1
|
| 108 |
+
ball_track: np.ndarray | None = None
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@dataclass
|
| 112 |
+
class Feature:
|
| 113 |
+
key: str
|
| 114 |
+
label: str
|
| 115 |
+
value: float | None
|
| 116 |
+
unit: str
|
| 117 |
+
band: str # 'good' | 'amber' | 'poor' | 'na'
|
| 118 |
+
confidence: str # 'high' | 'med' | 'low'
|
| 119 |
+
note: str = ""
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@dataclass
|
| 123 |
+
class FeatureSet:
|
| 124 |
+
features: list[Feature] = field(default_factory=list)
|
| 125 |
+
kicking_side: str = "r"
|
| 126 |
+
contact_idx: int = 0
|
| 127 |
+
|
| 128 |
+
def by_key(self, key: str) -> Feature | None:
|
| 129 |
+
return next((f for f in self.features if f.key == key), None)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@dataclass
|
| 133 |
+
class CoachReport:
|
| 134 |
+
summary: str
|
| 135 |
+
cues: list[str] = field(default_factory=list)
|
| 136 |
+
drills: list[str] = field(default_factory=list)
|
| 137 |
+
score: int = 0
|
| 138 |
+
engine: str = "rule"
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@dataclass
|
| 142 |
+
class Trajectory:
|
| 143 |
+
"""Ball flight path around contact (2D image plane). Pre/post points are
|
| 144 |
+
(frame_idx, x, y) tuples; launch_angle_deg is the elevation of the path just
|
| 145 |
+
after contact (positive = rising)."""
|
| 146 |
+
pre_points: list[tuple[int, float, float]] = field(default_factory=list)
|
| 147 |
+
post_points: list[tuple[int, float, float]] = field(default_factory=list)
|
| 148 |
+
launch_angle_deg: float | None = None
|
| 149 |
+
direction: str = "—" # 'left' | 'right' | '—'
|
| 150 |
+
valid: bool = False
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@dataclass
|
| 154 |
+
class AnalysisResult:
|
| 155 |
+
"""The full output of an AnalyzeKick run (domain-level; adapters render it)."""
|
| 156 |
+
feature_set: FeatureSet
|
| 157 |
+
report: CoachReport
|
| 158 |
+
contact: ContactResult
|
| 159 |
+
score: int
|
| 160 |
+
fps: float
|
| 161 |
+
diagnostics: dict = field(default_factory=dict)
|
| 162 |
+
trajectory: "Trajectory | None" = None
|
| 163 |
+
# populated by output adapters:
|
| 164 |
+
annotated_video_path: str | None = None
|
| 165 |
+
hero_image: np.ndarray | None = None
|
| 166 |
+
scorecard_image: np.ndarray | None = None
|
futheros/domain/trajectory.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ball trajectory + shot launch angle — pure domain logic (2D image plane).
|
| 2 |
+
|
| 3 |
+
We already detect the ball most frames, so the path is nearly free. The launch angle
|
| 4 |
+
is the elevation of the ball's path in the frames just after contact: positive = the
|
| 5 |
+
ball is rising. This is an in-plane ratio (rise over run), so it's defensible from a
|
| 6 |
+
side/3-4 view without any depth or metric scale — unlike speed, which we never claim.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
from .biomechanics import make_feature
|
| 13 |
+
from .models import ContactResult, Detection, Feature, Trajectory
|
| 14 |
+
|
| 15 |
+
POST_WINDOW = 12 # frames after contact used to fit the launch direction
|
| 16 |
+
PRE_WINDOW = 12
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _centers(dets: list[Detection]) -> np.ndarray:
|
| 20 |
+
n = len(dets)
|
| 21 |
+
pts = np.full((n, 2), np.nan, dtype=np.float32)
|
| 22 |
+
for i, d in enumerate(dets):
|
| 23 |
+
if d.ball is not None:
|
| 24 |
+
pts[i] = [d.ball.cx, d.ball.cy]
|
| 25 |
+
return pts
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def compute_trajectory(dets: list[Detection], contact: ContactResult) -> Trajectory:
|
| 29 |
+
n = len(dets)
|
| 30 |
+
ci = contact.frame_idx
|
| 31 |
+
pts = _centers(dets)
|
| 32 |
+
|
| 33 |
+
pre = [(i, float(pts[i, 0]), float(pts[i, 1]))
|
| 34 |
+
for i in range(max(0, ci - PRE_WINDOW), ci + 1) if not np.isnan(pts[i, 0])]
|
| 35 |
+
post = [(i, float(pts[i, 0]), float(pts[i, 1]))
|
| 36 |
+
for i in range(ci, min(n, ci + POST_WINDOW + 1)) if not np.isnan(pts[i, 0])]
|
| 37 |
+
|
| 38 |
+
traj = Trajectory(pre_points=pre, post_points=post)
|
| 39 |
+
if len(post) >= 3:
|
| 40 |
+
# launch vector from contact point to the mean of the next few detections
|
| 41 |
+
x0, y0 = post[0][1], post[0][2]
|
| 42 |
+
tail = post[1:6] if len(post) > 6 else post[1:]
|
| 43 |
+
dx = float(np.mean([p[1] for p in tail])) - x0
|
| 44 |
+
dy = float(np.mean([p[2] for p in tail])) - y0 # image y grows downward
|
| 45 |
+
if abs(dx) + abs(dy) > 2.0: # ball actually moved
|
| 46 |
+
# elevation above horizontal: rise = -dy (up positive), run = |dx|
|
| 47 |
+
traj.launch_angle_deg = float(np.degrees(np.arctan2(-dy, abs(dx) + 1e-6)))
|
| 48 |
+
traj.direction = "right" if dx > 0 else "left"
|
| 49 |
+
traj.valid = True
|
| 50 |
+
return traj
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def launch_angle_feature(traj: Trajectory) -> Feature:
|
| 54 |
+
"""Wrap the launch angle as a scored Feature so it flows into the scorecard,
|
| 55 |
+
coach and LLM like the biomechanics features."""
|
| 56 |
+
return make_feature("launch_angle", traj.launch_angle_deg if traj.valid else None)
|
futheros/ports/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ports — the abstract boundary of the hexagon.
|
| 2 |
+
|
| 3 |
+
The application service depends only on these Protocols; concrete adapters in
|
| 4 |
+
`futheros.adapters` implement them. Swapping RF-DETR for YOLO, or llama.cpp for a
|
| 5 |
+
rule engine, is a wiring change in the composition root, not a code change here.
|
| 6 |
+
"""
|
| 7 |
+
from .coach import CoachPort
|
| 8 |
+
from .detector import DetectorPort
|
| 9 |
+
from .pose import PosePort
|
| 10 |
+
from .renderer import RendererPort
|
| 11 |
+
from .video import VideoSinkPort, VideoSourcePort
|
| 12 |
+
|
| 13 |
+
__all__ = ["CoachPort", "DetectorPort", "PosePort", "RendererPort",
|
| 14 |
+
"VideoSinkPort", "VideoSourcePort"]
|
futheros/ports/coach.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Protocol, runtime_checkable
|
| 4 |
+
|
| 5 |
+
from ..domain.models import CoachReport, FeatureSet
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@runtime_checkable
|
| 9 |
+
class CoachPort(Protocol):
|
| 10 |
+
"""Turns measured features into a coaching report (summary, cues, drills)."""
|
| 11 |
+
|
| 12 |
+
name: str
|
| 13 |
+
|
| 14 |
+
def coach(self, feature_set: FeatureSet) -> CoachReport:
|
| 15 |
+
...
|
futheros/ports/detector.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Protocol, runtime_checkable
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from ..domain.models import Detection
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@runtime_checkable
|
| 11 |
+
class DetectorPort(Protocol):
|
| 12 |
+
"""Finds the subject player + ball per frame and returns one Detection each."""
|
| 13 |
+
|
| 14 |
+
name: str
|
| 15 |
+
|
| 16 |
+
def detect(self, frames_bgr: list[np.ndarray]) -> list[Detection]:
|
| 17 |
+
...
|
futheros/ports/goal_detector.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Protocol, runtime_checkable
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from ..domain.models import Box
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@runtime_checkable
|
| 11 |
+
class GoalDetectorPort(Protocol):
|
| 12 |
+
"""Locates the goal-mouth region(s) in a frame. For a fixed camera this is run
|
| 13 |
+
once and reused; the result feeds goal-event detection + scorer attribution."""
|
| 14 |
+
|
| 15 |
+
name: str
|
| 16 |
+
|
| 17 |
+
def detect_goals(self, frame_bgr: np.ndarray, conf: float = 0.4) -> list[Box]:
|
| 18 |
+
...
|
futheros/ports/pose.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Protocol, runtime_checkable
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from ..domain.models import FramePose
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@runtime_checkable
|
| 11 |
+
class PosePort(Protocol):
|
| 12 |
+
"""Extracts per-frame 2D body landmarks (image plane only)."""
|
| 13 |
+
|
| 14 |
+
name: str
|
| 15 |
+
|
| 16 |
+
def estimate(self, frames_bgr: list[np.ndarray]) -> list[FramePose]:
|
| 17 |
+
...
|
| 18 |
+
|
| 19 |
+
def close(self) -> None:
|
| 20 |
+
...
|
futheros/ports/renderer.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Protocol, runtime_checkable
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from ..domain.models import (Clip, ContactResult, Detection, FeatureSet, FramePose,
|
| 8 |
+
Trajectory)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@runtime_checkable
|
| 12 |
+
class RendererPort(Protocol):
|
| 13 |
+
"""Produces visual artifacts: annotated frames, hero freeze-frame, scorecard."""
|
| 14 |
+
|
| 15 |
+
def annotate(self, clip: Clip, poses: list[FramePose], dets: list[Detection],
|
| 16 |
+
fs: FeatureSet, contact: ContactResult,
|
| 17 |
+
trajectory: Trajectory | None = None) -> list[np.ndarray]:
|
| 18 |
+
...
|
| 19 |
+
|
| 20 |
+
def hero(self, clip: Clip, poses: list[FramePose], dets: list[Detection],
|
| 21 |
+
fs: FeatureSet, contact: ContactResult,
|
| 22 |
+
trajectory: Trajectory | None = None) -> np.ndarray:
|
| 23 |
+
...
|
| 24 |
+
|
| 25 |
+
def scorecard(self, fs: FeatureSet, score: int) -> np.ndarray:
|
| 26 |
+
...
|
futheros/ports/video.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Protocol, runtime_checkable
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from ..domain.models import Clip
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@runtime_checkable
|
| 11 |
+
class VideoSourcePort(Protocol):
|
| 12 |
+
"""Loads a video file into an upright in-memory Clip."""
|
| 13 |
+
|
| 14 |
+
def load(self, path: str, force_rotation: int | None = None) -> Clip:
|
| 15 |
+
...
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@runtime_checkable
|
| 19 |
+
class VideoSinkPort(Protocol):
|
| 20 |
+
"""Writes annotated frames to a playable video file, returns its path."""
|
| 21 |
+
|
| 22 |
+
def write(self, frames_bgr: list[np.ndarray], out_path: str, fps: float) -> str:
|
| 23 |
+
...
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy
|
| 2 |
+
opencv-python-headless
|
| 3 |
+
gradio
|
| 4 |
+
spaces
|
| 5 |
+
mediapipe>=0.10.18
|
| 6 |
+
rfdetr>=1.0
|
| 7 |
+
supervision
|
| 8 |
+
sam3==0.1.4
|
| 9 |
+
huggingface-hub
|