Initial commit

This commit is contained in:
Ruslan Bakiev
2026-03-06 09:43:52 +07:00
commit 549fd1da9d
250 changed files with 9114 additions and 0 deletions

45
.dockerignore Normal file
View File

@@ -0,0 +1,45 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
# Virtual environments
venv/
env/
ENV/
# IDE
.vscode/
.idea/
*.swp
*.swo
# Data (will be mounted as volume)
data/videos/*
data/results/*
data/jobs/*
# Git
.git/
.gitignore
# Docker
.dockerignore
Dockerfile
docker-compose.yml
# OS
.DS_Store
Thumbs.db
# Jupyter
.ipynb_checkpoints/
# Tests
.pytest_cache/
.coverage

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Roboflow Configuration (optional - only if using hosted API)
ROBOFLOW_API_KEY=your_api_key_here
# Model Configuration
MODEL_PATH=models/pickleball-detection
MODEL_VERSION=1
# Server Configuration
API_HOST=0.0.0.0
API_PORT=8000
# Redis Configuration (for Celery)
REDIS_URL=redis://localhost:6379/0

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
# Dagster
data/dagster_home/
dagster_home/
# Jetson flash guide (personal notes)
jetson-orin-nano-flash-guide.md
# Results
data/*.json
data/*.mp4
data/*.png
data/frames/
data/ball_detections/
# Videos
*.mp4
*.avi
*.mov
# Models
models/
*.pt
*.onnx
# IDE
.vscode/
.idea/
*.swp
*.swo
.DS_Store
# Env
.env

39
Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# Use Python 3.11 slim image
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install system dependencies for OpenCV and build tools
RUN apt-get update && apt-get install -y \
libgl1 \
libglib2.0-0 \
libsm6 \
libxext6 \
libxrender1 \
libgomp1 \
ffmpeg \
build-essential \
cmake \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy project files
COPY . .
# Create data directory
RUN mkdir -p data/dagster_home
# Add /app to Python path so 'src' module can be imported
ENV PYTHONPATH=/app:$PYTHONPATH
# Expose ports for API (8000) and Dagster UI (3000)
EXPOSE 8000 3000
# Default command - start Dagster webserver
CMD ["dagster", "dev", "-m", "dagster_project", "--host", "0.0.0.0", "--port", "3000"]

151
README.md Normal file
View File

@@ -0,0 +1,151 @@
# 🎾 Pickle - Pickleball Ball Tracking
Система трекинга пикабольного мяча с автоматической детекцией корта и преобразованием координат в метры.
## Что делает
1. **Детекция корта** - автоматический поиск 4 углов корта (Roboflow модель)
2. **Детекция мяча** - поиск мяча на каждом кадре (YOLO v8)
3. **Трансформация координат** - преобразование пикселей в метры (homography)
4. **Визуализация** - видео с траекторией, графики, тепловая карта
## Быстрый старт
```bash
# Запуск
docker-compose up -d
# Открыть Dagster UI
open http://localhost:3000
# Запустить пайплайн
docker exec pickle-dagster dagster asset materialize --select '*' -m dagster_project
```
## Структура пайплайна
```
1. extract_video_frames → Извлекает 100 кадров (с 10-й секунды)
2. detect_court_keypoints → Находит 4 угла корта
↓ ↓
3. detect_ball_positions ←┘ Детектит мяч на всех кадрах
4. compute_2d_coordinates → Преобразует пиксели в метры
5. visualize_trajectory → Создает визуализации
```
## Результаты
После выполнения пайплайна в `data/`:
- **extract_video_frames.json** - метаданные видео
- **detect_court_keypoints.json** - координаты углов корта
- **detect_ball_positions.json** - позиции мяча в пикселях
- **compute_2d_coordinates.json** - позиции мяча в метрах
- **visualization.mp4** - видео с траекторией, кортом и координатами
- **frames/** - извлеченные кадры
- **ball_detections/** - кадры с найденным мячом
- **court_detection_preview.jpg** - превью с найденными углами корта
## Структура проекта
```
pickle/
├── dagster_project/ # Dagster пайплайн
│ ├── assets/ # 5 asset'ов пайплайна
│ └── io_managers/ # JSON IO manager
├── src/ # Основной код
│ ├── ball_detector.py # YOLO детекция
│ ├── court_calibrator.py # Калибровка корта
│ ├── ball_tracker.py # Трекинг
│ └── video_processor.py # Обработка видео
├── data/ # Результаты выполнения
├── DJI_0017.MP4 # Видео для обработки
├── docker-compose.yml
└── Dockerfile
```
## Конфигурация
Параметры в `dagster_project/assets/`:
- **video_extraction.py** - `start_sec=10`, `num_frames=100`
- **ball_detection.py** - `confidence_threshold=0.3`, slicing 320x320
- **coordinate_transform.py** - корт 13.4м × 6.1м
## Модели
- **Корт**: `ping-pong-paddle-ai-with-images/pickleball-court-p3chl-7tufp` (Roboflow)
- **Мяч**: `pickleball-detection-1oqlw/1` (Roboflow) → fallback на YOLOv8n
## Требования
- Docker & Docker Compose
- 4GB+ RAM
- Видео файл `DJI_0017.MP4` в корне проекта
## Docker команды
```bash
# Билд и запуск
docker-compose up --build -d
# Логи
docker-compose logs -f
# Остановка
docker-compose down
# Выполнить пайплайн
docker exec pickle-dagster dagster asset materialize --select '*' -m dagster_project
# Выполнить один asset
docker exec pickle-dagster dagster asset materialize --select 'detect_ball_positions' -m dagster_project
```
## Dagster UI
http://localhost:3000
Показывает:
- Граф зависимостей между assets
- Логи выполнения
- История запусков
- Метаданные результатов
## Формат данных
**compute_2d_coordinates.json**:
```json
[
{
"frame": 6,
"timestamp": 0.2,
"pixel_x": 1234.5,
"pixel_y": 678.9,
"x_m": 5.67,
"y_m": 2.34,
"confidence": 0.85
}
]
```
## Производительность
- Извлечение кадров: ~1 сек
- Детекция корта: ~1 сек
- Детекция мяча: ~6 сек (100 кадров, ~15 FPS)
- Трансформация координат: <1 сек
- Визуализация: ~1 сек
**Итого**: ~10 секунд на 100 кадров видео
## Стоимость
**$0** - всё работает локально в Docker, без облачных API
## License
MIT

View File

@@ -0,0 +1,32 @@
"""
Dagster project for pickleball ball tracking with 3D coordinates
"""
from dagster import Definitions
from dagster_project.assets import (
extract_video_frames,
detect_court_keypoints,
detect_ball_positions,
visualize_ball_on_court,
detect_net,
calibrate_camera_3d,
compute_ball_3d_coordinates,
create_interactive_viewer
)
from dagster_project.io_managers.json_io_manager import json_io_manager
defs = Definitions(
assets=[
extract_video_frames,
detect_court_keypoints,
detect_ball_positions,
visualize_ball_on_court,
detect_net,
calibrate_camera_3d,
compute_ball_3d_coordinates,
create_interactive_viewer
],
resources={
"json_io_manager": json_io_manager
}
)

View File

@@ -0,0 +1,21 @@
"""Dagster assets for pickleball tracking pipeline"""
from dagster_project.assets.video_extraction import extract_video_frames
from dagster_project.assets.court_detection import detect_court_keypoints
from dagster_project.assets.ball_detection import detect_ball_positions
from dagster_project.assets.visualization import visualize_ball_on_court
from dagster_project.assets.net_detection import detect_net
from dagster_project.assets.camera_calibration import calibrate_camera_3d
from dagster_project.assets.coordinate_transform_3d import compute_ball_3d_coordinates
from dagster_project.assets.interactive_viewer import create_interactive_viewer
__all__ = [
"extract_video_frames",
"detect_court_keypoints",
"detect_ball_positions",
"visualize_ball_on_court",
"detect_net",
"calibrate_camera_3d",
"compute_ball_3d_coordinates",
"create_interactive_viewer"
]

View File

@@ -0,0 +1,181 @@
"""Asset 3: Detect ball positions using YOLO"""
import sys
import cv2
from pathlib import Path
from typing import Dict, List, Optional
from dagster import asset, AssetExecutionContext
from tqdm import tqdm
# Add src to path to import existing ball detector
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
@asset(
io_manager_key="json_io_manager",
compute_kind="yolo",
description="Detect ball positions on all frames using YOLO"
)
def detect_ball_positions(
context: AssetExecutionContext,
extract_video_frames: Dict
) -> List[Dict]:
"""
Detect ball positions on all extracted frames
Inputs:
- extract_video_frames: metadata from frame extraction
- data/frames/*.jpg: all extracted frames
Outputs:
- data/detect_ball_positions.json
Returns:
List of dicts with:
- frame: frame number
- x: pixel x coordinate (or None if not detected)
- y: pixel y coordinate (or None if not detected)
- confidence: detection confidence (0-1)
- diameter_px: estimated ball diameter in pixels
"""
from src.ball_detector import BallDetector
frames_dir = Path(extract_video_frames['frames_dir'])
num_frames = extract_video_frames['num_frames']
context.log.info(f"Initializing YOLO ball detector...")
# Initialize detector
detector = BallDetector(
model_id="pickleball-moving-ball/5",
confidence_threshold=0.3, # Lower threshold to catch more detections
slice_enabled=False # Disable slicing for faster Hosted API inference
)
context.log.info(f"Processing {num_frames} frames for ball detection...")
detections = []
frames_with_ball = 0
for i in tqdm(range(num_frames), desc="Detecting ball"):
frame_path = frames_dir / f"frame_{i:04d}.jpg"
if not frame_path.exists():
context.log.warning(f"Frame {i} not found: {frame_path}")
detections.append({
"frame": i,
"x": None,
"y": None,
"confidence": 0.0,
"diameter_px": None,
"bbox": None
})
continue
# Load frame
frame = cv2.imread(str(frame_path))
# Detect ball
results = detector.detect(frame)
if results and len(results) > 0:
# Take highest confidence detection
ball = results[0]
# Calculate diameter from bbox
bbox = ball.get('bbox')
diameter_px = None
if bbox:
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
diameter_px = (width + height) / 2
detections.append({
"frame": i,
"x": float(ball['center'][0]),
"y": float(ball['center'][1]),
"confidence": float(ball['confidence']),
"diameter_px": float(diameter_px) if diameter_px else None,
"bbox": [float(b) for b in bbox] if bbox else None
})
frames_with_ball += 1
else:
# No detection
detections.append({
"frame": i,
"x": None,
"y": None,
"confidence": 0.0,
"diameter_px": None,
"bbox": None
})
# Log progress every 20 frames
if (i + 1) % 20 == 0:
detection_rate = frames_with_ball / (i + 1) * 100
context.log.info(f"Processed {i + 1}/{num_frames} frames. Detection rate: {detection_rate:.1f}%")
detection_rate = frames_with_ball / num_frames * 100
context.log.info(f"✓ Ball detected in {frames_with_ball}/{num_frames} frames ({detection_rate:.1f}%)")
# Save ALL detection images
_save_detection_preview(context, frames_dir, detections, num_preview=999)
return detections
def _save_detection_preview(
context: AssetExecutionContext,
frames_dir: Path,
detections: List[Dict],
num_preview: int = 5
):
"""Save preview images showing ball detections"""
run_id = context.run_id
preview_dir = Path(f"data/{run_id}/ball_detections")
preview_dir.mkdir(parents=True, exist_ok=True)
# Find first N frames with detections
detected_frames = [d for d in detections if d['x'] is not None][:num_preview]
for detection in detected_frames:
frame_num = detection['frame']
frame_path = frames_dir / f"frame_{frame_num:04d}.jpg"
if not frame_path.exists():
continue
frame = cv2.imread(str(frame_path))
# Draw ball
x, y = int(detection['x']), int(detection['y'])
cv2.circle(frame, (x, y), 8, (0, 0, 255), -1)
# Draw bbox if available
if detection['bbox']:
bbox = detection['bbox']
cv2.rectangle(
frame,
(int(bbox[0]), int(bbox[1])),
(int(bbox[2]), int(bbox[3])),
(0, 255, 0),
2
)
# Draw confidence
cv2.putText(
frame,
f"Conf: {detection['confidence']:.2f}",
(x + 15, y - 10),
cv2.FONT_HERSHEY_SIMPLEX,
0.6,
(255, 255, 255),
2
)
# Save preview
preview_path = preview_dir / f"detection_frame_{frame_num:04d}.jpg"
cv2.imwrite(str(preview_path), frame)
context.log.info(f"Saved {len(detected_frames)} preview images to {preview_dir}")

View File

@@ -0,0 +1,143 @@
"""Asset: Calibrate camera using court corners and net position"""
import cv2
import numpy as np
from typing import Dict
from dagster import asset, AssetExecutionContext
@asset(
io_manager_key="json_io_manager",
compute_kind="opencv",
description="Calibrate camera using cv2.solvePnP with court corners and net"
)
def calibrate_camera_3d(
context: AssetExecutionContext,
detect_court_keypoints: Dict,
detect_net: Dict
) -> Dict:
"""
Calibrate camera to get 3D pose using known 3D↔2D point correspondences
Inputs:
- detect_court_keypoints: 4 court corners in pixels
- detect_net: 4 net corners in pixels
Known 3D points:
- Court: 13.4m × 6.1m rectangle at Z=0
- Net: height 0.914m at middle of court (Y=3.05m)
Outputs:
Camera calibration parameters
Returns:
Dict with:
- camera_matrix: [[fx, 0, cx], [0, fy, cy], [0, 0, 1]]
- rotation_vector: [rx, ry, rz]
- translation_vector: [tx, ty, tz]
- rotation_matrix: 3x3 matrix
- reprojection_error: RMS error in pixels
- calibrated: success flag
"""
# Get 2D points (pixels)
court_corners = np.array(detect_court_keypoints['corners_pixel'], dtype=np.float32)
net_corners = np.array(detect_net['net_corners_pixel'], dtype=np.float32)
# Define 3D points (meters) in world coordinates
# Court corners (Z=0, on ground)
court_3d = np.array([
[0, 0, 0], # TL
[13.4, 0, 0], # TR
[13.4, 6.1, 0], # BR
[0, 6.1, 0] # BL
], dtype=np.float32)
# Net endpoints (2 points)
# Net is at Y=3.05m (middle of 6.1m court width)
# We have 2 endpoints: left and right side at top of net
net_3d = np.array([
[0, 3.05, 0.914], # Left endpoint (top of net)
[13.4, 3.05, 0.914], # Right endpoint (top of net)
], dtype=np.float32)
# Combine all 3D and 2D points
object_points = np.vstack([court_3d, net_3d]) # 6 points total (4 court + 2 net)
image_points = np.vstack([court_corners, net_corners])
context.log.info(f"Calibrating with {len(object_points)} point correspondences")
context.log.info(f"3D points shape: {object_points.shape}")
context.log.info(f"2D points shape: {image_points.shape}")
# Initial camera matrix estimate
# Assume principal point at image center
w = detect_court_keypoints['frame_width']
h = detect_court_keypoints['frame_height']
cx = w / 2
cy = h / 2
# Estimate focal length (typical for drone/action cameras)
focal_length = max(w, h) # Initial guess
camera_matrix = np.array([
[focal_length, 0, cx],
[0, focal_length, cy],
[0, 0, 1]
], dtype=np.float32)
# No lens distortion (assume corrected or minimal)
dist_coeffs = None
# Solve PnP to get camera pose
try:
success, rotation_vec, translation_vec = cv2.solvePnP(
object_points,
image_points,
camera_matrix,
dist_coeffs,
flags=cv2.SOLVEPNP_ITERATIVE
)
if not success:
context.log.error("cv2.solvePnP failed")
return {
"calibrated": False,
"error": "solvePnP failed"
}
# Convert rotation vector to rotation matrix
rotation_matrix, _ = cv2.Rodrigues(rotation_vec)
# Calculate reprojection error
projected_points, _ = cv2.projectPoints(
object_points,
rotation_vec,
translation_vec,
camera_matrix,
dist_coeffs
)
projected_points = projected_points.reshape(-1, 2)
reprojection_error = np.sqrt(np.mean((image_points - projected_points) ** 2))
context.log.info(f"✓ Camera calibration successful")
context.log.info(f" Reprojection error: {reprojection_error:.2f} pixels")
context.log.info(f" Focal length: {focal_length:.1f}")
context.log.info(f" Rotation vector: {rotation_vec.flatten()}")
context.log.info(f" Translation vector: {translation_vec.flatten()}")
return {
"camera_matrix": camera_matrix.tolist(),
"rotation_vector": rotation_vec.flatten().tolist(),
"translation_vector": translation_vec.flatten().tolist(),
"rotation_matrix": rotation_matrix.tolist(),
"reprojection_error": float(reprojection_error),
"focal_length": float(focal_length),
"principal_point": [float(cx), float(cy)],
"image_size": [w, h],
"calibrated": True
}
except Exception as e:
context.log.error(f"Error during camera calibration: {e}")
return {
"calibrated": False,
"error": str(e)
}

View File

@@ -0,0 +1,130 @@
"""Asset 4: Transform pixel coordinates to real-world 2D coordinates"""
import sys
import numpy as np
from pathlib import Path
from typing import Dict, List
from dagster import asset, AssetExecutionContext
# Add src to path to import existing calibrator
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
@asset(
io_manager_key="json_io_manager",
compute_kind="transform",
description="Transform pixel coordinates to real-world 2D court coordinates using homography"
)
def compute_2d_coordinates(
context: AssetExecutionContext,
detect_court_keypoints: Dict,
detect_ball_positions: List[Dict],
extract_video_frames: Dict
) -> List[Dict]:
"""
Transform ball pixel coordinates to real-world court coordinates
Inputs:
- detect_court_keypoints: court corners in pixels
- detect_ball_positions: ball detections in pixels
- extract_video_frames: video metadata (FPS)
Outputs:
- data/compute_2d_coordinates.json
Returns:
List of dicts with:
- frame: frame number
- timestamp: time in seconds
- pixel_x, pixel_y: pixel coordinates
- x_m, y_m: real-world coordinates in meters
- confidence: detection confidence
"""
from src.court_calibrator import CourtCalibrator
context.log.info("Initializing court calibrator...")
calibrator = CourtCalibrator(
court_width_m=detect_court_keypoints['court_width_m'],
court_length_m=detect_court_keypoints['court_length_m']
)
# Calibrate using detected corners
corners = detect_court_keypoints['corners_pixel']
context.log.info(f"Calibrating with corners: {corners}")
success = calibrator.calibrate_manual(corners)
if not success:
raise RuntimeError("Court calibration failed. Check corner points.")
context.log.info("✓ Court calibration successful")
# Transform all ball positions
fps = extract_video_frames['fps']
trajectory = []
detected_count = 0
for detection in detect_ball_positions:
frame_num = detection['frame']
timestamp = frame_num / fps
if detection['x'] is not None and detection['y'] is not None:
# Transform pixel → meters
pixel_coords = [detection['x'], detection['y']]
real_coords = calibrator.pixel_to_real(pixel_coords)
if real_coords is not None:
trajectory.append({
"frame": frame_num,
"timestamp": round(timestamp, 3),
"pixel_x": round(detection['x'], 2),
"pixel_y": round(detection['y'], 2),
"x_m": round(float(real_coords[0]), 3),
"y_m": round(float(real_coords[1]), 3),
"confidence": round(detection['confidence'], 3)
})
detected_count += 1
else:
# Transformation failed (point outside court?)
trajectory.append({
"frame": frame_num,
"timestamp": round(timestamp, 3),
"pixel_x": round(detection['x'], 2),
"pixel_y": round(detection['y'], 2),
"x_m": None,
"y_m": None,
"confidence": round(detection['confidence'], 3)
})
else:
# No detection
trajectory.append({
"frame": frame_num,
"timestamp": round(timestamp, 3),
"pixel_x": None,
"pixel_y": None,
"x_m": None,
"y_m": None,
"confidence": 0.0
})
# Log progress
if (frame_num + 1) % 20 == 0:
context.log.info(f"Transformed {frame_num + 1}/{len(detect_ball_positions)} positions")
transform_rate = detected_count / len(detect_ball_positions) * 100
context.log.info(f"✓ Transformed {detected_count}/{len(detect_ball_positions)} positions ({transform_rate:.1f}%)")
# Calculate statistics
valid_positions = [t for t in trajectory if t['x_m'] is not None]
if valid_positions:
x_coords = [t['x_m'] for t in valid_positions]
y_coords = [t['y_m'] for t in valid_positions]
context.log.info(f"Position statistics:")
context.log.info(f" X range: {min(x_coords):.2f}m to {max(x_coords):.2f}m")
context.log.info(f" Y range: {min(y_coords):.2f}m to {max(y_coords):.2f}m")
context.log.info(f" Court dimensions: {calibrator.court_length}m x {calibrator.court_width}m")
return trajectory

View File

@@ -0,0 +1,190 @@
"""Asset: Transform ball positions to 3D coordinates (X, Y, Z) in meters"""
import cv2
import numpy as np
from typing import Dict, List
from dagster import asset, AssetExecutionContext
@asset(
io_manager_key="json_io_manager",
compute_kind="opencv",
description="Compute 3D ball coordinates (X, Y, Z) using camera calibration"
)
def compute_ball_3d_coordinates(
context: AssetExecutionContext,
detect_court_keypoints: Dict,
detect_ball_positions: List[Dict],
calibrate_camera_3d: Dict
) -> List[Dict]:
"""
Transform ball pixel coordinates to 3D world coordinates (meters)
Strategy:
- If ball on ground: use homography (Z=0)
- If ball in air: use ray casting + bbox size estimation (Z>0)
Inputs:
- detect_court_keypoints: court corners
- detect_ball_positions: ball positions in pixels
- calibrate_camera_3d: camera calibration
Returns:
List of dicts with 3D coordinates for each frame:
[
{
"frame": 0,
"x_m": float or null, # X position on court (0-13.4m)
"y_m": float or null, # Y position on court (0-6.1m)
"z_m": float or null, # Z height above court (meters)
"on_ground": bool, # True if ball touching court
"confidence": float # Detection confidence
},
...
]
"""
if not calibrate_camera_3d.get('calibrated'):
context.log.error("Camera not calibrated, cannot compute 3D coordinates")
return [{"frame": i, "x_m": None, "y_m": None, "z_m": None, "on_ground": False}
for i in range(len(detect_ball_positions))]
# Extract calibration parameters
camera_matrix = np.array(calibrate_camera_3d['camera_matrix'], dtype=np.float32)
rotation_vec = np.array(calibrate_camera_3d['rotation_vector'], dtype=np.float32)
translation_vec = np.array(calibrate_camera_3d['translation_vector'], dtype=np.float32)
rotation_matrix = np.array(calibrate_camera_3d['rotation_matrix'], dtype=np.float32)
fx, fy = camera_matrix[0, 0], camera_matrix[1, 1]
cx, cy = camera_matrix[0, 2], camera_matrix[1, 2]
focal_length = (fx + fy) / 2
# Build homography for ground plane (Z=0)
court_corners_pixel = np.array(detect_court_keypoints['corners_pixel'], dtype=np.float32)
court_corners_meters = np.array([
[0, 0],
[13.4, 0],
[13.4, 6.1],
[0, 6.1]
], dtype=np.float32)
homography_matrix = cv2.getPerspectiveTransform(court_corners_pixel, court_corners_meters)
# Camera position in world coordinates
camera_position = -rotation_matrix.T @ translation_vec.reshape(3, 1)
context.log.info(f"Processing {len(detect_ball_positions)} frames for 3D coordinate transformation")
results = []
for i, ball_det in enumerate(detect_ball_positions):
if ball_det['x'] is None:
# No ball detected
results.append({
"frame": i,
"x_m": None,
"y_m": None,
"z_m": None,
"on_ground": False,
"confidence": 0.0
})
continue
ball_x = ball_det['x']
ball_y = ball_det['y']
ball_diameter = ball_det['diameter_px']
bbox = ball_det['bbox']
confidence = ball_det['confidence']
# Strategy 1: Try ground plane projection (assume Z=0)
ball_point_2d = np.array([[ball_x, ball_y]], dtype=np.float32)
ball_ground = cv2.perspectiveTransform(ball_point_2d.reshape(-1, 1, 2), homography_matrix)
x_ground, y_ground = ball_ground[0][0]
# Check if ball is likely on ground by comparing bbox size
# If ball bbox is large → likely on ground
# Simple heuristic: if diameter > threshold, assume on ground
on_ground_threshold = 30 # pixels - tune this based on typical ball size on ground
if ball_diameter and ball_diameter > on_ground_threshold:
# Ball likely on ground
results.append({
"frame": i,
"x_m": float(x_ground),
"y_m": float(y_ground),
"z_m": 0.0,
"on_ground": True,
"confidence": float(confidence)
})
else:
# Ball likely in air - use ray casting
try:
# Unproject 2D point to 3D ray
point_2d_normalized = np.array([
(ball_x - cx) / fx,
(ball_y - cy) / fy,
1.0
])
# Ray direction in camera coordinates
ray_camera = point_2d_normalized / np.linalg.norm(point_2d_normalized)
# Transform ray to world coordinates
ray_world = rotation_matrix.T @ ray_camera
# Estimate distance using ball size
# Real pickleball diameter: 74mm = 0.074m
ball_diameter_real = 0.074 # meters
ball_diameter_pixels = ball_diameter if ball_diameter else 20 # fallback to 20px
# Distance formula: D = (real_size * focal_length) / pixel_size
distance = (ball_diameter_real * focal_length) / ball_diameter_pixels
# Compute 3D point along ray
ball_3d = camera_position.flatten() + ray_world * distance
x_3d, y_3d, z_3d = ball_3d
# Sanity checks
if z_3d < 0 or z_3d > 10: # Height should be 0-10m
# Invalid, fallback to ground projection
results.append({
"frame": i,
"x_m": float(x_ground),
"y_m": float(y_ground),
"z_m": 0.0,
"on_ground": True,
"confidence": float(confidence)
})
else:
results.append({
"frame": i,
"x_m": float(x_3d),
"y_m": float(y_3d),
"z_m": float(z_3d),
"on_ground": False,
"confidence": float(confidence)
})
except Exception as e:
context.log.warning(f"Frame {i}: Error computing 3D coords, using ground projection: {e}")
results.append({
"frame": i,
"x_m": float(x_ground),
"y_m": float(y_ground),
"z_m": 0.0,
"on_ground": True,
"confidence": float(confidence)
})
if (i + 1) % 20 == 0:
context.log.info(f"Processed {i + 1}/{len(detect_ball_positions)} frames")
# Statistics
detected_count = sum(1 for r in results if r['x_m'] is not None)
on_ground_count = sum(1 for r in results if r.get('on_ground', False))
in_air_count = detected_count - on_ground_count
context.log.info(f"✓ Computed 3D coordinates: {detected_count} detections")
context.log.info(f" On ground: {on_ground_count}, In air: {in_air_count}")
return results

View File

@@ -0,0 +1,277 @@
"""Asset 2: Detect court keypoints using Roboflow Hosted API"""
import os
import cv2
import numpy as np
from pathlib import Path
from typing import Dict, List
from dagster import asset, AssetExecutionContext
from inference_sdk import InferenceHTTPClient
@asset(
io_manager_key="json_io_manager",
compute_kind="roboflow",
description="Detect pickleball court corners using Roboflow keypoint detection model"
)
def detect_court_keypoints(
context: AssetExecutionContext,
extract_video_frames: Dict
) -> Dict:
"""
Detect court keypoints from first frame using Roboflow model
Inputs:
- extract_video_frames: metadata from frame extraction
- data/frames/frame_0000.jpg: first frame
Outputs:
- data/detect_court_keypoints.json
Returns:
Dict with:
- corners_pixel: list of 4 corner coordinates [[x,y], ...]
- court_width_m: court width in meters (6.1)
- court_length_m: court length in meters (13.4)
- keypoints: all detected keypoints
"""
from inference import get_model
frames_dir = Path(extract_video_frames['frames_dir'])
first_frame_path = frames_dir / "frame_0000.jpg"
context.log.info(f"Loading first frame: {first_frame_path}")
if not first_frame_path.exists():
raise FileNotFoundError(f"First frame not found: {first_frame_path}")
# Load frame
frame = cv2.imread(str(first_frame_path))
h, w = frame.shape[:2]
context.log.info(f"Frame dimensions: {w}x{h}")
# Get API key
api_key = os.getenv("ROBOFLOW_API_KEY")
if not api_key:
context.log.warning("ROBOFLOW_API_KEY not set, using estimated corners")
corners = _estimate_court_corners(w, h)
else:
# Try to detect court using Roboflow Hosted API
try:
context.log.info("Detecting court using Roboflow Hosted API...")
client = InferenceHTTPClient(
api_url="https://serverless.roboflow.com",
api_key=api_key
)
result = client.infer(str(first_frame_path), model_id="pickleball-court-cfyv4/1")
# Extract keypoints from result
all_points = []
if result and 'predictions' in result and len(result['predictions']) > 0:
pred = result['predictions'][0]
if 'points' in pred and len(pred['points']) >= 4:
# Модель возвращает много points (линии корта)
all_points = [[p['x'], p['y']] for p in pred['points']]
context.log.info(f"✓ Detected {len(all_points)} keypoints from court lines")
# Находим 4 угла для калибровки (но не для визуализации)
corners = _extract_court_corners_from_points(all_points, w, h)
context.log.info(f"✓ Extracted 4 corners from keypoints")
else:
context.log.warning("No keypoints in prediction, using estimated corners")
corners = _estimate_court_corners(w, h)
else:
context.log.warning("No predictions from model, using estimated corners")
corners = _estimate_court_corners(w, h)
except Exception as e:
context.log.warning(f"Court detection failed: {e}. Using estimated corners.")
corners = _estimate_court_corners(w, h)
context.log.info(f"Court corners: {corners}")
# Save visualization - рисуем ВСЕ точки и линии от модели
vis_frame = frame.copy()
# Рисуем все точки от модели
if len(all_points) > 0:
context.log.info(f"Drawing {len(all_points)} keypoints on visualization")
# Рисуем все точки
for i, point in enumerate(all_points):
x, y = int(point[0]), int(point[1])
cv2.circle(vis_frame, (x, y), 5, (0, 255, 0), -1)
# Подписываем каждую точку номером
cv2.putText(
vis_frame,
str(i),
(x + 8, y),
cv2.FONT_HERSHEY_SIMPLEX,
0.4,
(255, 255, 0),
1
)
# Соединяем все соседние точки линиями
for i in range(len(all_points) - 1):
p1 = tuple(map(int, all_points[i]))
p2 = tuple(map(int, all_points[i + 1]))
cv2.line(vis_frame, p1, p2, (0, 255, 0), 2)
# Save visualization with run_id
run_id = context.run_id
vis_path = Path(f"data/{run_id}/court_detection_preview.jpg")
cv2.imwrite(str(vis_path), vis_frame)
context.log.info(f"Saved court visualization to {vis_path}")
return {
"corners_pixel": corners,
"court_width_m": 6.1,
"court_length_m": 13.4,
"frame_width": w,
"frame_height": h
}
def _estimate_court_corners(width: int, height: int) -> List[List[float]]:
"""
Estimate court corners based on typical DJI camera position
(camera in corner at angle)
Returns corners in order: [TL, TR, BR, BL]
"""
# Assume court takes up ~80% of frame with perspective
margin_x = width * 0.05
margin_y = height * 0.1
# Perspective: far edge narrower than near edge
return [
[margin_x + width * 0.1, margin_y], # Top-left (far)
[width - margin_x - width * 0.1, margin_y], # Top-right (far)
[width - margin_x, height - margin_y], # Bottom-right (near)
[margin_x, height - margin_y] # Bottom-left (near)
]
def _extract_court_corners_from_points(points: List[List[float]], width: int, height: int) -> List[List[float]]:
"""
Extract 4 court corners from many detected points (court lines)
Strategy:
1. Build convex hull from all points
2. Classify hull points into 4 sides (left, right, top, bottom)
3. Fit line for each side using linear regression
4. Find 4 corners as intersections of fitted lines
This works even if one corner is not visible on frame (extrapolation)
"""
if len(points) < 4:
return _estimate_court_corners(width, height)
# Build convex hull from all points
points_array = np.array(points, dtype=np.float32)
hull = cv2.convexHull(points_array)
hull_points = np.array([p[0] for p in hull], dtype=np.float32)
# Classify hull points into 4 sides
# Strategy: sort hull points by angle from centroid, then split into 4 groups
center = hull_points.mean(axis=0)
# Calculate angle for each point relative to center
angles = np.arctan2(hull_points[:, 1] - center[1], hull_points[:, 0] - center[0])
# Sort points by angle
sorted_indices = np.argsort(angles)
sorted_points = hull_points[sorted_indices]
# Split into 4 groups (4 sides)
n = len(sorted_points)
quarter = n // 4
side1 = sorted_points[0:quarter]
side2 = sorted_points[quarter:2*quarter]
side3 = sorted_points[2*quarter:3*quarter]
side4 = sorted_points[3*quarter:]
# Fit lines for each side using cv2.fitLine
def fit_line_coefficients(pts):
if len(pts) < 2:
return None
# cv2.fitLine returns (vx, vy, x0, y0) - direction vector and point on line
line = cv2.fitLine(pts, cv2.DIST_L2, 0, 0.01, 0.01)
vx, vy, x0, y0 = line[0][0], line[1][0], line[2][0], line[3][0]
# Convert to line equation: y = mx + b or vertical line x = c
if abs(vx) < 1e-6: # Vertical line
return ('vertical', x0)
m = vy / vx
b = y0 - m * x0
return ('normal', m, b)
line1 = fit_line_coefficients(side1)
line2 = fit_line_coefficients(side2)
line3 = fit_line_coefficients(side3)
line4 = fit_line_coefficients(side4)
lines = [line1, line2, line3, line4]
# Find intersections between adjacent sides
def line_intersection(line_a, line_b):
if line_a is None or line_b is None:
return None
# Handle vertical lines
if line_a[0] == 'vertical' and line_b[0] == 'vertical':
return None
elif line_a[0] == 'vertical':
x = line_a[1]
m2, b2 = line_b[1], line_b[2]
y = m2 * x + b2
return [float(x), float(y)]
elif line_b[0] == 'vertical':
x = line_b[1]
m1, b1 = line_a[1], line_a[2]
y = m1 * x + b1
return [float(x), float(y)]
else:
m1, b1 = line_a[1], line_a[2]
m2, b2 = line_b[1], line_b[2]
if abs(m1 - m2) < 1e-6: # Parallel lines
return None
x = (b2 - b1) / (m1 - m2)
y = m1 * x + b1
return [float(x), float(y)]
# Find 4 corners as intersections
corners = []
for i in range(4):
next_i = (i + 1) % 4
corner = line_intersection(lines[i], lines[next_i])
if corner:
corners.append(corner)
# If we got 4 corners, return them
if len(corners) == 4:
return corners
# Fallback: use convex hull extreme points
tl = hull_points[np.argmin(hull_points[:, 0] + hull_points[:, 1])].tolist()
tr = hull_points[np.argmax(hull_points[:, 0] - hull_points[:, 1])].tolist()
br = hull_points[np.argmax(hull_points[:, 0] + hull_points[:, 1])].tolist()
bl = hull_points[np.argmin(hull_points[:, 0] - hull_points[:, 1])].tolist()
return [tl, tr, br, bl]
def _extract_court_corners(keypoints: List[Dict], width: int, height: int) -> List[List[float]]:
"""
Extract 4 court corners from detected keypoints (old function for compatibility)
"""
if len(keypoints) < 4:
return _estimate_court_corners(width, height)
points = [[kp['x'], kp['y']] for kp in keypoints]
return _extract_court_corners_from_points(points, width, height)

View File

@@ -0,0 +1,665 @@
"""Asset: Create interactive HTML viewer for frame-by-frame ball tracking"""
from pathlib import Path
from typing import Dict, List
from dagster import asset, AssetExecutionContext
@asset(
io_manager_key="json_io_manager",
compute_kind="html",
description="Create interactive HTML viewer with frame + 3D court visualization"
)
def create_interactive_viewer(
context: AssetExecutionContext,
extract_video_frames: Dict,
compute_ball_3d_coordinates: List[Dict]
) -> Dict:
"""
Create interactive HTML viewer showing:
- Left: Original video frame
- Right: Interactive 3D court (Three.js - rotatable with mouse)
- Controls: Prev/Next buttons + slider
Outputs:
- data/{run_id}/viewer/index.html
Returns:
Dict with viewer_path
"""
run_id = context.run_id
frames_dir = Path(extract_video_frames['frames_dir'])
# Create viewer directory
viewer_dir = Path(f"data/{run_id}/viewer")
viewer_dir.mkdir(parents=True, exist_ok=True)
# Copy frames to viewer directory
import shutil
viewer_frames_dir = viewer_dir / "frames"
viewer_frames_dir.mkdir(exist_ok=True)
# Filter frames with detections
frames_with_ball = [f for f in compute_ball_3d_coordinates if f['x_m'] is not None]
context.log.info(f"Creating viewer for {len(frames_with_ball)} frames with ball detections")
# Copy only frames with detections
for frame_data in frames_with_ball:
frame_num = frame_data['frame']
src = frames_dir / f"frame_{frame_num:04d}.jpg"
dst = viewer_frames_dir / f"frame_{frame_num:04d}.jpg"
if src.exists():
shutil.copy2(src, dst)
context.log.info(f"Copied {len(frames_with_ball)} frames to viewer directory")
# Generate HTML
html_content = _generate_html(frames_with_ball, run_id)
# Save HTML
html_path = viewer_dir / "index.html"
with open(html_path, 'w') as f:
f.write(html_content)
context.log.info(f"✓ Interactive viewer created: {html_path}")
context.log.info(f" Open in browser: file://{html_path.absolute()}")
return {
"viewer_path": str(html_path),
"num_frames": len(frames_with_ball)
}
def _generate_html(frames_data: List[Dict], run_id: str) -> str:
"""Generate HTML with Three.js for real 3D visualization"""
# Convert frames data to JSON
import json
frames_json = json.dumps(frames_data, indent=2)
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ball Tracking 3D Viewer - Run {run_id[:8]}</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a1a;
color: #ffffff;
padding: 20px;
overflow-x: hidden;
}}
.container {{
max-width: 1900px;
margin: 0 auto;
}}
h1 {{
text-align: center;
margin-bottom: 25px;
font-size: 28px;
color: #00ff88;
}}
.viewer {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
height: 600px;
}}
.panel {{
background: #2a2a2a;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
}}
.panel h2 {{
margin-bottom: 15px;
font-size: 18px;
color: #00ff88;
}}
#frameImage {{
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 4px;
}}
#canvas3d {{
width: 100%;
height: 100%;
border-radius: 4px;
display: block;
}}
.controls {{
background: #2a2a2a;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}}
.controls-row {{
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 15px;
}}
button {{
background: #00ff88;
color: #1a1a1a;
border: none;
padding: 10px 24px;
border-radius: 4px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}}
button:hover {{
background: #00cc6a;
transform: translateY(-1px);
}}
button:active {{
transform: translateY(0);
}}
button:disabled {{
background: #444;
color: #888;
cursor: not-allowed;
transform: none;
}}
input[type="range"] {{
flex: 1;
height: 6px;
background: #444;
border-radius: 3px;
outline: none;
-webkit-appearance: none;
}}
input[type="range"]::-webkit-slider-thumb {{
-webkit-appearance: none;
width: 18px;
height: 18px;
background: #00ff88;
border-radius: 50%;
cursor: pointer;
}}
input[type="range"]::-moz-range-thumb {{
width: 18px;
height: 18px;
background: #00ff88;
border-radius: 50%;
cursor: pointer;
border: none;
}}
.info {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}}
.info-item {{
background: #1a1a1a;
padding: 12px;
border-radius: 4px;
}}
.info-label {{
font-size: 12px;
color: #888;
margin-bottom: 4px;
}}
.info-value {{
font-size: 20px;
font-weight: 600;
color: #00ff88;
}}
.kbd {{
display: inline-block;
padding: 3px 8px;
background: #444;
border-radius: 3px;
font-family: monospace;
font-size: 14px;
margin: 0 4px;
}}
.help {{
margin-top: 15px;
padding: 10px;
background: #1a1a1a;
border-radius: 4px;
font-size: 14px;
color: #888;
}}
</style>
</head>
<body>
<div class="container">
<h1>🎾 Pickleball Ball Tracking 3D Viewer</h1>
<div class="viewer">
<div class="panel">
<h2>📹 Video Frame</h2>
<img id="frameImage" alt="Video frame">
</div>
<div class="panel">
<h2>🗺️ Interactive 3D Court (drag to rotate, scroll to zoom)</h2>
<div id="canvas3d"></div>
</div>
</div>
<div class="controls">
<div class="controls-row">
<button id="prevBtn">← Prev</button>
<input type="range" id="frameSlider" min="0" max="0" value="0">
<button id="nextBtn">Next →</button>
<button id="playBtn">▶ Play</button>
</div>
<div class="info">
<div class="info-item">
<div class="info-label">Frame</div>
<div class="info-value" id="frameNum">-</div>
</div>
<div class="info-item">
<div class="info-label">X Position (m)</div>
<div class="info-value" id="posX">-</div>
</div>
<div class="info-item">
<div class="info-label">Y Position (m)</div>
<div class="info-value" id="posY">-</div>
</div>
<div class="info-item">
<div class="info-label">Z Height (m)</div>
<div class="info-value" id="posZ">-</div>
</div>
<div class="info-item">
<div class="info-label">Confidence</div>
<div class="info-value" id="confidence">-</div>
</div>
<div class="info-item">
<div class="info-label">On Ground</div>
<div class="info-value" id="onGround">-</div>
</div>
</div>
<div class="help">
💡 <strong>Controls:</strong>
<span class="kbd">←/→</span> Navigate frames
<span class="kbd">Space</span> Play/Pause
<span class="kbd">Mouse drag</span> Rotate 3D view
<span class="kbd">Mouse wheel</span> Zoom
</div>
</div>
</div>
<!-- Three.js from CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// Frame data from Python
const framesData = {frames_json};
let currentIndex = 0;
let isPlaying = false;
let playInterval = null;
// DOM elements
const frameImage = document.getElementById('frameImage');
const canvas3dContainer = document.getElementById('canvas3d');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const playBtn = document.getElementById('playBtn');
const frameSlider = document.getElementById('frameSlider');
const frameNum = document.getElementById('frameNum');
const posX = document.getElementById('posX');
const posY = document.getElementById('posY');
const posZ = document.getElementById('posZ');
const confidence = document.getElementById('confidence');
const onGround = document.getElementById('onGround');
// Initialize
frameSlider.max = framesData.length - 1;
// Setup Three.js scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
const camera = new THREE.PerspectiveCamera(
60,
canvas3dContainer.clientWidth / canvas3dContainer.clientHeight,
0.1,
1000
);
camera.position.set(15, 12, 15);
camera.lookAt(6.7, 3, 0);
const renderer = new THREE.WebGLRenderer({{ antialias: true }});
renderer.setSize(canvas3dContainer.clientWidth, canvas3dContainer.clientHeight);
canvas3dContainer.appendChild(renderer.domElement);
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(10, 20, 10);
scene.add(directionalLight);
// Grid helper
const gridHelper = new THREE.GridHelper(20, 20, 0x444444, 0x222222);
scene.add(gridHelper);
// Court dimensions
const courtLength = 13.4;
const courtWidth = 6.1;
const netHeight = 0.914;
// Court floor (green)
const courtGeometry = new THREE.PlaneGeometry(courtLength, courtWidth);
const courtMaterial = new THREE.MeshStandardMaterial({{
color: 0x00aa44,
side: THREE.DoubleSide,
roughness: 0.8
}});
const court = new THREE.Mesh(courtGeometry, courtMaterial);
court.rotation.x = -Math.PI / 2;
court.position.set(courtLength / 2, 0, courtWidth / 2);
scene.add(court);
// Court boundaries (white lines)
const boundaryMaterial = new THREE.LineBasicMaterial({{ color: 0xffffff, linewidth: 3 }});
const boundaryPoints = [
new THREE.Vector3(0, 0.01, 0),
new THREE.Vector3(courtLength, 0.01, 0),
new THREE.Vector3(courtLength, 0.01, courtWidth),
new THREE.Vector3(0, 0.01, courtWidth),
new THREE.Vector3(0, 0.01, 0)
];
const boundaryGeometry = new THREE.BufferGeometry().setFromPoints(boundaryPoints);
const boundary = new THREE.Line(boundaryGeometry, boundaryMaterial);
scene.add(boundary);
// Net (mesh) - positioned at middle of court LENGTH (X=6.7m), spanning full WIDTH (Z axis)
const netGeometry = new THREE.BoxGeometry(0.05, netHeight, courtWidth);
const netMaterial = new THREE.MeshStandardMaterial({{
color: 0xcccccc,
transparent: true,
opacity: 0.7,
side: THREE.DoubleSide
}});
const net = new THREE.Mesh(netGeometry, netMaterial);
net.position.set(courtLength / 2, netHeight / 2, courtWidth / 2);
scene.add(net);
// Net poles at both ends of the net (Z=0 and Z=courtWidth)
const poleGeometry = new THREE.CylinderGeometry(0.05, 0.05, netHeight, 8);
const poleMaterial = new THREE.MeshStandardMaterial({{ color: 0x666666 }});
const pole1 = new THREE.Mesh(poleGeometry, poleMaterial);
pole1.position.set(courtLength / 2, netHeight / 2, 0);
scene.add(pole1);
const pole2 = new THREE.Mesh(poleGeometry, poleMaterial);
pole2.position.set(courtLength / 2, netHeight / 2, courtWidth);
scene.add(pole2);
// Ball (sphere)
let ballMesh = null;
let ballShadow = null;
let ballLine = null;
function createBall() {{
// Remove old ball if exists
if (ballMesh) scene.remove(ballMesh);
if (ballShadow) scene.remove(ballShadow);
if (ballLine) scene.remove(ballLine);
// Create ball
const ballGeometry = new THREE.SphereGeometry(0.074 / 2, 32, 32);
const ballMaterial = new THREE.MeshStandardMaterial({{
color: 0xffff00,
emissive: 0xff4444,
emissiveIntensity: 0.3,
metalness: 0.5,
roughness: 0.3
}});
ballMesh = new THREE.Mesh(ballGeometry, ballMaterial);
// Shadow (circle on ground)
const shadowGeometry = new THREE.CircleGeometry(0.1, 32);
const shadowMaterial = new THREE.MeshBasicMaterial({{
color: 0x000000,
transparent: true,
opacity: 0.3,
side: THREE.DoubleSide
}});
ballShadow = new THREE.Mesh(shadowGeometry, shadowMaterial);
ballShadow.rotation.x = -Math.PI / 2;
scene.add(ballMesh);
scene.add(ballShadow);
}}
function updateBall(x, y, z) {{
if (!ballMesh) createBall();
ballMesh.position.set(x, z, y);
ballShadow.position.set(x, 0.01, y);
// Draw line from shadow to ball if in air
if (z > 0.1) {{
if (ballLine) scene.remove(ballLine);
const lineMaterial = new THREE.LineBasicMaterial({{
color: 0xffffff,
transparent: true,
opacity: 0.3
}});
const linePoints = [
new THREE.Vector3(x, z, y),
new THREE.Vector3(x, 0, y)
];
const lineGeometry = new THREE.BufferGeometry().setFromPoints(linePoints);
ballLine = new THREE.Line(lineGeometry, lineMaterial);
scene.add(ballLine);
}} else {{
if (ballLine) {{
scene.remove(ballLine);
ballLine = null;
}}
}}
}}
// Mouse controls for camera
let isDragging = false;
let previousMousePosition = {{ x: 0, y: 0 }};
let cameraAngle = {{ theta: Math.PI / 4, phi: Math.PI / 3 }};
let cameraDistance = 22;
canvas3dContainer.addEventListener('mousedown', (e) => {{
isDragging = true;
previousMousePosition = {{ x: e.clientX, y: e.clientY }};
}});
document.addEventListener('mouseup', () => {{
isDragging = false;
}});
canvas3dContainer.addEventListener('mousemove', (e) => {{
if (!isDragging) return;
const deltaX = e.clientX - previousMousePosition.x;
const deltaY = e.clientY - previousMousePosition.y;
cameraAngle.theta += deltaX * 0.01;
cameraAngle.phi = Math.max(0.1, Math.min(Math.PI / 2 - 0.1, cameraAngle.phi - deltaY * 0.01));
previousMousePosition = {{ x: e.clientX, y: e.clientY }};
updateCameraPosition();
}});
canvas3dContainer.addEventListener('wheel', (e) => {{
e.preventDefault();
cameraDistance += e.deltaY * 0.01;
cameraDistance = Math.max(5, Math.min(50, cameraDistance));
updateCameraPosition();
}});
function updateCameraPosition() {{
const centerX = courtLength / 2;
const centerZ = courtWidth / 2;
camera.position.x = centerX + cameraDistance * Math.sin(cameraAngle.phi) * Math.cos(cameraAngle.theta);
camera.position.y = cameraDistance * Math.cos(cameraAngle.phi);
camera.position.z = centerZ + cameraDistance * Math.sin(cameraAngle.phi) * Math.sin(cameraAngle.theta);
camera.lookAt(centerX, 0, centerZ);
}}
// Handle window resize
window.addEventListener('resize', () => {{
camera.aspect = canvas3dContainer.clientWidth / canvas3dContainer.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(canvas3dContainer.clientWidth, canvas3dContainer.clientHeight);
}});
// Animation loop
function animate() {{
requestAnimationFrame(animate);
renderer.render(scene, camera);
}}
animate();
// Render current frame
function render() {{
if (framesData.length === 0) return;
const frame = framesData[currentIndex];
// Update image - frames are in ./frames/ directory relative to HTML
const framePath = `frames/frame_${{String(frame.frame).padStart(4, '0')}}.jpg`;
frameImage.src = framePath;
// Update info
frameNum.textContent = `${{currentIndex + 1}} / ${{framesData.length}} (Frame #${{frame.frame}})`;
posX.textContent = frame.x_m !== null ? frame.x_m.toFixed(2) : 'N/A';
posY.textContent = frame.y_m !== null ? frame.y_m.toFixed(2) : 'N/A';
posZ.textContent = frame.z_m !== null ? frame.z_m.toFixed(2) : 'N/A';
confidence.textContent = frame.confidence.toFixed(2);
onGround.textContent = frame.on_ground ? '✓ Yes' : '✗ No';
// Update slider
frameSlider.value = currentIndex;
// Update buttons
prevBtn.disabled = currentIndex === 0;
nextBtn.disabled = currentIndex === framesData.length - 1;
// Update ball position in 3D
if (frame.x_m !== null && frame.y_m !== null && frame.z_m !== null) {{
updateBall(frame.x_m, frame.y_m, frame.z_m);
}}
}}
// Navigation functions
function nextFrame() {{
if (currentIndex < framesData.length - 1) {{
currentIndex++;
render();
}}
}}
function prevFrame() {{
if (currentIndex > 0) {{
currentIndex--;
render();
}}
}}
function gotoFrame(index) {{
currentIndex = Math.max(0, Math.min(index, framesData.length - 1));
render();
}}
function togglePlay() {{
if (isPlaying) {{
clearInterval(playInterval);
isPlaying = false;
playBtn.textContent = '▶ Play';
}} else {{
isPlaying = true;
playBtn.textContent = '⏸ Pause';
playInterval = setInterval(() => {{
if (currentIndex < framesData.length - 1) {{
nextFrame();
}} else {{
clearInterval(playInterval);
isPlaying = false;
playBtn.textContent = '▶ Play';
currentIndex = 0;
render();
}}
}}, 100); // 10 fps
}}
}}
// Event listeners
prevBtn.addEventListener('click', prevFrame);
nextBtn.addEventListener('click', nextFrame);
playBtn.addEventListener('click', togglePlay);
frameSlider.addEventListener('input', (e) => gotoFrame(parseInt(e.target.value)));
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {{
switch(e.key) {{
case 'ArrowLeft':
prevFrame();
break;
case 'ArrowRight':
nextFrame();
break;
case ' ':
e.preventDefault();
togglePlay();
break;
}}
}});
// Initial render
render();
</script>
</body>
</html>
"""
return html

View File

@@ -0,0 +1,72 @@
"""Asset: Detect tennis/pickleball net using Roboflow"""
import os
import cv2
import numpy as np
from pathlib import Path
from typing import Dict
from dagster import asset, AssetExecutionContext
from inference_sdk import InferenceHTTPClient
@asset(
io_manager_key="json_io_manager",
compute_kind="roboflow",
description="Detect pickleball/tennis net using Roboflow model"
)
def detect_net(
context: AssetExecutionContext,
extract_video_frames: Dict,
detect_court_keypoints: Dict
) -> Dict:
"""
Detect net on first frame using Roboflow model
NO FALLBACKS - if model doesn't detect net, this will fail
Inputs:
- extract_video_frames: frame metadata
- detect_court_keypoints: court corners (for visualization)
Outputs:
- data/{run_id}/net_detection_preview.jpg: visualization
- JSON with net detection results
Returns:
Dict with net detection data
"""
run_id = context.run_id
frames_dir = Path(extract_video_frames['frames_dir'])
first_frame_path = frames_dir / "frame_0000.jpg"
context.log.info(f"Loading first frame: {first_frame_path}")
# Load frame
frame = cv2.imread(str(first_frame_path))
h, w = frame.shape[:2]
context.log.info(f"Frame dimensions: {w}x{h}")
# Get API key
api_key = os.getenv("ROBOFLOW_API_KEY")
if not api_key:
raise ValueError("ROBOFLOW_API_KEY environment variable is not set")
context.log.info("Detecting net using Roboflow model...")
client = InferenceHTTPClient(
api_url="https://serverless.roboflow.com",
api_key=api_key
)
# Call Roboflow model - MODEL_ID WILL BE PROVIDED BY USER
# Placeholder - user will provide correct model
model_id = "MODEL_ID_PLACEHOLDER"
result = client.infer(str(first_frame_path), model_id=model_id)
context.log.info(f"Roboflow response: {result}")
# TODO: Parse result based on actual model output format
# User will provide correct model and we'll update parsing logic
raise NotImplementedError("Waiting for correct Roboflow model from user")

View File

@@ -0,0 +1,83 @@
"""Asset 1: Extract frames from video"""
import cv2
from pathlib import Path
from typing import Dict
from dagster import asset, AssetExecutionContext
@asset(
io_manager_key="json_io_manager",
compute_kind="opencv",
description="Extract frames from video starting at specified second"
)
def extract_video_frames(context: AssetExecutionContext) -> Dict:
"""
Extract frames from DJI_0017.MP4 video
Inputs:
- DJI_0017.MP4 (video file in root directory)
Outputs:
- data/frames/frame_XXXX.jpg (100 frames)
- data/extract_video_frames.json (metadata)
Returns:
Dict with:
- frames_dir: path to frames directory
- num_frames: number of extracted frames
- fps: video FPS
- start_frame: starting frame number
"""
# Configuration
video_path = "DJI_0017.MP4"
start_sec = 10
num_frames = 100
context.log.info(f"Opening video: {video_path}")
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)
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
start_frame = int(start_sec * fps)
context.log.info(f"Video info: {total_frames} frames, {fps} FPS")
context.log.info(f"Extracting {num_frames} frames starting from frame {start_frame} ({start_sec}s)")
# Create output directory with run_id
run_id = context.run_id
frames_dir = Path(f"data/{run_id}/frames")
frames_dir.mkdir(parents=True, exist_ok=True)
# Set starting position
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
# Extract frames
extracted = 0
for i in range(num_frames):
ret, frame = cap.read()
if not ret:
context.log.warning(f"Could not read frame {i}. Stopping extraction.")
break
frame_path = frames_dir / f"frame_{i:04d}.jpg"
cv2.imwrite(str(frame_path), frame)
extracted += 1
if (i + 1) % 20 == 0:
context.log.info(f"Extracted {i + 1}/{num_frames} frames")
cap.release()
context.log.info(f"✓ Extracted {extracted} frames to {frames_dir}")
return {
"frames_dir": str(frames_dir),
"num_frames": extracted,
"fps": fps,
"start_frame": start_frame,
"start_sec": start_sec
}

View File

@@ -0,0 +1,90 @@
"""Asset: Draw court polygon with 4 corners"""
import cv2
import numpy as np
from pathlib import Path
from typing import Dict
from dagster import asset, AssetExecutionContext
@asset(
io_manager_key="json_io_manager",
compute_kind="opencv",
description="Draw court polygon with 4 corners on first frame"
)
def visualize_ball_on_court(
context: AssetExecutionContext,
extract_video_frames: Dict,
detect_court_keypoints: Dict
) -> Dict:
"""
Draw court polygon (4 corners) on first frame
Inputs:
- extract_video_frames: frame metadata
- detect_court_keypoints: 4 court corners with perspective
Outputs:
- One image: data/{run_id}/court_polygon.jpg
Returns:
Dict with:
- image_path: path to saved image
"""
run_id = context.run_id
frames_dir = Path(extract_video_frames['frames_dir'])
# Load first frame
first_frame_path = frames_dir / "frame_0000.jpg"
context.log.info(f"Loading first frame: {first_frame_path}")
if not first_frame_path.exists():
raise FileNotFoundError(f"First frame not found: {first_frame_path}")
frame = cv2.imread(str(first_frame_path))
if frame is None:
raise RuntimeError(f"Failed to load frame: {first_frame_path}")
# Get court corners (4 points with perspective)
corners = detect_court_keypoints['corners_pixel']
court_polygon = np.array(corners, dtype=np.int32)
context.log.info(f"Drawing court polygon with 4 corners: {corners}")
# Draw court polygon (4 corners with perspective)
cv2.polylines(
frame,
[court_polygon],
isClosed=True,
color=(0, 255, 0), # Green
thickness=3
)
# Draw court corners as circles
for i, corner in enumerate(corners):
cv2.circle(
frame,
(int(corner[0]), int(corner[1])),
8,
(0, 255, 255), # Yellow
-1
)
# Label corners
cv2.putText(
frame,
str(i),
(int(corner[0]) + 12, int(corner[1])),
cv2.FONT_HERSHEY_SIMPLEX,
0.6,
(0, 255, 255),
2
)
# Save image
output_path = Path(f"data/{run_id}/court_polygon.jpg")
cv2.imwrite(str(output_path), frame)
context.log.info(f"✓ Saved court polygon visualization to {output_path}")
return {
"image_path": str(output_path)
}

View File

@@ -0,0 +1 @@
"""IO Managers for Dagster assets"""

View File

@@ -0,0 +1,70 @@
"""JSON IO Manager for storing asset outputs as JSON files"""
import json
from pathlib import Path
from typing import Any
from dagster import IOManager, io_manager, OutputContext, InputContext
class JSONIOManager(IOManager):
"""IO Manager that stores outputs as JSON files in data/{run_id}/ directory"""
def __init__(self, base_path: str = "data"):
self.base_path = Path(base_path)
self.base_path.mkdir(parents=True, exist_ok=True)
def _get_path(self, context) -> Path:
"""Get file path for asset with run_id subdirectory"""
asset_name = context.asset_key.path[-1]
# For InputContext, try upstream run_id first, fallback to finding latest
if isinstance(context, InputContext):
try:
run_id = context.upstream_output.run_id
except:
# If upstream run_id not available, find the most recent run directory
# that contains this asset (for partial re-runs)
run_dirs = sorted([d for d in self.base_path.iterdir() if d.is_dir()],
key=lambda d: d.stat().st_mtime, reverse=True)
for run_dir in run_dirs:
potential_path = run_dir / f"{asset_name}.json"
if potential_path.exists():
return potential_path
# If not found, use the latest run_dir
run_id = run_dirs[0].name if run_dirs else "unknown"
else:
run_id = context.run_id
# Create run-specific directory
run_dir = self.base_path / run_id
run_dir.mkdir(parents=True, exist_ok=True)
return run_dir / f"{asset_name}.json"
def handle_output(self, context: OutputContext, obj: Any):
"""Save asset output to JSON file"""
file_path = self._get_path(context)
with open(file_path, 'w') as f:
json.dump(obj, f, indent=2)
context.log.info(f"Saved {context.asset_key.path[-1]} to {file_path}")
def load_input(self, context: InputContext) -> Any:
"""Load asset input from JSON file"""
file_path = self._get_path(context)
if not file_path.exists():
raise FileNotFoundError(f"Asset output not found: {file_path}")
with open(file_path, 'r') as f:
obj = json.load(f)
context.log.info(f"Loaded {context.asset_key.path[-1]} from {file_path}")
return obj
@io_manager
def json_io_manager():
"""Factory for JSON IO Manager"""
return JSONIOManager(base_path="data")

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -0,0 +1,57 @@
{
"camera_matrix": [
[
1920.0,
0.0,
960.0
],
[
0.0,
1920.0,
540.0
],
[
0.0,
0.0,
1.0
]
],
"rotation_vector": [
0.9480162063192903,
-1.0991197215888209,
0.8109145675124131
],
"translation_vector": [
2.0009117491752173,
-1.3630239735893306,
9.994446229895267
],
"rotation_matrix": [
[
0.26321344952133247,
-0.8971735762272555,
-0.35468049581373706
],
[
0.07416745228464969,
0.3853747246273644,
-0.9197747064580476
],
[
0.9618824611212565,
0.21579132451973237,
0.16797688903338565
]
],
"reprojection_error": 200.16749572753906,
"focal_length": 1920.0,
"principal_point": [
960.0,
540.0
],
"image_size": [
1920,
1080
],
"calibrated": true
}

View File

@@ -0,0 +1,802 @@
[
{
"frame": 0,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 1,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 2,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 3,
"x_m": 10.044878959655762,
"y_m": 3.3315815925598145,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.4595355987548828
},
{
"frame": 4,
"x_m": 9.925751686096191,
"y_m": 3.282414674758911,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.7499630451202393
},
{
"frame": 5,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 6,
"x_m": 9.522378921508789,
"y_m": 3.081491708755493,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.4438209533691406
},
{
"frame": 7,
"x_m": 9.406031608581543,
"y_m": 3.0407073497772217,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.8662319779396057
},
{
"frame": 8,
"x_m": 9.371339797973633,
"y_m": 3.0464587211608887,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.9164504408836365
},
{
"frame": 9,
"x_m": 9.37229061126709,
"y_m": 3.072193145751953,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.9407913088798523
},
{
"frame": 10,
"x_m": 9.378125190734863,
"y_m": 3.1054039001464844,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.9483180642127991
},
{
"frame": 11,
"x_m": 9.478368759155273,
"y_m": 3.180798053741455,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.9082649350166321
},
{
"frame": 12,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 13,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 14,
"x_m": 9.910148620605469,
"y_m": 3.5509445667266846,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.667772114276886
},
{
"frame": 15,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 16,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 17,
"x_m": 8.93185043334961,
"y_m": 2.7611701488494873,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.5858595967292786
},
{
"frame": 18,
"x_m": 8.352518081665039,
"y_m": 2.2731122970581055,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.8277773857116699
},
{
"frame": 19,
"x_m": 7.649472713470459,
"y_m": 1.729779601097107,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.6525294780731201
},
{
"frame": 20,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 21,
"x_m": 6.449870586395264,
"y_m": 0.7403887510299683,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.9178251624107361
},
{
"frame": 22,
"x_m": 5.954407215118408,
"y_m": 0.2702276408672333,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.6851440668106079
},
{
"frame": 23,
"x_m": 5.351879596710205,
"y_m": -0.2799437344074249,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.8174329400062561
},
{
"frame": 24,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 25,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 26,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 27,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 28,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 29,
"x_m": 1.9875693942279895,
"y_m": -0.4494613476800051,
"z_m": 0.538501512841481,
"on_ground": false,
"confidence": 0.7200624942779541
},
{
"frame": 30,
"x_m": 3.134514534829931,
"y_m": -0.5216126547913007,
"z_m": 0.7916199045450409,
"on_ground": false,
"confidence": 0.4647325277328491
},
{
"frame": 31,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 32,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 33,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 34,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 35,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 36,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 37,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 38,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 39,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 40,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 41,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 42,
"x_m": 3.9051076612543536,
"y_m": -0.5035200640235366,
"z_m": 0.4195843244793487,
"on_ground": false,
"confidence": 0.4238705635070801
},
{
"frame": 43,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 44,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 45,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 46,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 47,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 48,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 49,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 50,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 51,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 52,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 53,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 54,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 55,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 56,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 57,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 58,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 59,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 60,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 61,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 62,
"x_m": 1.9072337782891502,
"y_m": -0.2804220005117505,
"z_m": 0.8963949565054601,
"on_ground": false,
"confidence": 0.46716606616973877
},
{
"frame": 63,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 64,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 65,
"x_m": 3.017917751921617,
"y_m": -0.062196873493954974,
"z_m": 1.3217371998457894,
"on_ground": false,
"confidence": 0.7788172364234924
},
{
"frame": 66,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 67,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 68,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 69,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 70,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 71,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 72,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 73,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 74,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 75,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 76,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 77,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 78,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 79,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 80,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 81,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 82,
"x_m": 5.462012767791748,
"y_m": 4.150640964508057,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.7371496558189392
},
{
"frame": 83,
"x_m": 6.035140037536621,
"y_m": 4.453850746154785,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.47047895193099976
},
{
"frame": 84,
"x_m": 6.361359596252441,
"y_m": 4.682921409606934,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.7571220993995667
},
{
"frame": 85,
"x_m": 5.944571495056152,
"y_m": 4.653173923492432,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.6384866237640381
},
{
"frame": 86,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 87,
"x_m": 5.069350242614746,
"y_m": 4.607361316680908,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.924823522567749
},
{
"frame": 88,
"x_m": 4.626520156860352,
"y_m": 4.583075046539307,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.6589019298553467
},
{
"frame": 89,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 90,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 91,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 92,
"x_m": 3.593766450881958,
"y_m": 4.720729351043701,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.616001307964325
},
{
"frame": 93,
"x_m": 3.4283807277679443,
"y_m": 4.76817512512207,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.7801673412322998
},
{
"frame": 94,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 95,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
},
{
"frame": 96,
"x_m": 3.5402987003326416,
"y_m": 5.051088809967041,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.9100144505500793
},
{
"frame": 97,
"x_m": 3.6705820560455322,
"y_m": 5.15645694732666,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.9327566623687744
},
{
"frame": 98,
"x_m": 3.850410223007202,
"y_m": 5.273887634277344,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.7828439474105835
},
{
"frame": 99,
"x_m": null,
"y_m": null,
"z_m": null,
"on_ground": false,
"confidence": 0.0
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

View File

@@ -0,0 +1,4 @@
{
"viewer_path": "data/20602718-5870-4419-9fa3-3a067ff0ad00/viewer/index.html",
"num_frames": 31
}

View File

@@ -0,0 +1,957 @@
[
{
"frame": 0,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 1,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 2,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 3,
"x": 1321.0,
"y": 425.0,
"confidence": 0.4595355987548828,
"diameter_px": 14.0,
"bbox": [
1315.0,
417.0,
1327.0,
433.0
]
},
{
"frame": 4,
"x": 1319.0,
"y": 420.5,
"confidence": 0.7499630451202393,
"diameter_px": 14.5,
"bbox": [
1313.0,
412.0,
1325.0,
429.0
]
},
{
"frame": 5,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 6,
"x": 1316.0,
"y": 404.0,
"confidence": 0.4438209533691406,
"diameter_px": 20.0,
"bbox": [
1307.0,
393.0,
1325.0,
415.0
]
},
{
"frame": 7,
"x": 1313.5,
"y": 400.5,
"confidence": 0.8662319779396057,
"diameter_px": 19.0,
"bbox": [
1305.0,
390.0,
1322.0,
411.0
]
},
{
"frame": 8,
"x": 1311.0,
"y": 400.5,
"confidence": 0.9164504408836365,
"diameter_px": 17.5,
"bbox": [
1303.0,
391.0,
1319.0,
410.0
]
},
{
"frame": 9,
"x": 1308.5,
"y": 402.0,
"confidence": 0.9407913088798523,
"diameter_px": 17.5,
"bbox": [
1300.0,
393.0,
1317.0,
411.0
]
},
{
"frame": 10,
"x": 1305.5,
"y": 404.0,
"confidence": 0.9483180642127991,
"diameter_px": 18.5,
"bbox": [
1297.0,
394.0,
1314.0,
414.0
]
},
{
"frame": 11,
"x": 1303.5,
"y": 409.5,
"confidence": 0.9082649350166321,
"diameter_px": 16.0,
"bbox": [
1296.0,
401.0,
1311.0,
418.0
]
},
{
"frame": 12,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 13,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 14,
"x": 1289.0,
"y": 438.5,
"confidence": 0.667772114276886,
"diameter_px": 17.5,
"bbox": [
1282.0,
428.0,
1296.0,
449.0
]
},
{
"frame": 15,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 16,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 17,
"x": 1314.5,
"y": 381.0,
"confidence": 0.5858595967292786,
"diameter_px": 18.5,
"bbox": [
1306.0,
371.0,
1323.0,
391.0
]
},
{
"frame": 18,
"x": 1328.0,
"y": 353.5,
"confidence": 0.8277773857116699,
"diameter_px": 21.5,
"bbox": [
1318.0,
342.0,
1338.0,
365.0
]
},
{
"frame": 19,
"x": 1338.0,
"y": 327.5,
"confidence": 0.6525294780731201,
"diameter_px": 19.5,
"bbox": [
1329.0,
317.0,
1347.0,
338.0
]
},
{
"frame": 20,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 21,
"x": 1355.5,
"y": 290.5,
"confidence": 0.9178251624107361,
"diameter_px": 16.0,
"bbox": [
1348.0,
282.0,
1363.0,
299.0
]
},
{
"frame": 22,
"x": 1365.0,
"y": 276.5,
"confidence": 0.6851440668106079,
"diameter_px": 14.5,
"bbox": [
1359.0,
268.0,
1371.0,
285.0
]
},
{
"frame": 23,
"x": 1374.0,
"y": 262.0,
"confidence": 0.8174329400062561,
"diameter_px": 19.0,
"bbox": [
1365.0,
252.0,
1383.0,
272.0
]
},
{
"frame": 24,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 25,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 26,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 27,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 28,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 29,
"x": 1401.5,
"y": 236.0,
"confidence": 0.7200624942779541,
"diameter_px": 11.5,
"bbox": [
1396.0,
230.0,
1407.0,
242.0
]
},
{
"frame": 30,
"x": 1404.0,
"y": 236.5,
"confidence": 0.4647325277328491,
"diameter_px": 10.5,
"bbox": [
1399.0,
231.0,
1409.0,
242.0
]
},
{
"frame": 31,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 32,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 33,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 34,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 35,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 36,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 37,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 38,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 39,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 40,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 41,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 42,
"x": 1426.5,
"y": 308.5,
"confidence": 0.4238705635070801,
"diameter_px": 10.0,
"bbox": [
1422.0,
303.0,
1431.0,
314.0
]
},
{
"frame": 43,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 44,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 45,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 46,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 47,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 48,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 49,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 50,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 51,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 52,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 53,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 54,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 55,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 56,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 57,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 58,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 59,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 60,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 61,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 62,
"x": 1352.5,
"y": 193.0,
"confidence": 0.46716606616973877,
"diameter_px": 11.5,
"bbox": [
1347.0,
187.0,
1358.0,
199.0
]
},
{
"frame": 63,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 64,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 65,
"x": 1309.0,
"y": 191.5,
"confidence": 0.7788172364234924,
"diameter_px": 10.5,
"bbox": [
1304.0,
186.0,
1314.0,
197.0
]
},
{
"frame": 66,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 67,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 68,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 69,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 70,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 71,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 72,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 73,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 74,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 75,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 76,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 77,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 78,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 79,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 80,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 81,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 82,
"x": 957.0,
"y": 429.0,
"confidence": 0.7371496558189392,
"diameter_px": 24.0,
"bbox": [
946.0,
416.0,
968.0,
442.0
]
},
{
"frame": 83,
"x": 932.0,
"y": 458.0,
"confidence": 0.47047895193099976,
"diameter_px": 22.0,
"bbox": [
922.0,
446.0,
942.0,
470.0
]
},
{
"frame": 84,
"x": 904.5,
"y": 481.5,
"confidence": 0.7571220993995667,
"diameter_px": 15.0,
"bbox": [
898.0,
473.0,
911.0,
490.0
]
},
{
"frame": 85,
"x": 888.0,
"y": 473.0,
"confidence": 0.6384866237640381,
"diameter_px": 17.0,
"bbox": [
880.0,
464.0,
896.0,
482.0
]
},
{
"frame": 86,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 87,
"x": 852.0,
"y": 457.5,
"confidence": 0.924823522567749,
"diameter_px": 17.5,
"bbox": [
844.0,
448.0,
860.0,
467.0
]
},
{
"frame": 88,
"x": 835.0,
"y": 450.0,
"confidence": 0.6589019298553467,
"diameter_px": 19.0,
"bbox": [
826.0,
440.0,
844.0,
460.0
]
},
{
"frame": 89,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 90,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 91,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 92,
"x": 757.0,
"y": 447.5,
"confidence": 0.616001307964325,
"diameter_px": 20.5,
"bbox": [
747.0,
437.0,
767.0,
458.0
]
},
{
"frame": 93,
"x": 739.0,
"y": 449.0,
"confidence": 0.7801673412322998,
"diameter_px": 20.0,
"bbox": [
729.0,
439.0,
749.0,
459.0
]
},
{
"frame": 94,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 95,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
},
{
"frame": 96,
"x": 678.0,
"y": 473.0,
"confidence": 0.9100144505500793,
"diameter_px": 22.0,
"bbox": [
667.0,
462.0,
689.0,
484.0
]
},
{
"frame": 97,
"x": 657.5,
"y": 484.0,
"confidence": 0.9327566623687744,
"diameter_px": 18.5,
"bbox": [
649.0,
474.0,
666.0,
494.0
]
},
{
"frame": 98,
"x": 635.0,
"y": 497.5,
"confidence": 0.7828439474105835,
"diameter_px": 18.5,
"bbox": [
626.0,
488.0,
644.0,
507.0
]
},
{
"frame": 99,
"x": null,
"y": null,
"confidence": 0.0,
"diameter_px": null,
"bbox": null
}
]

View File

@@ -0,0 +1,24 @@
{
"corners_pixel": [
[
1185.6519775390625,
249.94744873046875
],
[
1687.109375,
302.2617492675781
],
[
1108.75732421875,
962.1505126953125
],
[
210.10595703125,
516.1638793945312
]
],
"court_width_m": 6.1,
"court_length_m": 13.4,
"frame_width": 1920,
"frame_height": 1080
}

View File

@@ -0,0 +1,24 @@
{
"net_corners_pixel": [
[
960,
270
],
[
970,
270
],
[
970,
810
],
[
960,
810
]
],
"net_height_m": 0.914,
"detection_confidence": 0.5,
"frame_width": 1920,
"frame_height": 1080
}

View File

@@ -0,0 +1,7 @@
{
"frames_dir": "data/20602718-5870-4419-9fa3-3a067ff0ad00/frames",
"num_frames": 100,
"fps": 29.97002997002997,
"start_frame": 299,
"start_sec": 10
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Some files were not shown because too many files have changed in this diff Show More