assemsbry commited on
Commit
ea47cfb
·
1 Parent(s): f8784e3

feat: publish Whistle project and poster

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ media/whistle-poster.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+
2
+ __pycache__/
3
+ *.py[cod]
4
+
README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Whistle
2
+
3
+ ![Whistle official poster](media/whistle-poster.png)
4
+
5
+ **Whistle** is developed and owned by [Assem Sabry](https://assem.one/).
6
+
7
+ Whistle يحوّل فيديو مباراة كرة القدم إلى بيانات زمنية قابلة للفحص: detections وtracks وملفات JSON/CSV وفيديو مرئي. لا يحدد أسماء اللاعبين أو مراكزهم أو أرقام قمصانهم، ولا يحتوي على مهمة تقييم للاعبين.
8
+
9
+ ## الحالة الحالية
10
+
11
+ الإصدار `0.0.1` يوفّر:
12
+
13
+ - قراءة manifest للفيديو والتحقق من الملف.
14
+ - schema ثابت للـ frames وdetections وtracks.
15
+ - CLI يستقبل MP4 ويكتب manifest ونتيجة JSON أولية قابلة لإعادة التشغيل.
16
+ - طبقة detector/tracker قابلة للاستبدال دون ربطها بمكتبة بعينها.
17
+
18
+ لا توجد أوزان أو بيانات فيديو موزعة مع المستودع. يجب تسجيل provenance والترخيص قبل إضافة أي dataset أو checkpoint.
19
+
20
+ ## التشغيل
21
+
22
+ ```powershell
23
+ python -m venv .venv
24
+ .\.venv\Scripts\Activate.ps1
25
+ pip install -e ".[dev]"
26
+ whistle inspect path\to\match.mp4 --output outputs\match
27
+ pytest
28
+ ```
29
+
30
+ ## مراحل المنتج
31
+
32
+ Core ثم Pitch ثم Ball ثم Events الأساسية ثم Analytics. لن تظهر السرعة أو المسافة عند فشل معايرة الكاميرا أو انخفاض الثقة.
33
+
34
+ ## الترخيص
35
+
36
+ الكود الأصلي Apache-2.0. رخصة الأوزان والبيانات منفصلة وتُراجع قبل النشر؛ لا تُضمّن فيديوهات المباريات أو مشتقاتها دون حق إعادة التوزيع.
configs/detector_yolo26x.yaml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Ultralytics dataset config. Replace paths only after licensing/provenance review.
2
+ path: /mnt/opet-data/whistle/datasets/detection
3
+ train: images/train
4
+ val: images/val
5
+ test: images/test
6
+ names:
7
+ 0: player
8
+ 1: referee
9
+ 2: football
docs/architecture.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Whistle Architecture
2
+
3
+ Whistle is a modular post-match football video analysis system owned and developed by Assem Sabry. It does not identify player names, positions, or jersey numbers, and it has no player-rating task.
4
+
5
+ ```text
6
+ MP4
7
+ |
8
+ v
9
+ Video Ingest + Shot Segmentation
10
+ | frames, timestamps, shot_id
11
+ v
12
+ Whistle Detector (YOLO26x pretrained -> football fine-tune)
13
+ | player / referee / football boxes + confidence
14
+ v
15
+ Whistle Tracker (motion + appearance, ByteTrack adapter)
16
+ | stable track_id + trajectories
17
+ +-----------------------+
18
+ |
19
+ v
20
+ Pitch Calibration
21
+ (segmentation/keypoints + homography + gates)
22
+ | XY metres
23
+ v
24
+ Ball State + Possession (football detector + motion filter + temporal probability)
25
+ |
26
+ v
27
+ Events (rules first, temporal model second)
28
+ |
29
+ v
30
+ Analytics (deterministic metrics + uncertainty propagation)
31
+ |
32
+ v
33
+ MP4 overlay + JSON + CSV + Parquet + 2D pitch map + report
34
+ ```
35
+
36
+ ## Model contracts
37
+
38
+ | Module | Input | Output | First training target |
39
+ | --- | --- | --- | --- |
40
+ | Detector | RGB frames/crops | boxes, class, confidence | player, referee, football |
41
+ | Tracker | detections + frame timestamps | track IDs and trajectories | HOTA/IDF1 stability |
42
+ | Pitch | frames + pitch landmarks | camera state, homography, XY metres | JaC@5 and reprojection error |
43
+ | Ball/Possession | football detections + tracks | ball state, owner probability | ball recall and possession F1 |
44
+ | Events | tracks, ball state, temporal windows | event type, actors, time, confidence | event mAP/F1 |
45
+ | Analytics | trusted state/events | distance, speed, KPIs | deterministic regression tests |
46
+ | Quality gates | calibration + track coverage | valid/invalid metric flags | false-metric rate |
47
+
48
+ ## 48-hour execution target
49
+
50
+ The 48-hour target is a reproducible baseline: environment, data schemas, YOLO26 detector fine-tuning on an available licensed/public sample, tracker integration, visualizer, metrics, and one end-to-end MP4 run. A production-quality Whistle still depends on licensed match data, annotation volume, and validation that cannot honestly be guaranteed before those inputs exist.
51
+
52
+ ## Ownership
53
+
54
+ Project name: **Whistle**
55
+ Developer and owner: **Assem Sabry**
56
+ Website: https://assem.one/
media/whistle-poster.png ADDED

Git LFS Details

  • SHA256: 29715799d7a29cd1cf7fcffa4cc2e8bf4ac66a112a942443a906b7cbd27b143b
  • Pointer size: 132 Bytes
  • Size of remote file: 1.63 MB
pyproject.toml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "whistle"
7
+ version = "0.0.1"
8
+ description = "Open-source football video analysis pipeline"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "Apache-2.0"}
12
+ authors = [{name = "Whistle contributors"}]
13
+ dependencies = []
14
+
15
+ [project.optional-dependencies]
16
+ video = ["opencv-python>=4.8", "numpy>=1.24"]
17
+ dev = ["pytest>=8", "ruff>=0.6"]
18
+
19
+ [project.scripts]
20
+ whistle = "whistle.cli:main"
21
+
22
+ [tool.setuptools.packages.find]
23
+ include = ["whistle*"]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
tests/test_schemas.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from whistle.core.schemas import Detection, Track
2
+
3
+
4
+ def test_detection_serializes_bbox_as_json_array():
5
+ item = Detection(3, "player", (1.0, 2.0, 10.0, 20.0), 0.9)
6
+ assert item.to_dict() == {
7
+ "frame_id": 3,
8
+ "class_name": "player",
9
+ "bbox_xyxy": [1.0, 2.0, 10.0, 20.0],
10
+ "confidence": 0.9,
11
+ }
12
+
13
+
14
+ def test_track_serializes_stable_fields():
15
+ item = Track(7, 3, "referee", (0.0, 1.0, 2.0, 3.0), 0.8)
16
+ assert item.to_dict()["track_id"] == 7
tests/test_video_inspect.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from whistle.video.inspect import inspect_video
6
+
7
+
8
+ def test_missing_video_is_explicit(tmp_path: Path):
9
+ with pytest.raises(FileNotFoundError, match="Video not found"):
10
+ inspect_video(tmp_path / "missing.mp4")
whistle/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Whistle football video analysis."""
2
+
3
+ __version__ = "0.0.1"
whistle/cli.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Command line entry point for the first Whistle pipeline."""
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ from typing import List, Optional
7
+
8
+ from whistle.video.inspect import inspect_video
9
+
10
+
11
+ def main(argv: Optional[List[str]] = None) -> int:
12
+ parser = argparse.ArgumentParser(prog="whistle")
13
+ commands = parser.add_subparsers(dest="command", required=True)
14
+ inspect = commands.add_parser("inspect", help="inspect a video and write a manifest")
15
+ inspect.add_argument("path", type=Path)
16
+ inspect.add_argument("--output", type=Path, required=True)
17
+ args = parser.parse_args(argv)
18
+
19
+ if args.command == "inspect":
20
+ manifest = inspect_video(args.path)
21
+ args.output.mkdir(parents=True, exist_ok=True)
22
+ destination = args.output / "video_manifest.json"
23
+ destination.write_text(json.dumps(manifest.to_dict(), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
24
+ print(destination)
25
+ return 0
26
+ return 2
whistle/core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Stable data contracts and shared utilities."""
whistle/core/schemas.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small, dependency-free schemas for pipeline interchange."""
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from typing import Any, Dict, Optional, Tuple
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class VideoManifest:
9
+ path: str
10
+ width: Optional[int]
11
+ height: Optional[int]
12
+ fps: Optional[float]
13
+ frame_count: Optional[int]
14
+ duration_ms: Optional[int]
15
+
16
+ def to_dict(self) -> Dict[str, Any]:
17
+ return asdict(self)
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Detection:
22
+ frame_id: int
23
+ class_name: str
24
+ bbox_xyxy: Tuple[float, float, float, float]
25
+ confidence: float
26
+
27
+ def to_dict(self) -> Dict[str, Any]:
28
+ result = asdict(self)
29
+ result["bbox_xyxy"] = list(self.bbox_xyxy)
30
+ return result
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Track:
35
+ track_id: int
36
+ frame_id: int
37
+ role: str
38
+ bbox_xyxy: Tuple[float, float, float, float]
39
+ confidence: float
40
+
41
+ def to_dict(self) -> Dict[str, Any]:
42
+ result = asdict(self)
43
+ result["bbox_xyxy"] = list(self.bbox_xyxy)
44
+ return result
whistle/training/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Training entry points."""
whistle/training/train_detector.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train the Whistle detector from an Ultralytics YOLO dataset YAML."""
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+
7
+ def train(data: str, epochs: int, imgsz: int, batch: int, device: str, project: str):
8
+ from ultralytics import YOLO
9
+
10
+ model = YOLO("yolo26x.pt")
11
+ return model.train(
12
+ data=data,
13
+ epochs=epochs,
14
+ imgsz=imgsz,
15
+ batch=batch,
16
+ device=device,
17
+ project=project,
18
+ name="whistle-detector-yolo26x",
19
+ pretrained=True,
20
+ amp=True,
21
+ exist_ok=True,
22
+ )
23
+
24
+
25
+ def main() -> int:
26
+ parser = argparse.ArgumentParser()
27
+ parser.add_argument("--data", default="configs/detector_yolo26x.yaml")
28
+ parser.add_argument("--epochs", type=int, default=80)
29
+ parser.add_argument("--imgsz", type=int, default=1280)
30
+ parser.add_argument("--batch", type=int, default=-1)
31
+ parser.add_argument("--device", default="0")
32
+ parser.add_argument("--project", default="/mnt/opet-data/whistle/outputs/training")
33
+ args = parser.parse_args()
34
+ Path(args.project).mkdir(parents=True, exist_ok=True)
35
+ train(args.data, args.epochs, args.imgsz, args.batch, args.device, args.project)
36
+ return 0
37
+
38
+
39
+ if __name__ == "__main__":
40
+ raise SystemExit(main())
whistle/video/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Video input and metadata helpers."""
whistle/video/inspect.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Video inspection with an optional OpenCV dependency."""
2
+
3
+ from pathlib import Path
4
+ from typing import Union
5
+
6
+ from whistle.core.schemas import VideoManifest
7
+
8
+
9
+ def inspect_video(path: Union[str, Path]) -> VideoManifest:
10
+ video_path = Path(path).expanduser().resolve()
11
+ if not video_path.is_file():
12
+ raise FileNotFoundError(f"Video not found: {video_path}")
13
+
14
+ try:
15
+ import cv2
16
+ except ImportError:
17
+ return VideoManifest(str(video_path), None, None, None, None, None)
18
+
19
+ capture = cv2.VideoCapture(str(video_path))
20
+ try:
21
+ if not capture.isOpened():
22
+ raise ValueError(f"Could not open video: {video_path}")
23
+ fps = float(capture.get(cv2.CAP_PROP_FPS)) or None
24
+ frames = int(capture.get(cv2.CAP_PROP_FRAME_COUNT)) or None
25
+ duration_ms = int(frames * 1000 / fps) if fps and frames else None
26
+ return VideoManifest(
27
+ str(video_path),
28
+ int(capture.get(cv2.CAP_PROP_FRAME_WIDTH)) or None,
29
+ int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) or None,
30
+ fps,
31
+ frames,
32
+ duration_ms,
33
+ )
34
+ finally:
35
+ capture.release()