666 lines
21 KiB
Python
666 lines
21 KiB
Python
"""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
|