Spaces:
Sleeping
Sleeping
"""Ball detection and tracking for the DRS application. | |
This module implements a simple motion‑based tracker to follow the cricket | |
ball in a video. Professional ball tracking systems use multiple high | |
frame‑rate cameras and sophisticated object detectors. Here, we rely on | |
background subtraction combined with circle detection (Hough circles) to | |
locate the ball in each frame. The tracker keeps the coordinates and | |
timestamps of the ball's centre so that downstream modules can | |
estimate its trajectory and predict whether it will hit the stumps. | |
The detection pipeline makes the following assumptions: | |
* Only one ball is present in the scene at a time. | |
* The ball is approximately circular in appearance. | |
* The camera is static or moves little compared to the ball. | |
These assumptions hold for many amateur cricket recordings but are | |
obviously simplified compared to a true DRS system. | |
""" | |
from __future__ import annotations | |
import cv2 | |
import numpy as np | |
from typing import Dict, List, Tuple | |
def detect_and_track_ball(video_path: str) -> Dict[str, List]: | |
"""Detect and track the cricket ball in a video. | |
Parameters | |
---------- | |
video_path: str | |
Path to the trimmed video segment containing the delivery and appeal. | |
Returns | |
------- | |
Dict[str, List] | |
A dictionary containing: | |
``centers``: list of (x, y) coordinates of the ball in successive frames. | |
``timestamps``: list of timestamps (in seconds) corresponding to each centre. | |
``radii``: list of detected circle radii (in pixels). | |
""" | |
cap = cv2.VideoCapture(video_path) | |
if not cap.isOpened(): | |
raise RuntimeError(f"Could not open video {video_path}") | |
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0 | |
# Background subtractor for motion detection | |
bg_sub = cv2.createBackgroundSubtractorMOG2(history=500, varThreshold=32, detectShadows=False) | |
centers: List[Tuple[int, int]] = [] | |
radii: List[int] = [] | |
timestamps: List[float] = [] | |
previous_center: Tuple[int, int] | None = None | |
frame_idx = 0 | |
while True: | |
ret, frame = cap.read() | |
if not ret: | |
break | |
timestamp = frame_idx / fps | |
# Preprocess: grayscale and blur | |
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) | |
blurred = cv2.GaussianBlur(gray, (5, 5), 0) | |
# Apply background subtraction to emphasise moving objects | |
fg_mask = bg_sub.apply(frame) | |
# Remove noise from mask | |
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) | |
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel) | |
detected_center: Tuple[int, int] | None = None | |
detected_radius: int | None = None | |
# Attempt to detect circles using Hough transform | |
circles = cv2.HoughCircles( | |
blurred, | |
cv2.HOUGH_GRADIENT, | |
dp=1.2, | |
minDist=20, | |
param1=50, | |
param2=30, | |
minRadius=3, | |
maxRadius=30, | |
) | |
if circles is not None: | |
circles = np.round(circles[0, :]).astype("int") | |
# Choose the circle closest to the previous detection to maintain | |
# continuity. If no previous detection exists, pick the circle | |
# with the smallest radius (likely the ball). | |
if previous_center is not None: | |
min_dist = float("inf") | |
chosen = None | |
for x, y, r in circles: | |
dist = (x - previous_center[0]) ** 2 + (y - previous_center[1]) ** 2 | |
if dist < min_dist: | |
min_dist = dist | |
chosen = (x, y, r) | |
if chosen is not None: | |
detected_center = (int(chosen[0]), int(chosen[1])) | |
detected_radius = int(chosen[2]) | |
else: | |
# No previous centre: pick the smallest radius circle | |
chosen = min(circles, key=lambda c: c[2]) | |
detected_center = (int(chosen[0]), int(chosen[1])) | |
detected_radius = int(chosen[2]) | |
# Fallback: use contours on the foreground mask to find moving blobs | |
if detected_center is None: | |
contours, _ = cv2.findContours(fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
# Filter contours by area to eliminate noise; choose the one | |
# closest to previous centre or the smallest area blob | |
candidates = [] | |
for cnt in contours: | |
area = cv2.contourArea(cnt) | |
if 10 < area < 800: # adjust thresholds as necessary | |
x, y, w, h = cv2.boundingRect(cnt) | |
cx = x + w // 2 | |
cy = y + h // 2 | |
candidates.append((cx, cy, w, h, area)) | |
if candidates: | |
if previous_center is not None: | |
chosen = min(candidates, key=lambda c: (c[0] - previous_center[0]) ** 2 + (c[1] - previous_center[1]) ** 2) | |
else: | |
chosen = min(candidates, key=lambda c: c[4]) | |
cx, cy, w, h, _ = chosen | |
detected_center = (int(cx), int(cy)) | |
detected_radius = int(max(w, h) / 2) | |
if detected_center is not None: | |
centers.append(detected_center) | |
radii.append(detected_radius or 5) | |
timestamps.append(timestamp) | |
previous_center = detected_center | |
# Increment frame index regardless of detection | |
frame_idx += 1 | |
cap.release() | |
return {"centers": centers, "radii": radii, "timestamps": timestamps} |