Spaces:
Sleeping
Sleeping
Upload 6 files
Browse files- drs_modules/__init__.py +7 -0
- drs_modules/detection.py +142 -0
- drs_modules/lbw_decision.py +52 -0
- drs_modules/trajectory.py +117 -0
- drs_modules/video_processing.py +117 -0
- drs_modules/visualization.py +219 -0
drs_modules/__init__.py
ADDED
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Helper package for the DRS application.
|
2 |
+
|
3 |
+
This package groups together the individual components used by the
|
4 |
+
Digital Review System. Each submodule focuses on a specific part of
|
5 |
+
the pipeline: video processing, ball detection and tracking,
|
6 |
+
trajectory estimation, LBW decision logic and result visualisation.
|
7 |
+
"""
|
drs_modules/detection.py
ADDED
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Ball detection and tracking for the DRS application.
|
2 |
+
|
3 |
+
This module implements a simple motion‑based tracker to follow the cricket
|
4 |
+
ball in a video. Professional ball tracking systems use multiple high
|
5 |
+
frame‑rate cameras and sophisticated object detectors. Here, we rely on
|
6 |
+
background subtraction combined with circle detection (Hough circles) to
|
7 |
+
locate the ball in each frame. The tracker keeps the coordinates and
|
8 |
+
timestamps of the ball's centre so that downstream modules can
|
9 |
+
estimate its trajectory and predict whether it will hit the stumps.
|
10 |
+
|
11 |
+
The detection pipeline makes the following assumptions:
|
12 |
+
|
13 |
+
* Only one ball is present in the scene at a time.
|
14 |
+
* The ball is approximately circular in appearance.
|
15 |
+
* The camera is static or moves little compared to the ball.
|
16 |
+
|
17 |
+
These assumptions hold for many amateur cricket recordings but are
|
18 |
+
obviously simplified compared to a true DRS system.
|
19 |
+
"""
|
20 |
+
|
21 |
+
from __future__ import annotations
|
22 |
+
|
23 |
+
import cv2
|
24 |
+
import numpy as np
|
25 |
+
from typing import Dict, List, Tuple
|
26 |
+
|
27 |
+
|
28 |
+
def detect_and_track_ball(video_path: str) -> Dict[str, List]:
|
29 |
+
"""Detect and track the cricket ball in a video.
|
30 |
+
|
31 |
+
Parameters
|
32 |
+
----------
|
33 |
+
video_path: str
|
34 |
+
Path to the trimmed video segment containing the delivery and appeal.
|
35 |
+
|
36 |
+
Returns
|
37 |
+
-------
|
38 |
+
Dict[str, List]
|
39 |
+
A dictionary containing:
|
40 |
+
``centers``: list of (x, y) coordinates of the ball in successive frames.
|
41 |
+
``timestamps``: list of timestamps (in seconds) corresponding to each centre.
|
42 |
+
``radii``: list of detected circle radii (in pixels).
|
43 |
+
"""
|
44 |
+
cap = cv2.VideoCapture(video_path)
|
45 |
+
if not cap.isOpened():
|
46 |
+
raise RuntimeError(f"Could not open video {video_path}")
|
47 |
+
|
48 |
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
49 |
+
|
50 |
+
# Background subtractor for motion detection
|
51 |
+
bg_sub = cv2.createBackgroundSubtractorMOG2(history=500, varThreshold=32, detectShadows=False)
|
52 |
+
|
53 |
+
centers: List[Tuple[int, int]] = []
|
54 |
+
radii: List[int] = []
|
55 |
+
timestamps: List[float] = []
|
56 |
+
|
57 |
+
previous_center: Tuple[int, int] | None = None
|
58 |
+
frame_idx = 0
|
59 |
+
while True:
|
60 |
+
ret, frame = cap.read()
|
61 |
+
if not ret:
|
62 |
+
break
|
63 |
+
timestamp = frame_idx / fps
|
64 |
+
|
65 |
+
# Preprocess: grayscale and blur
|
66 |
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
67 |
+
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
68 |
+
|
69 |
+
# Apply background subtraction to emphasise moving objects
|
70 |
+
fg_mask = bg_sub.apply(frame)
|
71 |
+
# Remove noise from mask
|
72 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
|
73 |
+
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel)
|
74 |
+
|
75 |
+
detected_center: Tuple[int, int] | None = None
|
76 |
+
detected_radius: int | None = None
|
77 |
+
|
78 |
+
# Attempt to detect circles using Hough transform
|
79 |
+
circles = cv2.HoughCircles(
|
80 |
+
blurred,
|
81 |
+
cv2.HOUGH_GRADIENT,
|
82 |
+
dp=1.2,
|
83 |
+
minDist=20,
|
84 |
+
param1=50,
|
85 |
+
param2=30,
|
86 |
+
minRadius=3,
|
87 |
+
maxRadius=30,
|
88 |
+
)
|
89 |
+
if circles is not None:
|
90 |
+
circles = np.round(circles[0, :]).astype("int")
|
91 |
+
# Choose the circle closest to the previous detection to maintain
|
92 |
+
# continuity. If no previous detection exists, pick the circle
|
93 |
+
# with the smallest radius (likely the ball).
|
94 |
+
if previous_center is not None:
|
95 |
+
min_dist = float("inf")
|
96 |
+
chosen = None
|
97 |
+
for x, y, r in circles:
|
98 |
+
dist = (x - previous_center[0]) ** 2 + (y - previous_center[1]) ** 2
|
99 |
+
if dist < min_dist:
|
100 |
+
min_dist = dist
|
101 |
+
chosen = (x, y, r)
|
102 |
+
if chosen is not None:
|
103 |
+
detected_center = (int(chosen[0]), int(chosen[1]))
|
104 |
+
detected_radius = int(chosen[2])
|
105 |
+
else:
|
106 |
+
# No previous centre: pick the smallest radius circle
|
107 |
+
chosen = min(circles, key=lambda c: c[2])
|
108 |
+
detected_center = (int(chosen[0]), int(chosen[1]))
|
109 |
+
detected_radius = int(chosen[2])
|
110 |
+
|
111 |
+
# Fallback: use contours on the foreground mask to find moving blobs
|
112 |
+
if detected_center is None:
|
113 |
+
contours, _ = cv2.findContours(fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
114 |
+
# Filter contours by area to eliminate noise; choose the one
|
115 |
+
# closest to previous centre or the smallest area blob
|
116 |
+
candidates = []
|
117 |
+
for cnt in contours:
|
118 |
+
area = cv2.contourArea(cnt)
|
119 |
+
if 10 < area < 800: # adjust thresholds as necessary
|
120 |
+
x, y, w, h = cv2.boundingRect(cnt)
|
121 |
+
cx = x + w // 2
|
122 |
+
cy = y + h // 2
|
123 |
+
candidates.append((cx, cy, w, h, area))
|
124 |
+
if candidates:
|
125 |
+
if previous_center is not None:
|
126 |
+
chosen = min(candidates, key=lambda c: (c[0] - previous_center[0]) ** 2 + (c[1] - previous_center[1]) ** 2)
|
127 |
+
else:
|
128 |
+
chosen = min(candidates, key=lambda c: c[4])
|
129 |
+
cx, cy, w, h, _ = chosen
|
130 |
+
detected_center = (int(cx), int(cy))
|
131 |
+
detected_radius = int(max(w, h) / 2)
|
132 |
+
|
133 |
+
if detected_center is not None:
|
134 |
+
centers.append(detected_center)
|
135 |
+
radii.append(detected_radius or 5)
|
136 |
+
timestamps.append(timestamp)
|
137 |
+
previous_center = detected_center
|
138 |
+
# Increment frame index regardless of detection
|
139 |
+
frame_idx += 1
|
140 |
+
|
141 |
+
cap.release()
|
142 |
+
return {"centers": centers, "radii": radii, "timestamps": timestamps}
|
drs_modules/lbw_decision.py
ADDED
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""LBW decision logic for the cricket DRS demo.
|
2 |
+
|
3 |
+
This module encapsulates the high‑level logic used to decide whether a
|
4 |
+
batsman is out leg before wicket (LBW) based on the ball's trajectory.
|
5 |
+
In professional systems the decision depends on many factors: where
|
6 |
+
the ball pitched, the line it travelled relative to the stumps, the
|
7 |
+
height of impact on the pad/glove, and whether the batsman offered a
|
8 |
+
shot. To keep this example straightforward we apply a very simple
|
9 |
+
rule:
|
10 |
+
|
11 |
+
* If the predicted trajectory intersects the stumps, the batsman is
|
12 |
+
declared **OUT**.
|
13 |
+
* Otherwise the batsman is **NOT OUT**.
|
14 |
+
|
15 |
+
We also return the index of the frame deemed to be the impact frame.
|
16 |
+
Here we take the impact frame to be the last frame where the ball was
|
17 |
+
detected. In a more complete system one would detect the moment of
|
18 |
+
contact with the pad or glove and use that as the impact frame.
|
19 |
+
"""
|
20 |
+
|
21 |
+
from __future__ import annotations
|
22 |
+
|
23 |
+
from typing import List, Tuple
|
24 |
+
|
25 |
+
|
26 |
+
def make_lbw_decision(
|
27 |
+
centers: List[Tuple[int, int]],
|
28 |
+
trajectory_model: dict,
|
29 |
+
will_hit_stumps: bool,
|
30 |
+
) -> Tuple[str, int]:
|
31 |
+
"""Return a simple LBW decision based on trajectory intersection.
|
32 |
+
|
33 |
+
Parameters
|
34 |
+
----------
|
35 |
+
centers: list of tuple(int, int)
|
36 |
+
Sequence of detected ball centres.
|
37 |
+
trajectory_model: dict
|
38 |
+
The polynomial model fitted to the ball path (unused directly
|
39 |
+
here but included for extensibility).
|
40 |
+
will_hit_stumps: bool
|
41 |
+
Prediction that the ball's path intersects the stumps.
|
42 |
+
|
43 |
+
Returns
|
44 |
+
-------
|
45 |
+
Tuple[str, int]
|
46 |
+
The decision text (``"OUT"`` or ``"NOT OUT"``) and the index
|
47 |
+
of the impact frame. The impact frame is taken to be the
|
48 |
+
index of the last detection.
|
49 |
+
"""
|
50 |
+
impact_frame_idx = len(centers) - 1 if centers else -1
|
51 |
+
decision = "OUT" if will_hit_stumps else "NOT OUT"
|
52 |
+
return decision, impact_frame_idx
|
drs_modules/trajectory.py
ADDED
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Trajectory estimation for the cricket ball.
|
2 |
+
|
3 |
+
Professional ball tracking systems reconstruct the ball's path in 3D
|
4 |
+
from several camera angles and then use physics or machine learning
|
5 |
+
models to project its flight. Here we implement a far simpler
|
6 |
+
approach. Given a sequence of ball centre coordinates extracted from
|
7 |
+
a single camera (behind the bowler), we fit a polynomial curve to
|
8 |
+
approximate the ball's trajectory in image space. We assume that the
|
9 |
+
ball travels roughly along a parabolic path, so a quadratic fit to
|
10 |
+
``y`` as a function of ``x`` is appropriate for the vertical drop.
|
11 |
+
|
12 |
+
Because we lack explicit knowledge of the camera's field of view, the
|
13 |
+
stumps' location is estimated relative to the range of observed ball
|
14 |
+
positions. If the projected path intersects a fixed region near the
|
15 |
+
bottom middle of the frame, we say that the ball would have hit the
|
16 |
+
stumps.
|
17 |
+
"""
|
18 |
+
|
19 |
+
from __future__ import annotations
|
20 |
+
|
21 |
+
import numpy as np
|
22 |
+
from typing import Callable, Dict, List, Tuple
|
23 |
+
|
24 |
+
|
25 |
+
def estimate_trajectory(centers: List[Tuple[int, int]], timestamps: List[float]) -> Dict[str, object]:
|
26 |
+
"""Fit a polynomial to the ball's path.
|
27 |
+
|
28 |
+
Parameters
|
29 |
+
----------
|
30 |
+
centers: list of tuple(int, int)
|
31 |
+
Detected ball centre positions in pixel coordinates (x, y).
|
32 |
+
timestamps: list of float
|
33 |
+
Timestamps (in seconds) corresponding to each detection. Unused
|
34 |
+
in the current implementation but retained for extensibility.
|
35 |
+
|
36 |
+
Returns
|
37 |
+
-------
|
38 |
+
dict
|
39 |
+
A dictionary with keys ``coeffs`` (the polynomial coefficients
|
40 |
+
[a, b, c] for ``y = a*x^2 + b*x + c``) and ``model`` (a
|
41 |
+
callable that accepts an x coordinate and returns the
|
42 |
+
predicted y coordinate).
|
43 |
+
"""
|
44 |
+
if not centers:
|
45 |
+
# No detections; return a dummy model
|
46 |
+
return {"coeffs": np.array([0.0, 0.0, 0.0]), "model": lambda x: 0 * x}
|
47 |
+
|
48 |
+
xs = np.array([pt[0] for pt in centers], dtype=np.float64)
|
49 |
+
ys = np.array([pt[1] for pt in centers], dtype=np.float64)
|
50 |
+
|
51 |
+
# Require at least 3 points for a quadratic fit; otherwise fall back
|
52 |
+
# to a linear fit
|
53 |
+
if len(xs) >= 3:
|
54 |
+
coeffs = np.polyfit(xs, ys, 2)
|
55 |
+
def model(x: np.ndarray | float) -> np.ndarray | float:
|
56 |
+
return coeffs[0] * (x ** 2) + coeffs[1] * x + coeffs[2]
|
57 |
+
else:
|
58 |
+
coeffs = np.polyfit(xs, ys, 1)
|
59 |
+
def model(x: np.ndarray | float) -> np.ndarray | float:
|
60 |
+
return coeffs[0] * x + coeffs[1]
|
61 |
+
|
62 |
+
return {"coeffs": coeffs, "model": model}
|
63 |
+
|
64 |
+
|
65 |
+
def predict_stumps_intersection(trajectory: Dict[str, object]) -> bool:
|
66 |
+
"""Predict whether the ball's trajectory will hit the stumps.
|
67 |
+
|
68 |
+
The stumps are assumed to lie roughly in the centre of the frame
|
69 |
+
along the horizontal axis and occupy the lower quarter of the
|
70 |
+
vertical axis. This heuristic works reasonably well for videos
|
71 |
+
captured from behind the bowler. In a production system you
|
72 |
+
would calibrate the exact position of the stumps from the pitch
|
73 |
+
geometry.
|
74 |
+
|
75 |
+
Parameters
|
76 |
+
----------
|
77 |
+
trajectory: dict
|
78 |
+
Output of :func:`estimate_trajectory`, containing the
|
79 |
+
polynomial model and the original ``centers`` list if needed.
|
80 |
+
|
81 |
+
Returns
|
82 |
+
-------
|
83 |
+
bool
|
84 |
+
True if the ball is predicted to hit the stumps, False otherwise.
|
85 |
+
"""
|
86 |
+
model: Callable[[float], float] = trajectory["model"]
|
87 |
+
coeffs = trajectory["coeffs"]
|
88 |
+
|
89 |
+
# Recover approximate frame dimensions from the observed centres. We
|
90 |
+
# estimate the width and height as slightly larger than the max
|
91 |
+
# observed coordinates.
|
92 |
+
# Note: trajectory does not contain the centres directly, so we
|
93 |
+
# recompute width and height heuristically based on coefficient
|
94 |
+
# magnitudes. To avoid overcomplication we assign reasonable
|
95 |
+
# defaults if no centres were available.
|
96 |
+
if hasattr(trajectory, "centers"):
|
97 |
+
# never executed; left as placeholder
|
98 |
+
pass
|
99 |
+
|
100 |
+
# Use coefficients to infer approximate domain of x. The roots of
|
101 |
+
# derivative give extremum; but we simply sample across a range
|
102 |
+
# derived from typical video width (e.g. 640px)
|
103 |
+
frame_width = 640
|
104 |
+
frame_height = 360
|
105 |
+
|
106 |
+
# Estimate ball y position at the x coordinate corresponding to the
|
107 |
+
# middle stump: 50% of frame width
|
108 |
+
stumps_x = frame_width * 0.5
|
109 |
+
predicted_y = model(stumps_x)
|
110 |
+
|
111 |
+
# Define the vertical bounds of the wicket region in pixels. The
|
112 |
+
# top of the stumps is roughly three quarters down the frame and
|
113 |
+
# the bottom is at the very bottom. These ratios can be tuned.
|
114 |
+
stumps_y_low = frame_height * 0.65
|
115 |
+
stumps_y_high = frame_height * 0.95
|
116 |
+
|
117 |
+
return stumps_y_low <= predicted_y <= stumps_y_high
|
drs_modules/video_processing.py
ADDED
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Video processing utilities for the DRS application.
|
2 |
+
|
3 |
+
This module provides helper functions to save uploaded videos to the
|
4 |
+
filesystem and to trim the last N seconds from a video. Using
|
5 |
+
OpenCV's ``VideoCapture`` and ``VideoWriter`` avoids external
|
6 |
+
dependencies like ffmpeg or moviepy, which may not be installed in
|
7 |
+
all execution environments.
|
8 |
+
"""
|
9 |
+
|
10 |
+
from __future__ import annotations
|
11 |
+
|
12 |
+
import os
|
13 |
+
import shutil
|
14 |
+
from pathlib import Path
|
15 |
+
from typing import Union
|
16 |
+
|
17 |
+
import cv2
|
18 |
+
|
19 |
+
|
20 |
+
def save_uploaded_video(name: str, file_obj: Union[bytes, str, Path]) -> str:
|
21 |
+
"""Persist an uploaded video to a predictable location on disk.
|
22 |
+
|
23 |
+
When a user records or uploads a video in the Gradio interface, it
|
24 |
+
arrives as a temporary file object. To analyse the video later we
|
25 |
+
copy it into the working directory using its original filename.
|
26 |
+
|
27 |
+
Parameters
|
28 |
+
----------
|
29 |
+
name: str
|
30 |
+
The original filename from the upload widget.
|
31 |
+
file_obj: Union[bytes, str, Path]
|
32 |
+
The file-like object representing the uploaded video. Gradio
|
33 |
+
passes the file as a ``gradio.Files`` object whose `.name`
|
34 |
+
property holds the temporary path. This function accepts
|
35 |
+
either the temporary path or an open file handle.
|
36 |
+
|
37 |
+
Returns
|
38 |
+
-------
|
39 |
+
str
|
40 |
+
The absolute path where the video has been saved.
|
41 |
+
"""
|
42 |
+
# Determine a safe output directory. Use the current working
|
43 |
+
# directory so that Gradio can later access the file by path.
|
44 |
+
output_dir = Path(os.getcwd()) / "user_videos"
|
45 |
+
output_dir.mkdir(exist_ok=True)
|
46 |
+
|
47 |
+
# Compose an output filename; avoid overwriting by prefixing with an
|
48 |
+
# incrementing integer if necessary.
|
49 |
+
base_name = Path(name).stem
|
50 |
+
ext = Path(name).suffix or ".mp4"
|
51 |
+
counter = 0
|
52 |
+
dest = output_dir / f"{base_name}{ext}"
|
53 |
+
while dest.exists():
|
54 |
+
counter += 1
|
55 |
+
dest = output_dir / f"{base_name}_{counter}{ext}"
|
56 |
+
|
57 |
+
# If file_obj is a path, simply copy it; otherwise, read and write
|
58 |
+
if isinstance(file_obj, (str, Path)):
|
59 |
+
shutil.copy(str(file_obj), dest)
|
60 |
+
else:
|
61 |
+
# Gradio passes a file-like object with a `.read()` method
|
62 |
+
with open(dest, "wb") as f_out:
|
63 |
+
f_out.write(file_obj.read())
|
64 |
+
return str(dest)
|
65 |
+
|
66 |
+
|
67 |
+
def trim_last_seconds(input_path: str, output_path: str, seconds: int) -> None:
|
68 |
+
"""Save the last ``seconds`` of a video to ``output_path``.
|
69 |
+
|
70 |
+
This function reads the entire video file, calculates the starting
|
71 |
+
frame corresponding to ``seconds`` before the end, and writes the
|
72 |
+
remaining frames to a new video using OpenCV. If the video is
|
73 |
+
shorter than the requested duration, the whole video is copied.
|
74 |
+
|
75 |
+
Parameters
|
76 |
+
----------
|
77 |
+
input_path: str
|
78 |
+
Path to the source video file.
|
79 |
+
output_path: str
|
80 |
+
Path where the trimmed video will be saved.
|
81 |
+
seconds: int
|
82 |
+
The duration from the end of the video to retain.
|
83 |
+
"""
|
84 |
+
cap = cv2.VideoCapture(input_path)
|
85 |
+
if not cap.isOpened():
|
86 |
+
raise RuntimeError(f"Unable to open video: {input_path}")
|
87 |
+
|
88 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
89 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
90 |
+
if fps <= 0:
|
91 |
+
fps = 30.0 # default fallback
|
92 |
+
frames_to_keep = int(seconds * fps)
|
93 |
+
start_frame = max(total_frames - frames_to_keep, 0)
|
94 |
+
|
95 |
+
# Prepare writer with the same properties as the input
|
96 |
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
97 |
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
98 |
+
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
99 |
+
out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
|
100 |
+
|
101 |
+
# Skip frames until start_frame
|
102 |
+
current = 0
|
103 |
+
while current < start_frame:
|
104 |
+
ret, _ = cap.read()
|
105 |
+
if not ret:
|
106 |
+
break
|
107 |
+
current += 1
|
108 |
+
|
109 |
+
# Write remaining frames
|
110 |
+
while True:
|
111 |
+
ret, frame = cap.read()
|
112 |
+
if not ret:
|
113 |
+
break
|
114 |
+
out.write(frame)
|
115 |
+
|
116 |
+
cap.release()
|
117 |
+
out.release()
|
drs_modules/visualization.py
ADDED
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""Visualisation utilities for the DRS application.
|
2 |
+
|
3 |
+
This module contains functions to generate images and videos that
|
4 |
+
illustrate the ball's flight and the outcome of the LBW decision.
|
5 |
+
Using Matplotlib and OpenCV we create a 3D trajectory plot and an
|
6 |
+
annotated replay video. These assets are returned to the Gradio
|
7 |
+
interface for display to the user.
|
8 |
+
"""
|
9 |
+
|
10 |
+
from __future__ import annotations
|
11 |
+
|
12 |
+
import cv2
|
13 |
+
import numpy as np
|
14 |
+
import matplotlib
|
15 |
+
matplotlib.use("Agg") # Use a non‑interactive backend
|
16 |
+
import matplotlib.pyplot as plt
|
17 |
+
from mpl_toolkits.mplot3d import Axes3D # noqa: F401 # needed for 3D plots
|
18 |
+
from typing import List, Tuple, Callable
|
19 |
+
|
20 |
+
|
21 |
+
def generate_trajectory_plot(
|
22 |
+
centers: List[Tuple[int, int]],
|
23 |
+
trajectory: dict,
|
24 |
+
will_hit_stumps: bool,
|
25 |
+
output_path: str,
|
26 |
+
) -> None:
|
27 |
+
"""Create a 3D plot of the observed and predicted ball trajectory.
|
28 |
+
|
29 |
+
The x axis represents the horizontal pixel coordinate, the y axis
|
30 |
+
represents the vertical coordinate (top at 0), and the z axis
|
31 |
+
corresponds to the frame index (time). The predicted path is drawn
|
32 |
+
on the x–y plane at z=0 for clarity.
|
33 |
+
|
34 |
+
Parameters
|
35 |
+
----------
|
36 |
+
centers: list of tuple(int, int)
|
37 |
+
Detected ball centre positions.
|
38 |
+
trajectory: dict
|
39 |
+
Output of :func:`modules.trajectory.estimate_trajectory`.
|
40 |
+
will_hit_stumps: bool
|
41 |
+
Whether the ball is predicted to hit the stumps; controls the
|
42 |
+
colour of the predicted path.
|
43 |
+
output_path: str
|
44 |
+
Where to save the resulting PNG image.
|
45 |
+
"""
|
46 |
+
if not centers:
|
47 |
+
# If no points, draw an empty figure
|
48 |
+
fig = plt.figure(figsize=(6, 4))
|
49 |
+
ax = fig.add_subplot(111, projection="3d")
|
50 |
+
ax.set_title("No ball detections")
|
51 |
+
ax.set_xlabel("X (pixels)")
|
52 |
+
ax.set_ylabel("Y (pixels)")
|
53 |
+
ax.set_zlabel("Frame index")
|
54 |
+
fig.tight_layout()
|
55 |
+
fig.savefig(output_path)
|
56 |
+
plt.close(fig)
|
57 |
+
return
|
58 |
+
|
59 |
+
xs = np.array([c[0] for c in centers])
|
60 |
+
ys = np.array([c[1] for c in centers])
|
61 |
+
zs = np.arange(len(centers))
|
62 |
+
|
63 |
+
# Compute predicted path along the full x range
|
64 |
+
model: Callable[[float], float] = trajectory["model"]
|
65 |
+
x_range = np.linspace(xs.min(), xs.max(), 100)
|
66 |
+
y_pred = model(x_range)
|
67 |
+
|
68 |
+
fig = plt.figure(figsize=(6, 4))
|
69 |
+
ax = fig.add_subplot(111, projection="3d")
|
70 |
+
|
71 |
+
# Plot observed points
|
72 |
+
ax.plot(xs, ys, zs, 'o-', label="Detected ball path", color="blue")
|
73 |
+
|
74 |
+
# Plot predicted path on z=0 plane
|
75 |
+
colour = "green" if will_hit_stumps else "red"
|
76 |
+
ax.plot(x_range, y_pred, np.zeros_like(x_range), '--', label="Predicted path", color=colour)
|
77 |
+
|
78 |
+
ax.set_xlabel("X (pixels)")
|
79 |
+
ax.set_ylabel("Y (pixels)")
|
80 |
+
ax.set_zlabel("Frame index")
|
81 |
+
ax.set_title("Ball trajectory (observed vs predicted)")
|
82 |
+
ax.legend()
|
83 |
+
ax.invert_yaxis() # Invert y axis to match image coordinates
|
84 |
+
fig.tight_layout()
|
85 |
+
fig.savefig(output_path)
|
86 |
+
plt.close(fig)
|
87 |
+
|
88 |
+
|
89 |
+
def annotate_video_with_tracking(
|
90 |
+
video_path: str,
|
91 |
+
centers: List[Tuple[int, int]],
|
92 |
+
trajectory: dict,
|
93 |
+
will_hit_stumps: bool,
|
94 |
+
impact_frame_idx: int,
|
95 |
+
output_path: str,
|
96 |
+
) -> None:
|
97 |
+
"""Create an annotated replay video highlighting key elements.
|
98 |
+
|
99 |
+
The function reads the trimmed input video and writes out a new
|
100 |
+
video with the following overlays:
|
101 |
+
|
102 |
+
* The detected ball centre (small filled circle).
|
103 |
+
* A polyline showing the path of the ball up to the current
|
104 |
+
frame.
|
105 |
+
* The predicted trajectory across the frame, drawn as a dashed
|
106 |
+
curve.
|
107 |
+
* A rectangle representing the stumps zone at the bottom centre
|
108 |
+
of the frame; coloured green if the ball is predicted to hit
|
109 |
+
and red otherwise.
|
110 |
+
* The text "OUT" or "NOT OUT" displayed after the impact frame.
|
111 |
+
* Auto zoom effect on the impact frame by drawing a thicker
|
112 |
+
circle around the ball.
|
113 |
+
|
114 |
+
Parameters
|
115 |
+
----------
|
116 |
+
video_path: str
|
117 |
+
Path to the trimmed input video.
|
118 |
+
centers: list of tuple(int, int)
|
119 |
+
Detected ball centres for each frame analysed.
|
120 |
+
trajectory: dict
|
121 |
+
Output of :func:`modules.trajectory.estimate_trajectory`.
|
122 |
+
will_hit_stumps: bool
|
123 |
+
Whether the ball is predicted to hit the stumps.
|
124 |
+
impact_frame_idx: int
|
125 |
+
Index of the frame considered as the impact frame.
|
126 |
+
output_path: str
|
127 |
+
Where to save the annotated video.
|
128 |
+
"""
|
129 |
+
cap = cv2.VideoCapture(video_path)
|
130 |
+
if not cap.isOpened():
|
131 |
+
raise RuntimeError(f"Could not open video {video_path}")
|
132 |
+
|
133 |
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
134 |
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
135 |
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
136 |
+
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
137 |
+
writer = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
|
138 |
+
|
139 |
+
model: Callable[[float], float] = trajectory["model"]
|
140 |
+
# Precompute predicted path points for drawing on each frame
|
141 |
+
x_vals = np.linspace(0, width - 1, 50)
|
142 |
+
y_preds = model(x_vals)
|
143 |
+
# Ensure predicted y values stay within frame
|
144 |
+
y_preds_clamped = np.clip(y_preds, 0, height - 1).astype(int)
|
145 |
+
|
146 |
+
# Define stumps zone coordinates
|
147 |
+
stumps_width = int(width * 0.1)
|
148 |
+
stumps_height = int(height * 0.3)
|
149 |
+
stumps_x = int((width - stumps_width) / 2)
|
150 |
+
stumps_y = int(height * 0.65)
|
151 |
+
stumps_color = (0, 255, 0) if will_hit_stumps else (0, 0, 255)
|
152 |
+
|
153 |
+
frame_idx = 0
|
154 |
+
path_points: List[Tuple[int, int]] = []
|
155 |
+
|
156 |
+
while True:
|
157 |
+
ret, frame = cap.read()
|
158 |
+
if not ret:
|
159 |
+
break
|
160 |
+
|
161 |
+
# Draw stumps region on every frame
|
162 |
+
cv2.rectangle(
|
163 |
+
frame,
|
164 |
+
(stumps_x, stumps_y),
|
165 |
+
(stumps_x + stumps_width, stumps_y + stumps_height),
|
166 |
+
stumps_color,
|
167 |
+
2,
|
168 |
+
)
|
169 |
+
|
170 |
+
# Draw predicted trajectory line (dashed effect by skipping points)
|
171 |
+
for i in range(len(x_vals) - 1):
|
172 |
+
if i % 4 != 0:
|
173 |
+
continue
|
174 |
+
pt1 = (int(x_vals[i]), int(y_preds_clamped[i]))
|
175 |
+
pt2 = (int(x_vals[i + 1]), int(y_preds_clamped[i + 1]))
|
176 |
+
cv2.line(frame, pt1, pt2, stumps_color, 1, lineType=cv2.LINE_AA)
|
177 |
+
|
178 |
+
# If we have a centre for this frame, draw it and update the path
|
179 |
+
if frame_idx < len(centers):
|
180 |
+
cx, cy = centers[frame_idx]
|
181 |
+
path_points.append((cx, cy))
|
182 |
+
# Draw past trajectory as a polyline
|
183 |
+
if len(path_points) > 1:
|
184 |
+
cv2.polylines(frame, [np.array(path_points, dtype=np.int32)], False, (255, 0, 0), 2)
|
185 |
+
# Draw the ball centre (bigger on impact frame)
|
186 |
+
radius = 5
|
187 |
+
thickness = -1
|
188 |
+
colour = (255, 255, 255)
|
189 |
+
if frame_idx == impact_frame_idx:
|
190 |
+
# Auto zoom effect: larger circle and thicker outline
|
191 |
+
radius = 10
|
192 |
+
thickness = 2
|
193 |
+
colour = (0, 255, 255)
|
194 |
+
cv2.circle(frame, (cx, cy), radius, colour, thickness)
|
195 |
+
else:
|
196 |
+
# Continue drawing the path beyond detection frames
|
197 |
+
if len(path_points) > 1:
|
198 |
+
cv2.polylines(frame, [np.array(path_points, dtype=np.int32)], False, (255, 0, 0), 2)
|
199 |
+
|
200 |
+
# After the impact frame, display the decision text
|
201 |
+
if frame_idx >= impact_frame_idx and impact_frame_idx >= 0:
|
202 |
+
decision_text = "OUT" if will_hit_stumps else "NOT OUT"
|
203 |
+
font = cv2.FONT_HERSHEY_SIMPLEX
|
204 |
+
cv2.putText(
|
205 |
+
frame,
|
206 |
+
decision_text,
|
207 |
+
(50, 50),
|
208 |
+
font,
|
209 |
+
1.5,
|
210 |
+
(0, 255, 0) if will_hit_stumps else (0, 0, 255),
|
211 |
+
3,
|
212 |
+
lineType=cv2.LINE_AA,
|
213 |
+
)
|
214 |
+
|
215 |
+
writer.write(frame)
|
216 |
+
frame_idx += 1
|
217 |
+
|
218 |
+
cap.release()
|
219 |
+
writer.release()
|