mobileNet/game/钢琴/index.html

887 lines
31 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Goood Space - AI 空气钢琴</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
min-height: 100vh;
background: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 50%, #2d1b69 100%);
color: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
overflow-x: hidden;
}
/* Background Animation */
body::before {
content: '';
position: fixed;
width: 200%;
height: 200%;
top: -50%;
left: -50%;
background: radial-gradient(circle, rgba(255, 170, 0, 0.1) 0%, transparent 70%);
animation: rotate 40s linear infinite;
pointer-events: none;
z-index: -1;
}
body::after {
content: '';
position: fixed;
width: 150%;
height: 150%;
top: -25%;
left: -25%;
background: radial-gradient(circle, rgba(241, 196, 15, 0.05) 0%, transparent 60%);
animation: rotate-reverse 35s linear infinite;
pointer-events: none;
z-index: -1;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes rotate-reverse {
from { transform: rotate(360deg); }
to { transform: rotate(0deg); }
}
/* Header */
.header {
text-align: center;
margin-bottom: 3rem;
animation: fadeInDown 0.8s ease-out;
}
.brand-title {
font-size: 3.5rem;
background: linear-gradient(90deg, #ffaa00, #f1c40f, #00ff88);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 60px rgba(241, 196, 15, 0.5);
margin-bottom: 0.5rem;
}
.subtitle {
font-size: 1.5rem;
color: #f1c40f;
text-shadow: 0 0 20px rgba(241, 196, 15, 0.5);
}
.tagline {
font-size: 1rem;
color: #a8c0ff;
margin-top: 0.5rem;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Main Container */
.main-container {
display: flex;
gap: 3rem;
flex-wrap: wrap;
justify-content: center;
max-width: 1400px;
width: 100%;
}
/* Video Section */
.video-section {
position: relative;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
border: 3px solid #f1c40f;
border-radius: 20px;
padding: 2rem;
backdrop-filter: blur(10px);
box-shadow: 0 0 30px rgba(241, 196, 15, 0.5);
}
#video-container {
position: relative;
width: 480px;
height: 360px;
border-radius: 15px;
overflow: hidden;
background: #000;
}
#videoFeed, #poseCanvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 15px;
}
#videoFeed {
z-index: 1;
}
#poseCanvas {
z-index: 2;
}
.status-display {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.8);
padding: 1rem;
text-align: center;
color: #00ff88;
border-bottom-left-radius: 15px;
border-bottom-right-radius: 15px;
z-index: 3;
}
/* Control Panel */
.control-panel {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 2rem;
backdrop-filter: blur(10px);
min-width: 400px;
}
.panel-section {
background: rgba(0, 0, 0, 0.3);
border-radius: 15px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.panel-section h3 {
color: #f1c40f;
margin-bottom: 1rem;
text-align: center;
font-size: 1.3rem;
text-shadow: 0 0 10px rgba(241, 196, 15, 0.5);
}
/* Piano Keys Visual */
.piano-keys {
display: flex;
justify-content: center;
margin: 1.5rem 0;
padding: 1rem;
background: #222;
border-radius: 10px;
}
.piano-key {
width: 40px;
height: 120px;
background: linear-gradient(to bottom, #fff, #f0f0f0);
border: 1px solid #ccc;
border-radius: 0 0 5px 5px;
margin: 0 2px;
transition: all 0.1s ease;
position: relative;
}
.piano-key.active {
background: linear-gradient(to bottom, #f1c40f, #f39c12);
transform: translateY(2px);
box-shadow: 0 0 20px rgba(241, 196, 15, 0.8);
}
.piano-key .note-label {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
color: #333;
font-size: 0.8rem;
font-weight: bold;
}
/* Action Button */
.action-button {
width: 100%;
padding: 1.2rem;
font-size: 1.3rem;
border: none;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: bold;
margin-bottom: 1rem;
}
#startStopBtn {
background: linear-gradient(45deg, #27ae60, #2ecc71);
color: white;
box-shadow: 0 4px 20px rgba(46, 204, 113, 0.3);
}
#startStopBtn.playing {
background: linear-gradient(45deg, #c0392b, #e74c3c);
box-shadow: 0 4px 20px rgba(231, 76, 60, 0.3);
}
#startStopBtn:hover:not(:disabled) {
transform: scale(1.05);
box-shadow: 0 6px 30px rgba(46, 204, 113, 0.5);
}
#startStopBtn:disabled {
background: #555;
color: #999;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
#importModelBtn {
background: linear-gradient(45deg, #f39c12, #f1c40f);
color: #333;
box-shadow: 0 4px 20px rgba(241, 196, 15, 0.3);
}
#importModelBtn:hover:not(:disabled) {
transform: scale(1.05);
box-shadow: 0 6px 30px rgba(241, 196, 15, 0.5);
}
#importModelBtn:disabled {
background: #555;
color: #999;
cursor: not-allowed;
}
/* Info Display */
.info-display {
display: grid;
gap: 1rem;
}
.info-item {
display: flex;
justify-content: space-between;
padding: 0.8rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border-left: 3px solid #f1c40f;
}
.info-label {
color: #a8c0ff;
font-weight: bold;
}
.info-value {
color: #00ff88;
font-weight: bold;
}
#currentPlayingNote {
font-size: 2rem;
color: #f1c40f;
text-shadow: 0 0 20px rgba(241, 196, 15, 0.8);
text-align: center;
padding: 1rem;
background: rgba(241, 196, 15, 0.1);
border-radius: 10px;
margin-top: 1rem;
animation: pulseNote 1s infinite alternate;
}
@keyframes pulseNote {
from { transform: scale(1); opacity: 1; }
to { transform: scale(1.05); opacity: 0.9; }
}
/* Note Mapping */
.note-mapping {
background: rgba(0, 0, 0, 0.5);
border-radius: 10px;
padding: 1rem;
margin-top: 1rem;
}
.note-mapping ul {
list-style: none;
padding: 0;
}
.note-mapping li {
padding: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
color: #e0f2f7;
display: flex;
justify-content: space-between;
}
.note-mapping li:last-child {
border-bottom: none;
}
.note-mapping .note-id {
color: #f1c40f;
font-weight: bold;
}
/* Responsive Design */
@media (max-width: 1200px) {
.main-container {
flex-direction: column;
align-items: center;
}
#video-container {
width: 100%;
max-width: 480px;
height: auto;
aspect-ratio: 4/3;
}
.control-panel {
width: 100%;
min-width: unset;
}
}
@media (max-width: 768px) {
.brand-title {
font-size: 2.5rem;
}
.subtitle {
font-size: 1.2rem;
}
.piano-keys {
overflow-x: auto;
}
.piano-key {
width: 35px;
height: 100px;
}
}
</style>
<!-- TensorFlow.js 核心库 -->
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.20.0/dist/tf.min.js"></script>
<!-- KNN 分类器 -->
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/knn-classifier@1.2.2/dist/knn-classifier.min.js"></script>
<!-- MediaPipe Hands -->
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands"></script>
<!-- Hand Pose Detection -->
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/hand-pose-detection"></script>
</head>
<body>
<div class="header">
<h1 class="brand-title">Goood Space</h1>
<p class="subtitle">AI 空气钢琴</p>
<p class="tagline">通过手势弹奏虚拟钢琴!</p>
</div>
<div class="main-container">
<div class="video-section">
<div id="video-container">
<video id="videoFeed" autoplay muted playsinline></video>
<canvas id="poseCanvas"></canvas>
<div class="status-display" id="globalStatus">正在加载模型,请稍候...</div>
</div>
</div>
<div class="control-panel">
<div class="panel-section">
<h3>🎹 钢琴键盘</h3>
<div class="piano-keys" id="pianoKeys">
<div class="piano-key" data-note="0">
<span class="note-label">C</span>
</div>
<div class="piano-key" data-note="1">
<span class="note-label">D</span>
</div>
<div class="piano-key" data-note="2">
<span class="note-label">E</span>
</div>
<div class="piano-key" data-note="3">
<span class="note-label">F</span>
</div>
<div class="piano-key" data-note="4">
<span class="note-label">G</span>
</div>
<div class="piano-key" data-note="5">
<span class="note-label">A</span>
</div>
<div class="piano-key" data-note="6">
<span class="note-label">B</span>
</div>
<div class="piano-key" data-note="7">
<span class="note-label">C</span>
</div>
</div>
</div>
<div class="panel-section">
<h3>🎮 演奏控制</h3>
<input type="file" id="fileImporter" accept=".json" style="display: none;">
<button id="importModelBtn" class="action-button">导入手势模型</button>
<button id="startStopBtn" class="action-button" disabled>开始演奏</button>
<div class="info-display">
<div class="info-item">
<span class="info-label">实时手势:</span>
<span class="info-value" id="currentGestureDisplay">未识别</span>
</div>
<div class="info-item">
<span class="info-label">置信度:</span>
<span class="info-value" id="confidenceDisplay">0%</span>
</div>
</div>
<div id="currentPlayingNote">🎵 无</div>
</div>
<div class="panel-section">
<h3>📋 音符映射</h3>
<div class="note-mapping">
<ul id="gestureMappingList">
<li><span class="note-id">ID 0:</span> 中央C (C2)</li>
<li><span class="note-id">ID 1:</span> D2</li>
<li><span class="note-id">ID 2:</span> E2</li>
<li><span class="note-id">ID 3:</span> F2</li>
<li><span class="note-id">ID 4:</span> G2</li>
<li><span class="note-id">ID 5:</span> A2</li>
<li><span class="note-id">ID 6:</span> B2</li>
<li><span class="note-id">ID 7:</span> 高音C (C3)</li>
</ul>
</div>
</div>
</div>
</div>
<script>
// DOM 元素
const videoElement = document.getElementById('videoFeed');
const poseCanvas = document.getElementById('poseCanvas');
const poseCtx = poseCanvas.getContext('2d');
const globalStatusDisplay = document.getElementById('globalStatus');
const currentGestureDisplay = document.getElementById('currentGestureDisplay');
const confidenceDisplay = document.getElementById('confidenceDisplay');
const currentPlayingNoteDisplay = document.getElementById('currentPlayingNote');
const pianoKeysContainer = document.getElementById('pianoKeys');
const pianoKeys = pianoKeysContainer.querySelectorAll('.piano-key');
const importModelBtn = document.getElementById('importModelBtn');
const fileImporter = document.getElementById('fileImporter');
const startStopBtn = document.getElementById('startStopBtn');
// 全局变量
let detector;
let classifier;
let isHandDetectionReady = false;
let isModelLoaded = false;
let isPlaying = false;
let animationFrameId;
let currentPlayingNoteId = null;
// 音符映射
const gestureClassToAudioMap = {
'0': { name: '中央C (C2)', audio: null },
'1': { name: 'D2', audio: null },
'2': { name: 'E2', audio: null },
'3': { name: 'F2', audio: null },
'4': { name: 'G2', audio: null },
'5': { name: 'A2', audio: null },
'6': { name: 'B2', audio: null },
'7': { name: '高音C (C3)', audio: null }
};
// 创建音频对象使用Web Audio API生成音符
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const noteFrequencies = {
'0': 130.81, // C2
'1': 146.83, // D2
'2': 164.81, // E2
'3': 174.61, // F2
'4': 196.00, // G2
'5': 220.00, // A2
'6': 246.94, // B2
'7': 261.63 // C3
};
const MIN_CONFIDENCE_THRESHOLD = 0.30;
// MediaPipe Hands 连接点
const HAND_CONNECTIONS = [
[0, 1], [1, 2], [2, 3], [3, 4],
[0, 5], [5, 6], [6, 7], [7, 8],
[0, 9], [9, 10], [10, 11], [11, 12],
[0, 13], [13, 14], [14, 15], [15, 16],
[0, 17], [17, 18], [18, 19], [19, 20],
[0, 5], [5, 9], [9, 13], [13, 17], [17, 0]
];
// 初始化应用
async function initApp() {
updateGlobalStatus('正在加载手势模型和摄像头...');
try {
// 初始化 KNN 分类器
classifier = knnClassifier.create();
// 初始化手部检测器
const model = handPoseDetection.SupportedModels.MediaPipeHands;
const detectorConfig = {
runtime: 'mediapipe',
solutionPath: 'https://cdn.jsdelivr.net/npm/@mediapipe/hands'
};
detector = await handPoseDetection.createDetector(model, detectorConfig);
await setupCamera();
isHandDetectionReady = true;
updateGlobalStatus('手部检测器和摄像头已就绪。');
// 启动检测循环
startDetectionLoop();
// 绑定事件
importModelBtn.addEventListener('click', () => fileImporter.click());
fileImporter.addEventListener('change', (event) => loadKNNModelData(event.target.files[0]));
startStopBtn.addEventListener('click', togglePlaying);
// // 尝试从 CDN 加载模型
// try {
// const cdnJsonUrl = 'https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/models/hand-knn-model.json';
// await loadKNNModelData(null, cdnJsonUrl);
// updateGlobalStatus('CDN 手势识别模型加载成功!');
// isModelLoaded = true;
// importModelBtn.disabled = true;
// startStopBtn.disabled = false;
// } catch (cdnError) {
// console.warn('CDN 模型加载失败:', cdnError);
// updateGlobalStatus('请手动导入模型文件');
// importModelBtn.disabled = false;
// startStopBtn.disabled = true;
// }
} catch (error) {
console.error('应用初始化失败:', error);
updateGlobalStatus('初始化失败: ' + error.message);
}
}
// 设置摄像头
async function setupCamera() {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 480, height: 360, facingMode: 'user' }
});
videoElement.srcObject = stream;
return new Promise((resolve) => {
videoElement.onloadedmetadata = () => {
videoElement.play();
poseCanvas.width = videoElement.videoWidth;
poseCanvas.height = videoElement.videoHeight;
resolve();
};
});
}
// 加载 KNN 模型
async function loadKNNModelData(file = null, cdnUrl = null) {
updateGlobalStatus('正在加载模型...');
try {
let loadedModelData;
if (file) {
const reader = new FileReader();
loadedModelData = await new Promise((resolve, reject) => {
reader.onload = e => resolve(JSON.parse(e.target.result));
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsText(file);
});
} else if (cdnUrl) {
const response = await fetch(cdnUrl);
if (!response.ok) {
throw new Error('无法从 CDN 加载模型');
}
loadedModelData = await response.json();
} else {
throw new Error('未提供模型文件或 URL');
}
if (!loadedModelData || !loadedModelData.dataset) {
throw new Error('模型数据格式不正确');
}
classifier.clearAllClasses();
const dataset = {};
for (const classId in loadedModelData.dataset) {
const classData = loadedModelData.dataset[classId];
if (classData && classData.length > 0) {
const tensors = classData.map(data => tf.tensor1d(data));
dataset[classId] = tf.stack(tensors);
tensors.forEach(t => t.dispose());
}
}
classifier.setClassifierDataset(dataset);
isModelLoaded = true;
importModelBtn.disabled = true;
startStopBtn.disabled = false;
updateGlobalStatus('手势模型加载成功!');
} catch (error) {
console.error('模型加载失败:', error);
updateGlobalStatus('模型加载失败: ' + error.message);
isModelLoaded = false;
importModelBtn.disabled = false;
startStopBtn.disabled = true;
throw error;
} finally {
fileImporter.value = '';
}
}
// 检测循环
async function startDetectionLoop() {
if (!isHandDetectionReady) return;
async function detectAndPredict() {
try {
const hands = await detector.estimateHands(videoElement, { flipHorizontal: false });
poseCtx.clearRect(0, 0, poseCanvas.width, poseCanvas.height);
let currentConfidencePercentage = "0";
if (hands && hands.length > 0) {
drawHand(hands[0]);
if (isModelLoaded && isPlaying) {
const handTensor = flattenHand(hands[0]);
if (classifier.getNumClasses() > 0) {
const prediction = await classifier.predictClass(handTensor);
handTensor.dispose();
const predictedClassId = prediction.label;
const confidence = prediction.confidences[predictedClassId];
currentConfidencePercentage = (confidence * 100).toFixed(1);
if (confidence > MIN_CONFIDENCE_THRESHOLD) {
const noteInfo = gestureClassToAudioMap[predictedClassId];
if (noteInfo) {
currentGestureDisplay.textContent = noteInfo.name;
if (currentPlayingNoteId !== predictedClassId) {
playNote(predictedClassId, noteInfo.name);
currentPlayingNoteId = predictedClassId;
}
} else {
currentGestureDisplay.textContent = '未知音符';
stopAllNotes();
}
} else {
currentGestureDisplay.textContent = '未识别';
if (currentPlayingNoteId !== null) {
stopAllNotes();
}
}
} else {
handTensor.dispose();
currentGestureDisplay.textContent = '模型无数据';
stopAllNotes();
}
} else {
currentGestureDisplay.textContent = '静止';
stopAllNotes();
}
} else {
currentGestureDisplay.textContent = '请将手放入画面';
stopAllNotes();
}
confidenceDisplay.textContent = currentConfidencePercentage + '%';
} catch (error) {
console.error('检测错误:', error);
}
animationFrameId = requestAnimationFrame(detectAndPredict);
}
animationFrameId = requestAnimationFrame(detectAndPredict);
}
// 展平手部数据
function flattenHand(hand) {
const keypoints = hand.keypoints.map(p => [
p.x / videoElement.videoWidth,
p.y / videoElement.videoHeight
]).flat();
return tf.tensor(keypoints);
}
// 绘制手部
function drawHand(hand) {
// 绘制连接线
poseCtx.strokeStyle = '#3498db';
poseCtx.lineWidth = 3;
poseCtx.shadowColor = '#3498db';
poseCtx.shadowBlur = 5;
for (const connection of HAND_CONNECTIONS) {
const start = hand.keypoints[connection[0]];
const end = hand.keypoints[connection[1]];
poseCtx.beginPath();
poseCtx.moveTo(start.x, start.y);
poseCtx.lineTo(end.x, end.y);
poseCtx.stroke();
}
// 绘制关键点
poseCtx.fillStyle = '#f1c40f';
poseCtx.shadowColor = '#f1c40f';
poseCtx.shadowBlur = 8;
for (const keypoint of hand.keypoints) {
poseCtx.beginPath();
poseCtx.arc(keypoint.x, keypoint.y, 5, 0, 2 * Math.PI);
poseCtx.fill();
}
poseCtx.shadowBlur = 0;
}
// 播放音符(使用 Web Audio API
let currentOscillator = null;
let currentGainNode = null;
function playNote(noteId, noteName) {
// 停止之前的音符
stopAllNotes();
// 创建振荡器
currentOscillator = audioContext.createOscillator();
currentGainNode = audioContext.createGain();
// 设置频率
currentOscillator.frequency.value = noteFrequencies[noteId] || 440;
currentOscillator.type = 'sine';
// 设置音量包络
currentGainNode.gain.setValueAtTime(0, audioContext.currentTime);
currentGainNode.gain.linearRampToValueAtTime(0.3, audioContext.currentTime + 0.01);
currentGainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 1);
// 连接节点
currentOscillator.connect(currentGainNode);
currentGainNode.connect(audioContext.destination);
// 开始播放
currentOscillator.start(audioContext.currentTime);
currentOscillator.stop(audioContext.currentTime + 1);
// 更新UI
currentPlayingNoteDisplay.textContent = '🎵 ' + noteName;
// 高亮钢琴键
pianoKeys.forEach(key => key.classList.remove('active'));
const activeKey = pianoKeysContainer.querySelector(`[data-note="${noteId}"]`);
if (activeKey) {
activeKey.classList.add('active');
}
}
// 停止所有音符
function stopAllNotes() {
if (currentOscillator) {
try {
currentOscillator.stop();
} catch (e) {
// 已经停止
}
currentOscillator = null;
}
currentPlayingNoteId = null;
currentPlayingNoteDisplay.textContent = '🎵 无';
pianoKeys.forEach(key => key.classList.remove('active'));
}
// 切换演奏状态
function togglePlaying() {
if (!isModelLoaded) {
alert('请先导入手势模型!');
return;
}
isPlaying = !isPlaying;
if (isPlaying) {
startStopBtn.textContent = '停止演奏';
startStopBtn.classList.add('playing');
importModelBtn.disabled = true;
updateGlobalStatus('开始演奏,请摆出您的钢琴手势!');
} else {
startStopBtn.textContent = '开始演奏';
startStopBtn.classList.remove('playing');
importModelBtn.disabled = false;
updateGlobalStatus('已停止演奏');
stopAllNotes();
}
currentGestureDisplay.textContent = '静止';
confidenceDisplay.textContent = '0%';
}
// 更新状态
function updateGlobalStatus(message) {
globalStatusDisplay.textContent = message;
}
// 初始化
window.addEventListener('DOMContentLoaded', initApp);
// 清理资源
window.addEventListener('beforeunload', () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
if (detector) {
detector.dispose();
}
if (classifier) {
classifier.clearAllClasses();
}
tf.disposeAll();
stopAllNotes();
});
</script>
</body>
</html>