golf_tracking / trajectory_service.py
rehctiw25's picture
Upload folder using huggingface_hub
013216e verified
import numpy as np
from scipy.optimize import curve_fit
from scipy.interpolate import make_interp_spline, BSpline
import cv2
class TrajectoryService:
def __init__(self, fps=30):
self.fps = fps
self.points = [] # List of (frame_idx, x, y) tuples
self.trajectory_points = None
self.ball_metrics = None # Store calculated metrics
def add_point(self, frame_idx, x, y):
"""Add a point to the trajectory"""
self.points.append((frame_idx, x, y))
self.points.sort(key=lambda x: x[0]) # Sort by frame index
self.trajectory_points = None # Reset trajectory so it will be recalculated
self.ball_metrics = None # Reset ball metrics so they will be recalculated
def clear_points(self):
"""Clear all trajectory points"""
self.points = []
self.trajectory_points = None
self.ball_metrics = None
def _physics_model(self, t, v0x, v0y, g):
"""
Physics model for projectile motion - used for metrics calculation
t: time array
v0x: initial velocity x
v0y: initial velocity y
g: gravity (pixels/s^2, will be adjusted based on video scale)
"""
x0, y0 = self.points[0][1], self.points[0][2] # Initial position
x = x0 + v0x * t
y = y0 - (v0y * t - 0.5 * g * t * t) # Negative because y is inverted in images
return np.vstack((x, y)).T
def _calculate_ball_metrics(self):
"""Calculate ball speed and trajectory metrics using physics model"""
if len(self.points) < 2:
return None
try:
# Convert frame indices to time
times = np.array([(p[0] / self.fps) for p in self.points])
positions = np.array([(p[1], p[2]) for p in self.points])
# Initial velocity estimates
dt = times[-1] - times[0]
dx = positions[-1, 0] - positions[0, 0]
dy = positions[-1, 1] - positions[0, 1]
v0x_guess = dx / dt
v0y_guess = dy / dt - 0.5 * 9.81 * dt
# Adjust gravity based on video scale
video_height = max(p[2] for p in self.points) - min(
p[2] for p in self.points
)
g_scale = (
video_height / 10
) # Assume trajectory takes about 1/10th of video height
g = 9.81 * g_scale
# Fit trajectory with physics model
def fit_func(t, v0x, v0y):
return self._physics_model(t, v0x, v0y, g).ravel()
params, _ = curve_fit(
fit_func,
times,
positions.ravel(),
p0=[v0x_guess, v0y_guess],
maxfev=10000,
)
# Calculate metrics
v0x, v0y = params
initial_velocity = np.sqrt(v0x**2 + v0y**2)
launch_angle = np.degrees(
np.arctan2(-v0y, v0x)
) # Negative because y is inverted
max_height = max(positions[:, 1]) - positions[0, 1]
total_distance = abs(positions[-1, 0] - positions[0, 0])
# Store metrics
self.ball_metrics = {
"initial_velocity_px": initial_velocity, # pixels per second
"launch_angle_deg": launch_angle,
"max_height_px": max_height,
"total_distance_px": total_distance,
}
except (RuntimeError, ValueError) as e:
print(f"Could not calculate ball metrics: {e}")
self.ball_metrics = None
def _fit_trajectory(self):
"""Fit trajectory through points using interpolation and physics for the end"""
if len(self.points) < 2:
return None
# Sort points by frame index
self.points.sort(key=lambda x: x[0])
# Separate user's final point
final_point = self.points[-1]
model_points = self.points[:-1] if len(self.points) > 2 else self.points
# Extract times and positions for model points
times = np.array([p[0] for p in model_points])
positions = np.array([(p[1], p[2]) for p in model_points])
if len(model_points) > 2:
# Calculate velocities for last few visible points
last_points = positions[-3:] # Use last 3 points
last_times = times[-3:]
# Calculate average velocity
dt = (last_times[-1] - last_times[0]) / self.fps
dx = last_points[-1, 0] - last_points[0, 0]
dy = last_points[-1, 1] - last_points[0, 1]
vx = dx / dt
vy = dy / dt
# Estimate time to reach final point
dx_to_final = final_point[1] - positions[-1, 0]
time_to_final = dx_to_final / vx if abs(vx) > 1e-6 else 1.0
# Generate frames for the gap to final point
gap_frames = np.linspace(
times[-1], final_point[0], int(abs(time_to_final * self.fps))
)
# Calculate trajectory to final point using projectile motion
t = (gap_frames - times[-1]) / self.fps
x_gap = positions[-1, 0] + vx * t
y_gap = positions[-1, 1] + vy * t + 0.5 * 9.81 * t * t # Add gravity effect
# Adjust y_gap to ensure it reaches the final point
y_gap = np.interp(
x_gap, [x_gap[0], final_point[1]], [y_gap[0], final_point[2]]
)
# Combine model points with gap points
frames_smooth = np.concatenate([times, gap_frames])
x_smooth = np.concatenate([positions[:, 0], x_gap])
y_smooth = np.concatenate([positions[:, 1], y_gap])
else:
# If we only have start and end points, use simple interpolation
frames_smooth = np.linspace(
times[0], final_point[0], int((final_point[0] - times[0]) * 2)
)
x_smooth = np.interp(
frames_smooth,
[times[0], final_point[0]],
[positions[0, 0], final_point[1]],
)
y_smooth = np.interp(
frames_smooth,
[times[0], final_point[0]],
[positions[0, 1], final_point[2]],
)
# Apply smoothing to reduce shakiness
def smooth_array(arr, window=5):
kernel = np.ones(window) / window
return np.convolve(arr, kernel, mode="same")
if len(frames_smooth) > 5:
x_smooth = smooth_array(x_smooth)
y_smooth = smooth_array(y_smooth)
# Store interpolated points
self.trajectory_points = list(
zip(frames_smooth.astype(int), x_smooth, y_smooth)
)
# Calculate ball metrics using physics model
self._calculate_ball_metrics()
return self.trajectory_points
def draw_trajectory(self, frame, frame_idx, initial_thickness=8):
"""Draw the trajectory on a frame with clean ball tracking style"""
if not self.trajectory_points:
if len(self.points) >= 2:
self._fit_trajectory()
else:
return frame
# Get points up to current frame
current_points = [
(int(x), int(y)) for f, x, y in self.trajectory_points if f <= frame_idx
]
if len(current_points) > 1:
# Convert points to numpy array for easier manipulation
points_array = np.array(current_points)
# Calculate distance from start for thickness variation
distances = np.sqrt(np.sum((points_array - points_array[0]) ** 2, axis=1))
max_distance = np.max(distances)
# Draw trajectory segments with varying thickness
for i in range(len(points_array) - 1):
p1 = tuple(points_array[i])
p2 = tuple(points_array[i + 1])
# Calculate thickness based on distance (thinner as it goes further)
progress = distances[i] / max_distance if max_distance > 0 else 0
thickness = max(1, int(initial_thickness * (1 - progress * 0.7)))
# Draw single blue line (no white border)
cv2.line(
frame,
p1,
p2,
(255, 144, 30), # BGR: Light blue
thickness,
cv2.LINE_AA,
)
return frame
def get_trajectory(self):
"""Get the computed trajectory points"""
if not self.trajectory_points and len(self.points) >= 2:
return self._fit_trajectory()
return self.trajectory_points
def get_ball_metrics(self):
"""Get the calculated ball metrics"""
if not self.ball_metrics and len(self.points) >= 2:
self._calculate_ball_metrics()
return self.ball_metrics