Files
Ruslan Bakiev 549fd1da9d Initial commit
2026-03-06 09:43:52 +07:00

832 lines
24 KiB
HTML

<!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 6350640e</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 = [
{
"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": 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": 14,
"x_m": 9.910148620605469,
"y_m": 3.5509445667266846,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.667772114276886
},
{
"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": 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": 29,
"x_m": 4.331277370452881,
"y_m": -1.5349446535110474,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.7200624942779541
},
{
"frame": 30,
"x_m": 4.433146953582764,
"y_m": -1.5207486152648926,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.4647325277328491
},
{
"frame": 42,
"x_m": 8.577290534973145,
"y_m": 1.0078997611999512,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.4238705635070801
},
{
"frame": 62,
"x_m": -1.4370819330215454,
"y_m": -4.089122772216797,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.46716606616973877
},
{
"frame": 65,
"x_m": -3.1526687145233154,
"y_m": -3.9628825187683105,
"z_m": 0.0,
"on_ground": true,
"confidence": 0.7788172364234924
},
{
"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": 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": 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": 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
}
];
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>