Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • lokeshsat01/capstone-project
  • ashutoshpocham/capstone-project
  • ggali14/capstone-project
3 results
Show changes
Commits on Source (2)
Showing
with 1320 additions and 479 deletions
### Back-end
1. Navigate to the `Back-end` directory:
```bash
cd Back-end
```
2. Install dependencies:
```bash
npm install
```
3. Start the server:
```bash
npm start
```
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tennis Players</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header>
<h1>Welcome to Tennis Player Stats</h1>
<nav>
<a href="index.html">Home</a>
<a href="schedule.html">Schedule</a>
<a href="players.html">Players</a>
</nav>
</header>
<main>
<h2>Your go-to platform for Tennis Match Schedules and Player Stats</h2>
</main>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Search Tennis Players</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header>
<h1>Search for Tennis Players</h1>
<nav>
<a href="index.html">Home</a>
<a href="schedule.html">Schedule</a>
<a href="players.html">Players</a>
</nav>
</header>
<main>
<section id="player-search">
<h2>Enter the Player Name</h2>
<input type="text" id="player-name" placeholder="e.g., Roger Federer">
<button id="search-btn">Search</button>
</section>
<section id="players">
<h2>Player Details</h2>
<ul id="player-list"></ul>
</section>
</main>
<script src="players.js"></script>
</body>
</html>
const API_KEY = '3';
const searchBtn = document.getElementById('search-btn');
const playerList = document.getElementById('player-list');
searchBtn.addEventListener('click', () => {
const playerName = document.getElementById('player-name').value;
searchPlayer(playerName);
});
function searchPlayer(playerName) {
if (!playerName) {
alert("Please enter a player's name!");
return;
}
const formattedPlayerName = playerName.replace(/ /g, '_');
fetch(`https://www.thesportsdb.com/api/v1/json/${API_KEY}/searchplayers.php?p=${formattedPlayerName}`)
.then(response => response.json())
.then(data => {
playerList.innerHTML = '';
if (data.player) {
data.player.forEach(player => {
const listItem = document.createElement('li');
listItem.innerHTML = `
<strong>${player.strPlayer}</strong><br>
Nationality: ${player.strNationality}<br>
Birth Date: ${player.dateBorn}<br>
Height: ${player.strHeight}<br>
Weight: ${player.strWeight}<br>
Ranking: ${player.intRank || 'N/A'}<br>
Titles Won: ${player.intWins || 'N/A'}<br>
<br>
<strong>About the Player:</strong><br>
${player.strDescriptionEN || 'No description available.'}
`;
playerList.appendChild(listItem);
});
} else {
playerList.innerHTML = `<li>No player found with the name "${playerName}".</li>`;
}
})
.catch(error => {
console.error('Error fetching player data:', error);
});
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tennis Schedule</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header>
<h1>Upcoming Tennis Matches</h1>
<nav>
<a href="index.html">Home</a>
<a href="schedule.html">Schedule</a>
<a href="players.html">Players</a>
</nav>
</header>
<main>
<section id="matches">
<h2>Next 15 Tennis Matches</h2>
<ul id="upcoming-matches"></ul>
</section>
</main>
<script src="schedule.js"></script>
</body>
</html>
const API_KEY = '3'; // TheSportsDB free API key
const upcomingMatchesList = document.getElementById('upcoming-matches');
// Fetch next tennis events from the Tennis league (ID: 4391)
fetch(`https://www.thesportsdb.com/api/v1/json/${API_KEY}/eventsnextleague.php?id=4391`)
.then(response => response.json())
.then(data => {
// Clear any previous matches listed
upcomingMatchesList.innerHTML = '';
if (data.events) {
// Loop through the events and add them to the list
data.events.forEach(event => {
const listItem = document.createElement('li');
listItem.textContent = `${event.strEvent} on ${event.dateEvent}`;
upcomingMatchesList.appendChild(listItem);
});
} else {
// If no events found
upcomingMatchesList.innerHTML = '<li>No upcoming tennis matches available.</li>';
}
})
.catch(error => {
console.error('Error fetching tennis schedule:', error);
});
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f0f0f0;
}
header {
background-color: #333;
color: white;
padding: 1rem;
text-align: center;
}
nav a {
color: white;
margin: 0 15px;
text-decoration: none;
}
main {
padding: 20px;
}
h1, h2 {
color: #333;
}
ul {
list-style: none;
padding: 0;
}
li {
background-color: white;
margin: 10px 0;
padding: 10px;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
### Front-end
1. Navigate to the `Front-end` directory:
```bash
cd Front-end
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm start
```
from flask import Flask, render_template, request, redirect, url_for
import yt_dlp as youtube_dl
import os
import uuid
app = Flask(__name__)
# Directories to save uploaded videos and YouTube downloads
UPLOAD_FOLDER = "static/uploads"
YOUTUBE_FOLDER = "static/videos"
if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)
if not os.path.exists(YOUTUBE_FOLDER):
os.makedirs(YOUTUBE_FOLDER)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
@app.route('/')
def index():
return render_template('index.html')
@app.route('/upload', methods=['POST'])
def upload_video():
# Check if the form is a YouTube link submission
if 'video_url' in request.form and request.form['video_url']:
url = request.form['video_url']
unique_id = str(uuid.uuid4())
ydl_opts = {
'format': 'bestvideo+bestaudio/best',
'outtmpl': os.path.join(YOUTUBE_FOLDER, f'{unique_id}.%(ext)s'), # Use the correct YOUTUBE_FOLDER here
'merge_output_format': 'mp4',
'ffmpeg_location': r'C:\Program Files\ffmpeg-master-latest-win64-gpl-shared\bin' # Provide the ffmpeg path if necessary
}
try:
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
info_dict = ydl.extract_info(url, download=True)
file_name = f"{unique_id}.mp4"
video_path = os.path.join(YOUTUBE_FOLDER, file_name)
return redirect(url_for('play_video', video_file=file_name))
except Exception as e:
return f"Error: {str(e)}. Please check the YouTube URL or try again later."
# If a file is uploaded
elif 'file' in request.files and request.files['file']:
file = request.files['file']
if file.filename == '':
return 'No selected file'
unique_id = str(uuid.uuid4())
filename = f"{unique_id}.mp4"
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(file_path)
return redirect(url_for('play_video', video_file=filename))
return 'No video uploaded or URL provided.'
@app.route('/play/<video_file>')
def play_video(video_file):
# Determine the folder the video is in (uploads or YouTube downloads)
if os.path.exists(os.path.join(UPLOAD_FOLDER, video_file)):
video_url = url_for('static', filename=f'uploads/{video_file}')
else:
video_url = url_for('static', filename=f'videos/{video_file}')
return render_template('play.html', video_url=video_url)
if __name__ == '__main__':
app.run(debug=True)
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video Uploader</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
}
h1 {
color: #4CAF50;
}
form {
margin-top: 20px;
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
input[type="text"], input[type="file"] {
margin-bottom: 15px;
padding: 10px;
width: 300px;
border: 1px solid #ccc;
border-radius: 4px;
}
input[type="submit"] {
background-color: #4CAF50;
color: white;
border: none;
padding: 10px 20px;
cursor: pointer;
border-radius: 4px;
}
input[type="submit"]:hover {
background-color: #45a049;
}
</style>
</head>
<body>
<h1>Upload or Enter a YouTube Link</h1>
<!-- Form for YouTube link -->
<form action="/upload" method="POST" enctype="multipart/form-data">
<input type="text" name="video_url" placeholder="Enter YouTube Video URL">
<h2>OR</h2>
<!-- Form for file upload -->
<input type="file" name="file" accept="video/*">
<input type="submit" value="Submit">
</form>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Play Video</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
}
video {
width: 80%;
max-width: 800px;
border: 1px solid #ccc;
border-radius: 8px;
margin-top: 20px;
}
</style>
</head>
<body>
<h1>Watch Your Video</h1>
<video controls>
<source src="{{ video_url }}" type="video/mp4">
Your browser does not support the video tag.
</video>
</body>
</html>
### Machine Learning Models
1. Navigate to the `ML` directory:
```bash
cd ML
```
2. Set up a virtual environment and install dependencies:
```bash
pip install -r requirements.txt
```
3. Run model training or inference scripts:
```bash
python train.py
python analyze_video.py
\ No newline at end of file
import pandas as pd
import matplotlib
import matplotlib.pyplot as plt
matplotlib.use('Agg') # Use a non-GUI backend
import seaborn as sns
import os
# Set up data and static paths
DATA_FOLDER = "data/"
STATIC_FOLDER = os.path.join('static', 'graphs')
os.makedirs(STATIC_FOLDER, exist_ok=True)
# Load datasets
try:
matches = pd.read_csv(os.path.join(DATA_FOLDER, 'atp_matches_till_2022.csv'))
players = pd.read_csv(os.path.join(DATA_FOLDER, 'atp_players_till_2022.csv'))
rankings = pd.read_csv(os.path.join(DATA_FOLDER, 'atp_rankings_till_2022.csv'))
except FileNotFoundError as e:
raise FileNotFoundError(f"Error loading data files: {e}")
def save_graph(fig, player1_name, player2_name, graph_name):
"""
Save the graph to the static folder with the correct file name.
"""
sanitized_player1 = player1_name.replace(' ', '_').lower()
sanitized_player2 = player2_name.replace(' ', '_').lower()
graph_dir = os.path.join(STATIC_FOLDER, f"{sanitized_player1}_vs_{sanitized_player2}")
os.makedirs(graph_dir, exist_ok=True)
graph_path = os.path.join(graph_dir, f"{graph_name}.png")
fig.savefig(graph_path)
plt.close(fig) # Close the figure to free memory
return graph_path
# Function to sanitize filenames
def sanitize_filename(name):
return name.replace(" ", "_").replace(".", "").lower()
# Function to get player stats and generate visualizations
def get_player_stats_with_visuals(player1_name, player2_name):
players['full_name'] = players['name_first'] + ' ' + players['name_last']
# Validate if players exist in the dataset
if player1_name not in players['full_name'].values:
raise ValueError(f"Player '{player1_name}' not found in the players dataset.")
if player2_name not in players['full_name'].values:
raise ValueError(f"Player '{player2_name}' not found in the players dataset.")
# Fetch player details
player1_info = players[players['full_name'] == player1_name].iloc[0]
player2_info = players[players['full_name'] == player2_name].iloc[0]
player1_id = player1_info['player_id']
player2_id = player2_info['player_id']
player1_nationality = player1_info['ioc']
player2_nationality = player2_info['ioc']
# Filter matches for players
player1_matches = matches[(matches['winner_id'] == player1_id) | (matches['loser_id'] == player1_id)]
player2_matches = matches[(matches['winner_id'] == player2_id) | (matches['loser_id'] == player2_id)]
# Head-to-head record
head_to_head = matches[((matches['winner_id'] == player1_id) & (matches['loser_id'] == player2_id)) |
((matches['winner_id'] == player2_id) & (matches['loser_id'] == player1_id))]
player1_wins = head_to_head[head_to_head['winner_id'] == player1_id].shape[0]
player2_wins = head_to_head[head_to_head['winner_id'] == player2_id].shape[0]
# Win percentage
player1_total_wins = player1_matches[player1_matches['winner_id'] == player1_id].shape[0]
player1_total_matches = player1_matches.shape[0]
player1_win_percentage = (player1_total_wins / player1_total_matches) * 100 if player1_total_matches > 0 else 0
player2_total_wins = player2_matches[player2_matches['winner_id'] == player2_id].shape[0]
player2_total_matches = player2_matches.shape[0]
player2_win_percentage = (player2_total_wins / player2_total_matches) * 100 if player2_total_matches > 0 else 0
# Rankings and career-high
player1_rank_history = rankings[rankings['player'] == player1_id].copy()
player2_rank_history = rankings[rankings['player'] == player2_id].copy()
player1_rank_history['date'] = pd.to_datetime(player1_rank_history['ranking_date'], format='%Y%m%d')
player2_rank_history['date'] = pd.to_datetime(player2_rank_history['ranking_date'], format='%Y%m%d')
player1_current_rank = player1_rank_history['rank'].iloc[-1] if not player1_rank_history.empty else 'N/A'
player2_current_rank = player2_rank_history['rank'].iloc[-1] if not player2_rank_history.empty else 'N/A'
player1_career_high_rank = player1_rank_history['rank'].min() if not player1_rank_history.empty else 'N/A'
player2_career_high_rank = player2_rank_history['rank'].min() if not player2_rank_history.empty else 'N/A'
# Wins by surface
surfaces = ['Hard', 'Clay', 'Grass']
player1_wins_by_surface = player1_matches[player1_matches['winner_id'] == player1_id]['surface'].value_counts()
player2_wins_by_surface = player2_matches[player2_matches['winner_id'] == player2_id]['surface'].value_counts()
# Unique folder for graphs
folder_name = f"{sanitize_filename(player1_name)}_vs_{sanitize_filename(player2_name)}"
graph_folder = os.path.join(STATIC_FOLDER, folder_name)
os.makedirs(graph_folder, exist_ok=True)
# Graphs
graph_paths = {}
# Head-to-Head Wins
fig = plt.figure(figsize=(6, 4))
sns.barplot(x=[player1_name, player2_name], y=[player1_wins, player2_wins])
plt.title(f"Head-to-Head Wins: {player1_name} vs {player2_name}")
graph_paths['head_to_head'] = save_graph(fig, player1_name, player2_name, "head_to_head")
# Win Percentage
fig = plt.figure(figsize=(6, 4))
sns.barplot(x=[player1_name, player2_name], y=[player1_win_percentage, player2_win_percentage])
plt.title("Win Percentage Comparison")
graph_paths['win_percentage'] = save_graph(fig, player1_name, player2_name, "win_percentage")
# Wins by Surface
win_surface_df = pd.DataFrame({
'Surface': surfaces,
f'{player1_name} Wins': [player1_wins_by_surface.get(surface, 0) for surface in surfaces],
f'{player2_name} Wins': [player2_wins_by_surface.get(surface, 0) for surface in surfaces]
})
# Set Surface as index for better plotting
win_surface_df = win_surface_df.set_index('Surface')
# Create a new figure for the plot
fig, ax = plt.subplots(figsize=(8, 5))
# Plot the bar chart
win_surface_df.plot(kind='bar', ax=ax, stacked=False, colormap='viridis')
# Add labels and title
ax.set_title(f"Wins by Surface: {player1_name} vs {player2_name}")
ax.set_ylabel("Number of Wins")
ax.set_xlabel("Surface")
# Save the graph
graph_paths['wins_by_surface'] = save_graph(fig, player1_name, player2_name, "wins_by_surface")
# Ranking Progression Over Time
fig = plt.figure(figsize=(10, 6))
plt.plot(player1_rank_history['date'], player1_rank_history['rank'], label=player1_name)
plt.plot(player2_rank_history['date'], player2_rank_history['rank'], label=player2_name)
plt.gca().invert_yaxis()
plt.title("Ranking Progression Over Time")
graph_paths['ranking_progression'] = save_graph(fig, player1_name, player2_name, "ranking_progression")
# Compile stats
comparison = {
'Nationality': [player1_nationality, player2_nationality],
'Total Matches Played': [player1_total_matches, player2_total_matches],
'Total Wins': [player1_total_wins, player2_total_wins],
'Win Percentage': [player1_win_percentage, player2_win_percentage],
'Current Rank': [player1_current_rank, player2_current_rank],
'Career-High Rank': [player1_career_high_rank, player2_career_high_rank],
'Head-to-Head Wins': [player1_wins, player2_wins],
}
for key, path in graph_paths.items():
graph_paths[key] = path.replace("\\", "/")
return comparison, graph_paths
# Tennis OpenCV Video Analysis Application
# Capstone Project
## **Overview**
This application leverages OpenCV and deep learning models to analyze tennis match videos. Designed for researchers, analysts, and sports enthusiasts, it offers functionalities such as court detection, player tracking, and gameplay insights. By combining advanced machine learning algorithms and Azure cloud services, the application delivers real-time analytics to enhance understanding of tennis matches.
## Project Overview
This project is designed to provide video analysis for tennis matches using machine learning models. The system allows users to upload match videos, view performance analysis, and compare player statistics.
### **Key Highlights**
- **Objective**: Enhance tennis video analysis using deep learning models for player actions, ball tracking, and court keypoints detection to provide comprehensive match insights.
- **Features**:
- **Ball and Player Tracking**: Successfully integrated tracking mechanisms for both ball movement and player actions.
- **Court Keypoints Detection**: Developed a mini-court overlay for better visualization of gameplay.
- **Player Action Detection**: Completed detection and classification of key player actions (e.g., backhand, forehand, serve).
- **Player Statistics Analysis**: Integrated ATP data for player comparisons and utilized Azure OpenAI Services for advanced analysis.
- **Deployment**: The application is deployed using Azure Virtual Machines and Azure OpenAI services, ensuring scalability and real-time performance.
### Folder Structure
### **Dataset Details**
1. **Primary Dataset**:
- **Size**: 8,841 images (75% training, 25% validation).
- **Resolution**: 1280 × 720.
- **Content**: Videos of matches on hard, clay, and grass courts.
- **Source**: Extracted semi-automatically from YouTube highlights.
- **Link**: [Dataset Link](https://drive.google.com/file/d/1lhAaeQCmk2y440PmagA0KmIVBIysVMwu/view?usp=drive_link)
2. **Tennis Player Actions Dataset**:
- **Size**: 500 images per action category.
- **Categories**: Backhand, forehand, ready position, and serve.
- **Annotations**: COCO-format pose annotations for keypoints.
- **Source**: Mendeley Data.
- **Link**: [Dataset Link](https://data.mendeley.com/datasets/nv3rpsxhhk/1)
### **Tools and Technologies**
- **Frontend**: HTML, CSS, JS.
- **Backend**: Flask, PostgreSQL, Python, Azure OpenAI.
- **Machine Learning Models**:
- YOLOv8 for ball and player detection.
- TrackNet CNN for tennis court detection.
- ResNet-18 for player action detection.
- **Azure Services**:
- Azure Virtual Machines.
- Azure Virtual Network and Resource Groups.
- Azure OpenAI.
- **Visualization Tools**: Matplotlib, Seaborn.
- **Other**: Trello, Jira, Kanban for project management.
---
## **File Structure**
- **analysis/**: Scripts for analyzing tennis match data
- **constants/**: Stores constant values used throughout the app
- **court_line_detector/**: Court line detection algorithms and helpers
- **data/**: Dataset files or external input data
- **mini_court/**: Mini tennis court representation logic
- **models/**: Machine learning and deep learning models
- **models1/**: Pre-trained model files
- **static/**: Static assets like images, videos, and CSS
- **templates/**: HTML templates for the Flask app
- **tracker_stubs/**: Tracking utility stubs
- **trackers/**: Tracking algorithms
- **training/**: Scripts and data for model training
- **utils/**: Utility functions
- **app.py**: Main Flask application file
- **main.py**: Entry script for running the application
- **player_data.json**: Sample JSON data for players
- **PVP.py**: Player vs Player analysis script
- **raw_response.json**: Raw response for debugging API
- **README.md**: Documentation
- **requirements.txt**: Dependencies for the project
- **runbook.txt**: Instructions for deployment and setup
- **yolov8x.pt**: YOLOv8 model weights
---
### **Deployment Details**
The deployed application for the project can be accessed at the following link:
[Deployed Application](http://capstone-project-group8.westus2.cloudapp.azure.com:5000/)
#### **Important Notes:**
- The version deployed at the above link is an **older version** of the application.
- We are currently unable to deploy the updated version of the application due to network issues and resource connectivity problems with the Azure cloud environment.
#### **Challenges with Deployment:**
- The existing Azure resources are not connecting properly, and we are unable to perform incremental updates or fixes.
- Redeploying the updated version would require creating new instances and services from scratch, which is a time-intensive process.
- Given the limited time available before submission, we are focusing on ensuring the updated application runs successfully in the **local environment**.
#### **Current Status:**
- The latest version of the application, with all bug fixes and updates, is fully functional when run locally.
- We aim to demonstrate the updated functionality using the local environment while maintaining the older version for reference in the cloud.
This approach ensures that all project requirements are met within the given constraints.
## **How to Run the Application**
## **Steps to Run the Application**
### 1. **Set Up the Virtual Environment**
Create and activate a virtual environment to manage dependencies.
# Create the virtual environment
```bash
python3 -m venv myenv
```
# Activate the virtual environment (Linux/MacOS)
```bash
source myenv/bin/activate
```
# Activate the virtual environment (Windows)
```bash
myenv\Scripts\activate
```
### 2. **Install Dependencies**
Install the required libraries and dependencies listed in requirements.txt.
```bash
pip install -r requirements.txt
```
### 3. **Set Up the Database**
Initialize and migrate the database for storing user and gameplay data.
Initialize the Database
```bash
flask db init
```
Create the Migration Script
```bash
flask db migrate -m "Initial migration"
```
```bash
flask db revision -m "Add users table"
#copy this code in migrations folder under versions initial_migration.py
op.create_table(
'users',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('email', sa.String(length=150), nullable=False, unique=True),
sa.Column('password_hash', sa.String(length=200)),
sa.Column('created_at', sa.DateTime, nullable=True),
)
```
Apply the Migrations
```bash
flask db upgrade
```
### 4. **Run the Application**
Start the Flask application to serve it locally.
Start the Application
```bash
python app.py
```
/Back-end
/Front-end
/ML
/README.md
Access the Application
Open your browser and navigate to:
```bash
http://127.0.0.1:5000/
```
- **Back-end**: Contains the code for server-side logic and API routes.
- **Front-end**: User interface to upload videos and view analysis.
- **ML**: Machine learning models for video processing and performance analysis.
## Setup Instructions
### Prerequisites:
- Node.js
- Python 3.x
- Required libraries (listed in `requirements.txt`)
### Back-end
1. Navigate to the `Back-end` directory:
```bash
cd Back-end
```
2. Install dependencies:
```bash
npm install
```
3. Start the server:
```bash
npm start
```
### Front-end
1. Navigate to the `Front-end` directory:
```bash
cd Front-end
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm start
```
### Machine Learning Models
1. Navigate to the `ML` directory:
```bash
cd ML
```
2. Set up a virtual environment and install dependencies:
```bash
pip install -r requirements.txt
```
3. Run model training or inference scripts:
```bash
python train.py
python analyze_video.py
```
## Technologies Used:
- Node.js (Back-end)
- React/Vue.js (Front-end)
- Python (Machine Learning)
- TensorFlow/PyTorch (ML Models)
## Contributing
1. Fork the repository.
2. Create a feature branch (`git checkout -b feature-branch`).
3. Commit your changes (`git commit -m 'Add new feature'`).
4. Push to the branch (`git push origin feature-branch`).
5. Open a pull request.
you can create a user if all the db migrations is complete and view the application.
---
## **Brief Product Description**
The Tennis OpenCV Video Analysis Application is a tool designed to analyze tennis matches by detecting courts, tracking players, and extracting gameplay insights. Using advanced deep learning models like YOLOv8 and OpenCV, the app processes video footage to generate real-time or batch analytics for better performance evaluation and gameplay strategy.
---
## **Product Functionalities**
### Features:
- **Court Detection**: Identifies the boundaries and centerlines of the tennis court.
- **Player Tracking**: Tracks the movement of players during the match.
- **Video Analysis**: Extracts key gameplay metrics such as ball speed, player positioning, and shot patterns.
- **Data Visualization**: Presents insights using dynamic visualizations.
### Screenshots
#### 1. **Homepage**
![Homepage](path-to-homepage-image)
*Description*: A user-friendly interface to upload videos or view results.
#### 2. **Court Detection**
![Court Detection](path-to-court-detection-image)
*Description*: The system highlights detected court boundaries in uploaded videos.
---
## **Design**
### **Backend**
- The application is built using **Flask**, a lightweight web framework, which serves as the backbone for routing and API integrations.
- **PostgreSQL** is used as the database to store user information, match statistics, and gameplay data.
- The backend integrates **Azure OpenAI** for generating detailed player analysis and statistics summaries.
### **Computer Vision and Machine Learning**
- **YOLOv8**:
- Used for real-time detection of players and the ball.
- Provides accurate bounding boxes for dynamic tracking during matches.
- **TrackNet CNN**:
- Specialized for detecting and analyzing court keypoints.
- Generates mini-court overlays for enhanced visualization of gameplay.
- **ResNet-18**:
- Adapted for player action recognition, including forehand, backhand, serve, and ready positions.
- Utilizes transfer learning for robust classification on the custom dataset.
### **Frontend**
- Built with **HTML**, **CSS**, and **JavaScript** to ensure an intuitive and responsive user interface.
- Dynamic dashboards for visualizing gameplay data and court keypoints.
---
## **API Endpoints**
### 1. **User Authentication APIs**
#### **`/login`** (GET/POST)
- **Purpose**: Handles user login functionality.
- **Description**: Accepts user email and password via a form, verifies credentials using the database, and logs the user in. Redirects to the home page upon success.
- **Design Details**:
- Uses Flask-Login for session management.
- Hashes passwords for security using `check_password_hash`.
#### **`/register`** (GET/POST)
- **Purpose**: Handles user registration.
- **Description**: Allows new users to register by providing their name, email, and password. Stores hashed passwords in the database.
- **Design Details**:
- Ensures email uniqueness using database checks.
- Validates user inputs and provides feedback for errors.
#### **`/logout`** (GET)
- **Purpose**: Logs out the current user.
- **Description**: Ends the user session and redirects to the login page.
- **Design Details**:
- Uses Flask-Login’s `logout_user` method for session management.
---
### 2. **Tennis-Specific APIs**
#### **`/rankings`** (GET)
- **Purpose**: Displays the ATP player rankings.
- **Description**: Fetches live ATP rankings from the Tennis API and renders them on the **rankings.html** page.
- **Design Details**:
- Integrates the **RAPIDAPI Tennis API** to fetch player ranking data dynamically.
- Displays key player details such as name, ranking, and points.
#### **`/schedule`** (GET)
- **Purpose**: Displays tennis event schedules.
- **Description**: Fetches data about upcoming tennis events and renders them on the **schedule.html** page.
- **Design Details**:
- Uses the RAPIDAPI Tennis API to retrieve event data for a fixed date.
- Displays match information, including player names, event type, and location.
#### **`/player_search`** (GET)
- **Purpose**: Searches for detailed information about a specific tennis player.
- **Description**: Accepts a query parameter (player name) and retrieves player details from the RAPIDAPI Tennis API.
- **Design Details**:
- Returns player bio, statistics, and career highlights.
- Provides error handling for invalid or missing player data.
---
### 3. **Video Processing APIs**
#### **`/upload`** (POST)
- **Purpose**: Handles video uploads for analysis.
- **Description**: Accepts video uploads or YouTube links for analysis. Extracts player data and generates video-based statistics.
- **Design Details**:
- Processes videos using OpenCV and YOLOv8 for player and ball tracking.
- Fetches player data using the GPT API for in-depth comparisons.
- Saves videos and outputs dynamically for further analysis.
#### **`/play/<video_file>`** (GET)
- **Purpose**: Streams uploaded or processed videos.
- **Description**: Locates the video in the upload or YouTube folder and renders it on the **play.html** page.
- **Design Details**:
- Dynamically generates the video path and embeds it in the UI.
- Provides support for both local uploads and YouTube downloads.
---
### 4. **GPT Integration APIs**
#### **`fetch_gpt_player_data`** (Internal Function)
- **Purpose**: Fetches comparative player data from Azure OpenAI.
- **Description**: Generates a detailed JSON report comparing two players based on a predefined structured prompt.
- **Design Details**:
- Uses the GPT API to create sections like Player Overview, Career Highlights, Head-to-Head Records, and Historical Trends.
- Outputs data in strict JSON format, ensuring compatibility with frontend components.
---
### 5. **Static Pages**
#### **`/about`** (GET)
- **Purpose**: Provides information about the project and team.
- **Description**: Displays the **about.html** page with static project information.
- **Design Details**:
- Serves as an informational page for users to understand the project goals and functionalities.
---
## **Unique Features**
1. **Custom Court Line Detection**:
- A unique algorithm leveraging geometric transformations and computer vision techniques for accurate court line detection.
- Ensures compatibility with various court types (e.g., hard, clay, grass).
2. **Player Tracking and Mini-Court Visualization**:
- Tracks players dynamically and projects their positions onto a mini-court overlay for better understanding of movement patterns.
3. **Player Action Classification**:
- Detects key actions (e.g., forehand, backhand, serve) with high accuracy using ResNet-18.
- COCO-format annotations ensure precise keypoint detection for training the model.
4. **Scalable Deployment with Azure**:
- Deployed on **Azure Virtual Machines** and utilizes **Azure OpenAI** for advanced analytics.
- Ensures real-time analysis for live matches while maintaining scalability.
5. **Integration with ATP Data**:
- Combines real-time match analysis with historical ATP data for comprehensive player performance comparison.
6. **Modular and Extendable Design**:
- Built with modularity in mind, allowing easy integration of new models and functionalities without overhauling the core framework.
### **GPT Integration Features**
This application integrates **Azure OpenAI GPT** to generate in-depth comparative reports on tennis players. The following features are powered by GPT:
1. **Detailed Player Reports**:
- Provides comprehensive player comparisons, including personal details, career highlights, performance statistics, and historical trends.
- Information includes:
- Player overviews such as nationality, playing style, and professional start year.
- Career achievements like total titles, Grand Slam wins, and rankings.
2. **Customizable Report Generation**:
- Dynamic prompts are used to fetch customized JSON-based reports based on user inputs (e.g., specific player names).
- GPT is instructed to adhere to strict JSON formatting, ensuring compatibility with the application’s data handling pipelines.
3. **Key Report Sections**:
- **Player Overview**: Personal and professional details like name, nationality, and playing style.
- **Career Highlights**: Summarizes achievements, prize money, rankings, and Grand Slam wins.
- **Head-to-Head Records**: Detailed breakdown of matches, wins, and surface-based statistics.
- **Performance Statistics**: In-depth analysis of service and return stats, win percentages, and tiebreak records.
- **Tournament History**: Historical performances across Grand Slams, ATP Finals, and Masters tournaments.
- **Ranking Progression**: Year-wise timeline of rankings for each player.
- **Historical Trends**: Longest win streaks, performance against top-10 players, and notable match upsets.
4. **Dynamic Query Handling**:
- Automatically adjusts to fetch and format missing data as `null` or empty arrays, ensuring data integrity even when information is incomplete.
5. **JSON Compliance**:
- Ensures all keys are formatted in camelCase, and values conform to strict JSON standards, ready for seamless integration into the application.
By utilizing GPT for this purpose, the application enables highly accurate, detailed, and dynamic analysis, elevating the user experience with instant access to comprehensive tennis player analytics.
### **Why It's Unique**
- Unlike traditional sports analysis tools, this application focuses specifically on tennis with tailored algorithms for court and action detection.
- The use of custom datasets and COCO annotations ensures robustness across various court types and gameplay conditions.
- Combines video analysis with Azure-powered AI to provide enriched insights, setting it apart from standard OpenCV-based tools.
---
## **Retrospection**
### What Went Well:
- Successful integration of YOLOv8 for player detection.
- Real-time analysis capabilities achieved using Flask.
### Challenges:
- Encountered deployment issues on the cloud due to resource constraints.
- Initial difficulties in handling diverse video resolutions and angles.
---
## **Recommendations for Future Improvements**
1. **Enhance Scalability**:
- Optimize models for deployment on cloud platforms with limited resources.
2. **Additional Features**:
- Add support for advanced match analytics, such as shot predictions and heatmaps.
3. **Mobile Compatibility**:
- Create a mobile-friendly interface for broader accessibility.
---
This diff is collapsed.
from flask import Flask, render_template, jsonify, abort, url_for, request, redirect, flash
import yt_dlp as youtube_dl
import os
import uuid
import requests,json
from PVP import get_player_stats_with_visuals
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
from flask_migrate import Migrate
from models import db # Import SQLAlchemy instance
from models.user import User # Import the User model
from werkzeug.security import check_password_hash, generate_password_hash
app = Flask(__name__)
# Flask Configuration
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://postgres:Test1234!@localhost:5434/capstone_project'
app.config['SECRET_KEY'] = 'capstone-project'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
# Initialize Flask-Migrate for database migrations
migrate = Migrate(app, db)
# Initialize Flask-Login
login_manager = LoginManager()
login_manager.login_view = 'login'
login_manager.init_app(app)
with app.app_context():
# Access `current_user` or interact with `login_manager`
db.create_all()
# Define user loader
@login_manager.user_loader
def load_user(user_id):
from models.user import User
return User.query.get(int(user_id))
# Define your Azure OpenAI endpoint and API key
ENDPOINT = "https://capstone-project.cognitiveservices.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-08-01-preview"
API_KEY = "1BMz0wKtTR35QAuocSJmZGR6nSut2OFfq9gOUOPbNtcr3vTmnupWJQQJ99ALAC4f1cMXJ3w3AAAAACOGqweh" # Replace with your actual API key
# Directories to save uploaded videos and YouTube downloads
UPLOAD_FOLDER = "static/uploads"
YOUTUBE_FOLDER = "static/videos"
OUTPUT_FOLDER = "static/output_videos"
if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)
if not os.path.exists(YOUTUBE_FOLDER):
os.makedirs(YOUTUBE_FOLDER)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['OUTPUT_FOLDER'] = OUTPUT_FOLDER
def fetch_gpt_player_data(player1_name, player2_name):
# Headers for the request
headers = {
"Content-Type": "application/json",
"api-key": API_KEY,
}
payload = {
"messages": [
{
"role": "system",
"content": (
f"I am building a tennis analytics website. I need a detailed report comparing two players: {player1_name} and {player2_name}. "
"\n\nPlease provide the following details in **strict JSON format**. Ensure all fields conform to proper JSON standards (e.g., use double quotes for keys and values, avoid comments, and provide `null` for missing data)."
"\n\n### Required Sections"
"\n1. **Player Overview**: Include the following details for each player:"
"\n - Full name"
"\n - Nationality"
"\n - Date of birth (in `YYYY-MM-DD` format)"
"\n - Height (in cm)"
"\n - Weight (in kg)"
"\n - Playing hand (e.g., Right-handed or Left-handed)"
"\n - Playing style (brief description)"
"\n - Turned pro year"
"\n\n2. **Career Highlights**:"
"\n - Total career titles"
"\n - Grand Slam wins"
"\n - Olympic medals (as a nested object with `total` and `details`)"
"\n - Career prize money (in USD)"
"\n - Career-high ranking"
"\n - Current ranking"
"\n - Year-end No. 1 rankings (as an array of years)"
"\n\n3. **Head-to-Head Records**:"
"\n - Total matches played"
"\n - Wins by each player"
"\n - Wins by surface (as a nested object: Hard, Clay, Grass)"
"\n - Historical trends (brief summary)"
"\n\n4. **Performance Statistics**:"
"\n - Overall win percentage"
"\n - Service stats (nested object with `aces`, `doubleFaults`, `firstServePercentage`, `firstServePointsWon`, and `secondServePointsWon`)"
"\n - Return stats (nested object with `firstServeReturnPointsWon` and `secondServeReturnPointsWon`)"
"\n - Tiebreak record"
"\n\n5. **Tournament History**:"
"\n - Grand Slam performances (as a nested object with `AustralianOpen`, `FrenchOpen`, `Wimbledon`, `USOpen`)"
"\n - ATP Finals titles"
"\n - Masters titles"
"\n\n6. **Ranking Progression**:"
"\n - Timeline of ranking changes for each player (as an array of objects with `year` and `ranking`)"
"\n\n7. **Historical Trends**:"
"\n - Match-win streaks (longest streak for each player)"
"\n - Performance against top-10 players (win percentage)"
"\n - Notable upsets (brief description)"
"\n\n### Formatting Requirements"
"\n- Provide the response in valid JSON format."
"\n- If any data is missing or unavailable, use `null` or an empty array as appropriate."
"\n- Use camelCase for all keys (e.g., `fullName`, `totalCareerTitles`)."
)
}
],
"temperature": 0.7,
"top_p": 0.95,
"max_tokens": 2000
}
# Make the POST request to the Azure OpenAI endpoint
try:
response = requests.post(ENDPOINT, headers=headers, json=payload)
response.raise_for_status() # Will raise an HTTPError for bad responses (4xx or 5xx)
# Parse the content field from the response
response_json = response.json()
response_content = response_json['choices'][0]['message']['content']
# print(response_content)
# Clean and parse the JSON content
cleaned_content = response_content.strip("```json\n").strip("```")
# print(cleaned_content)
# Decode bytes to string and write the JSON response to a file
with open("player_data.json", "w") as file:
file.write(cleaned_content)
except requests.RequestException as e:
print(f"Failed to make the request. Error: {e}")
return jsonify(cleaned_content)
# Convert player names to the expected key format (e.g., "Rafael Nadal" -> "rafaelNadal")
def format_player_key(player_name):
return player_name.replace(" ", "")
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
email = request.form['email']
password = request.form['password']
user = User.query.filter_by(email=email).first()
if user and check_password_hash(user.password_hash, password):
login_user(user)
return redirect(url_for('index'))
else:
flash('Invalid email or password', 'error')
return render_template('login.html')
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
name = request.form['name']
email = request.form['email']
password = request.form['password']
if User.query.filter_by(email=email).first():
flash('Email already registered', 'error')
return redirect(url_for('register'))
new_user = User(name=name, email=email)
new_user.set_password(password)
db.session.add(new_user)
db.session.commit()
flash('Registration successful! Please log in.', 'success')
return redirect(url_for('login'))
return render_template('register.html')
@app.route('/logout')
@login_required
def logout():
logout_user()
flash('You have been logged out.', 'success')
return redirect(url_for('login'))
@app.route('/')
@login_required
def index():
try:
# Fetch upcoming matches
response = requests.get(EVENTS_URL, headers=HEADERS)
response.raise_for_status()
events_data = response.json()
# Extract top 10 upcoming matches
upcoming_matches = events_data['events'][:10]
# Fetch ATP rankings
rankings_response = requests.get(RANKINGS_URL, headers=HEADERS)
rankings_response.raise_for_status()
rankings_data = rankings_response.json()
# Extract top 10 players from rankings
top_10_players = rankings_data['rankings'][:10]
# Pass both upcoming matches and rankings to the template
return render_template('index.html', upcoming_matches=upcoming_matches, top_10_players=top_10_players)
except requests.exceptions.RequestException as e:
# Log the error and show an appropriate message
print(f"Error fetching data: {e}")
return render_template('error.html', message="Error fetching upcoming matches or player rankings.")
@app.route('/upload', methods=['POST'])
@login_required
def upload_video():
# Get Player 1 and Player 2 names
player1_name = request.form.get('player1')
player2_name = request.form.get('player2')
def to_camel_case(input_string):
words = input_string.split()
return words[0].lower() + ''.join(word.capitalize() for word in words[1:])
player1_camel_case = to_camel_case(player1_name)
player2_camel_case = to_camel_case(player2_name)
# Check if both players are provided
if not player1_name or not player2_name:
return render_template('error.html', message="Both Player 1 and Player 2 names are required.")
try:
response = fetch_gpt_player_data(player1_name,player2_name)
# Read the JSON data from the file
with open("player_data.json", "r") as file:
player_data = json.load(file) # Load the JSON content into a Python dictionary
players = player_data.get("players", {})
# Check if players is a list or dictionary
if isinstance(players, dict):
# Dictionary-based structure
player1_data = players.get(player1_camel_case) or players.get(player1_name)
player2_data = players.get(player2_camel_case) or players.get(player2_name)
elif isinstance(players, list):
# List-based structure: Find players by matching their names
player1_data = next((p for p in players if p["fullName"] == player1_name), None)
player2_data = next((p for p in players if p["fullName"] == player2_name), None)
else:
raise ValueError("Unknown players structure in JSON.")
if not player1_data or not player2_data:
raise ValueError("Player data not found in the JSON file.")
print("Player 1 Data:", player1_data)
print("Player 2 Data:", player2_data)
except KeyError as e:
print(f"Error fetching GPT data: Missing key {e}")
return render_template('error.html', message="Player data not found in GPT response.")
except json.JSONDecodeError as e:
print(f"Failed to parse JSON. Error: {e}")
return render_template('error.html', message="Failed to parse player stats.")
except Exception as e:
print(f"Error fetching GPT data: {e}")
return render_template('error.html', message="An error occurred while fetching player stats.")
# Generate player stats and graphs
try:
stats, graph_paths = get_player_stats_with_visuals(player1_name, player2_name)
except Exception as e:
print(f"Error generating player stats: {e}")
return render_template('error.html', message="An error occurred while generating player stats.")
# Check if the form is a YouTube link submission
if 'video_url' in request.form and request.form['video_url']:
url = request.form['video_url']
unique_id = str(uuid.uuid4())
ydl_opts = {
'format': 'bestvideo+bestaudio/best',
'outtmpl': os.path.join(YOUTUBE_FOLDER, f'{unique_id}.%(ext)s'), # Use the correct YOUTUBE_FOLDER here
'merge_output_format': 'mp4',
'ffmpeg_location': r'C:\Program Files\ffmpeg-master-latest-win64-gpl-shared\bin' # Provide the ffmpeg path if necessary
}
try:
with youtube_dl.YoutubeDL(ydl_opts) as ydl:
info_dict = ydl.extract_info(url, download=True)
file_name = f"{unique_id}.mp4"
video_path = os.path.join(YOUTUBE_FOLDER, file_name)
# Return the player names and video file
# Return video and player stats to the play.html template
return render_template(
'play.html',
video_url=video_path,
player1=player1_name,
player2=player2_name,
player1_data=player1_data,
player2_data=player2_data,
stats=stats,
graph_paths=graph_paths
)
except Exception as e:
return f"Error: {str(e)}. Please check the YouTube URL or try again later."
# If a file is uploaded
elif 'file' in request.files and request.files['file']:
file = request.files['file']
if file.filename == '':
return 'No selected file'
unique_id = str(uuid.uuid4())
filename = f"{unique_id}.mp4"
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(file_path)
filename1 = f"input_video1.mp4"
output_file_path = os.path.join(app.config['OUTPUT_FOLDER'], filename1)
# Return the player names and uploaded video file
return render_template(
'play.html',
video_url=file_path,
output_video_url=output_file_path,
player1=player1_name,
player2=player2_name,
player1_data=player1_data,
player2_data=player2_data,
stats=stats,
graph_paths=graph_paths
)
return render_template('error.html', message="No video uploaded or URL provided.")
@app.route('/play/<video_file>')
@login_required
def play_video(video_file):
# Determine the folder the video is in (uploads or YouTube downloads)
if os.path.exists(os.path.join(UPLOAD_FOLDER, video_file)):
video_url = url_for('static', filename=f'uploads/{video_file}')
else:
video_url = url_for('static', filename=f'videos/{video_file}')
return render_template('play.html', video_url=video_url)
# API keys and headers for Tennis API
# RAPIDAPI_KEY = 'd53a40c9bdmsh245afa9648bf057p1162f9jsn3339b9778f0a'
RAPIDAPI_KEY = 'a4194f971amsh34f35d4813b3dc0p18c3dfjsn85c4d9ddd8a0'
HEADERS = {
'x-rapidapi-key': RAPIDAPI_KEY,
'x-rapidapi-host': 'tennisapi1.p.rapidapi.com'
}
# Base URLs for the APIs
RANKINGS_URL = "https://tennisapi1.p.rapidapi.com/api/tennis/rankings/atp/live"
PLAYER_URL = "https://tennisapi1.p.rapidapi.com/api/tennis/search/{}" # player_id will be inserted
EVENTS_URL = "https://tennisapi1.p.rapidapi.com/api/tennis/events/19/10/2024" # Static date for now, consider dynamic
# Rankings Page - Rankings API
@app.route('/')
@app.route('/rankings')
@login_required
def rankings():
try:
# Requesting the ATP live rankings
response = requests.get(RANKINGS_URL, headers=HEADERS)
response.raise_for_status() # Raise an exception for bad status codes
rankings_data = response.json()
# Pass the rankings data to the template
return render_template('rankings.html', rankings=rankings_data['rankings'])
except requests.exceptions.RequestException as e:
# Log the error and show an appropriate message
print(f"Error fetching rankings: {e}")
return render_template('error.html', message="Error fetching rankings data.")
# Tennis Events Page - Tennis Events API
@app.route('/schedule')
@login_required
def tennis_events():
try:
# Requesting tennis events for a fixed date (can make this dynamic later)
response = requests.get(EVENTS_URL, headers=HEADERS)
response.raise_for_status() # Raise an exception for bad status codes
events_data = response.json()
# Pass the events data to the template
return render_template('schedule.html', events=events_data['events'])
except requests.exceptions.RequestException as e:
# Log the error and show an appropriate message
print(f"Error fetching events: {e}")
return render_template('error.html', message="Error fetching events data.")
@app.route('/player_search', methods=['GET'])
@login_required
def player_search():
query = request.args.get('query')
if not query:
return render_template('error.html', message="Player query not provided.")
try:
# Perform API request to search for the player
url = PLAYER_URL.format(query)
response = requests.get(url, headers=HEADERS)
response.raise_for_status() # Raise error if the request fails
# Parse response JSON
player_data = response.json()
# Check if results are found
results = player_data.get('results', [])
if not results:
return render_template('error.html', message="No player found for the query.")
# Extract player details from the first result
player_info = results[0]['entity']
# Render the player template with the player data
return render_template('player.html', player=player_info, full_data=player_info)
except requests.exceptions.RequestException as e:
print(f"Error fetching player info: {e}")
return render_template('error.html', message="Error fetching player data.")
except KeyError as e:
print(f"Key error: {e}")
return render_template('error.html', message="Invalid player data format.")
@app.route('/about')
def about():
return render_template('about.html')
if __name__ == '__main__':
app.run(debug=True)
SINGLE_LINE_WIDTH = 8.23
DOUBLE_LINE_WIDTH = 10.97
HALF_COURT_LINE_HEIGHT = 11.88
SERVICE_LINE_WIDTH = 6.4
DOUBLE_ALLY_DIFFERENCE = 1.37
NO_MANS_LAND_HEIGHT = 5.48
PLAYER_1_HEIGHT_METERS = 1.88
PLAYER_2_HEIGHT_METERS = 1.91
DEFAULT_PLAYER_HEIGHT_METERS = 1.88
\ No newline at end of file
from .court_line_detector import CourtLineDetector
\ No newline at end of file