dcrey7 commited on
Commit
0ec4e3c
·
1 Parent(s): 90a33c5

FUT-HEROS match mode: SAM3 goal detection + RF-DETR ball tracking + goal events + per-strike coaching (ZeroGPU)

Browse files
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __pycache__/
2
+ *.pyc
README.md CHANGED
@@ -1,14 +1,35 @@
1
  ---
2
- title: Fut Heros
3
- emoji: 💻
4
- colorFrom: indigo
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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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