832 lines
24 KiB
HTML
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 20602718</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": 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": 42,
|
|
"x_m": 3.9051076612543536,
|
|
"y_m": -0.5035200640235366,
|
|
"z_m": 0.4195843244793487,
|
|
"on_ground": false,
|
|
"confidence": 0.4238705635070801
|
|
},
|
|
{
|
|
"frame": 62,
|
|
"x_m": 1.9072337782891502,
|
|
"y_m": -0.2804220005117505,
|
|
"z_m": 0.8963949565054601,
|
|
"on_ground": false,
|
|
"confidence": 0.46716606616973877
|
|
},
|
|
{
|
|
"frame": 65,
|
|
"x_m": 3.017917751921617,
|
|
"y_m": -0.062196873493954974,
|
|
"z_m": 1.3217371998457894,
|
|
"on_ground": false,
|
|
"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>
|