1135 lines
49 KiB
HTML
1135 lines
49 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>AI 姿态控制贪吃蛇</title>
|
||
<style>
|
||
body {
|
||
font-family: Arial, sans-serif;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 100vh;
|
||
background: linear-gradient(135deg, #a8c0ff, #3f2b96);
|
||
color: #fff;
|
||
margin: 0;
|
||
padding: 20px;
|
||
}
|
||
|
||
h1 {
|
||
margin-bottom: 20px;
|
||
color: #fff;
|
||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
#game-area {
|
||
display: flex;
|
||
gap: 20px;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
}
|
||
|
||
#snake-canvas-container {
|
||
/* === 修改点 1:显式设置宽度和高度,使其与 canvas 元素尺寸一致 === */
|
||
width: 600px; /* 与 JavaScript 中 CANVAS_WIDTH 匹配 */
|
||
height: 400px; /* 与 JavaScript 中 CANVAS_HEIGHT 匹配 */
|
||
|
||
position: relative;
|
||
border: 5px solid #00f2fe;
|
||
box-shadow: 0 0 20px #00f2fe, 0 0 40px #00f2fe;
|
||
background-color: #333;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
#snakeCanvas {
|
||
background-color: #333;
|
||
display: block;
|
||
/* === 修改点 1:确保 canvas 实际宽度和高度与容器一致 === */
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
#game-info-panel {
|
||
background: rgba(0, 0, 0, 0.6);
|
||
padding: 25px;
|
||
border-radius: 10px;
|
||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
|
||
min-width: 300px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 15px;
|
||
}
|
||
|
||
.info-item {
|
||
font-size: 1.1em;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.info-item span:first-child {
|
||
font-weight: bold;
|
||
color: #a8c0ff;
|
||
}
|
||
|
||
#status-display {
|
||
background: #222;
|
||
padding: 10px;
|
||
border-radius: 5px;
|
||
font-size: 0.9em;
|
||
min-height: 30px;
|
||
color: #00f2fe;
|
||
text-align: center;
|
||
}
|
||
|
||
.gesture-indicator {
|
||
font-size: 1.2em;
|
||
color: #00ff00;
|
||
margin-top: 10px;
|
||
padding: 5px;
|
||
background: #2c2c2c;
|
||
border-radius: 5px;
|
||
text-align: center;
|
||
min-height: 25px;
|
||
}
|
||
|
||
.control-buttons {
|
||
display: flex;
|
||
gap: 10px;
|
||
justify-content: center;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.control-buttons button {
|
||
padding: 12px 25px;
|
||
font-size: 1.1em;
|
||
border: none;
|
||
border-radius: 5px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
|
||
color: white;
|
||
}
|
||
|
||
#startBtn {
|
||
background: linear-gradient(45deg, #2196F3, #21CBF3);
|
||
}
|
||
#startBtn:hover {
|
||
background: linear-gradient(45deg, #1976D2, #1AC8EF);
|
||
transform: translateY(-2px);
|
||
}
|
||
#startBtn:disabled {
|
||
background: #607d8b;
|
||
cursor: not-allowed;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
#importModelBtn {
|
||
background: linear-gradient(45deg, #FFC107, #FFEB3B);
|
||
}
|
||
#importModelBtn:hover {
|
||
background: linear-gradient(45deg, #FFA000, #FFD700);
|
||
transform: translateY(-2px);
|
||
}
|
||
#importModelBtn:disabled {
|
||
background: #9E9E9E;
|
||
cursor: not-allowed;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
#video-feed-container {
|
||
position: absolute;
|
||
top: 20px;
|
||
left: 20px;
|
||
width: 320px;
|
||
height: 240px;
|
||
border: 3px solid #6a1b9a;
|
||
border-radius: 10px;
|
||
background: #000;
|
||
overflow: hidden;
|
||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.6);
|
||
z-index: 100; /* 确保在游戏画布之上 */
|
||
}
|
||
|
||
#video-feed-container video {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
border-radius: 7px;
|
||
}
|
||
|
||
#video-feed-container canvas {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 7px;
|
||
}
|
||
|
||
.overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.85);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
color: white;
|
||
font-size: 2em;
|
||
text-align: center;
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||
}
|
||
.overlay.active {
|
||
opacity: 1;
|
||
visibility: visible;
|
||
}
|
||
.overlay h2 {
|
||
font-size: 3em;
|
||
margin-bottom: 20px;
|
||
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.5);
|
||
}
|
||
.overlay p {
|
||
font-size: 0.8em; /* 调整字体大小 */
|
||
}
|
||
.overlay button {
|
||
margin-top: 30px;
|
||
font-size: 1.5em;
|
||
padding: 15px 30px;
|
||
background: linear-gradient(45deg, #28a745, #20c997);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 50px;
|
||
cursor: pointer;
|
||
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.3);
|
||
transition: transform 0.2s, box-shadow 0.2s;
|
||
}
|
||
.overlay button:hover {
|
||
transform: translateY(-3px);
|
||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
|
||
}
|
||
.overlay button:active {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.instructions {
|
||
background: rgba(0, 0, 0, 0.6);
|
||
padding: 15px;
|
||
border-radius: 8px;
|
||
margin-top: 20px;
|
||
font-size: 0.9em;
|
||
line-height: 1.6;
|
||
width: 80%;
|
||
max-width: 500px;
|
||
text-align: left;
|
||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
||
animation: fadeIn 1s ease-out;
|
||
}
|
||
.instructions h3 {
|
||
color: #00f2fe;
|
||
margin-bottom: 10px;
|
||
text-align: center;
|
||
}
|
||
.instructions ul {
|
||
list-style: none;
|
||
padding-left: 0;
|
||
}
|
||
.instructions li {
|
||
margin-bottom: 5px;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.instructions li strong {
|
||
color: #a8c0ff;
|
||
min-width: 80px;
|
||
}
|
||
.instructions li span {
|
||
flex-grow: 1;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; transform: translateY(20px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
</style>
|
||
<!-- !!!!!! 核心劫持代码:确保在任何 TF.js 库之前加载 !!!!!! -->
|
||
<script>
|
||
(function() {
|
||
// 定义你的镜像服务器的公共前缀,用于存放 MoveNet 模型文件
|
||
// 例如:'https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/movenet-mirror/'
|
||
// 重要:确保你的镜像服务器的目录结构与原始模型文件的路径部分匹配。
|
||
// 举例:
|
||
// 如果原始是 https://tfhub.dev/deepmind/movenet/singlepose-lightning/4/model.json
|
||
// 那么在你的CDN上,你需要部署为:
|
||
// https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/movenet-mirror/tfhub.dev/deepmind/movenet/singlepose-lightning/4/model.json
|
||
//
|
||
// 如果你的CDN就是直接放了 model.json, group1-shard*of*.bin,那么 MIRROR_BASE_URL 将不含后续路径。
|
||
//
|
||
// **** 根据你前一个回复的镜像路径,我们假设你的镜像结构是: ****
|
||
// https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/fetch/snake_game/model.json
|
||
// https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/fetch/snake_game/group1-shard1of2.bin
|
||
// https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/fetch/snake_game/group1-shard2of2.bin
|
||
//
|
||
// 那么,我们需要将匹配的原始URL路径重写为 MIRROR_SPECIFIC_FILENAME_PREFIX
|
||
// 也就是将类似 "https://tfhub.dev/.../model.json..."
|
||
// 替换为 "https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/fetch/snake_game/model.json"
|
||
const MIRROR_SPECIFIC_FILENAME_PREFIX = 'https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/fetch/snake_game/';
|
||
|
||
// 定义需要被劫持的原始 URL 的域名模式
|
||
const INTERCEPT_DOMAINS = [
|
||
'https://tfhub.dev/',
|
||
// 如果实际的最终模型文件仍然解析到 storage.googleapis.com,也需要包含
|
||
// 例如:'https://storage.googleapis.com/tfjs-models/'
|
||
// 或者你观察到的实际的最终 Google Storage 域名
|
||
];
|
||
|
||
// 备份原始的 fetch 函数
|
||
const originalFetch = window.fetch;
|
||
|
||
window.fetch = function(input, init) {
|
||
let url = input;
|
||
if (input instanceof Request) {
|
||
url = input.url;
|
||
}
|
||
|
||
let newUrl = url;
|
||
let isIntercepted = false;
|
||
|
||
// 检查 URL 是否以我们关注的域名开头
|
||
for (const domain of INTERCEPT_DOMAINS) {
|
||
if (url.startsWith(domain)) {
|
||
// 尝试从 URL 中提取文件名 (不包含查询参数)
|
||
// 匹配 model.json 或 group1-shardXofY.bin
|
||
const fileNameMatch = url.match(/(model\.json|group1-shard\dof\d\.bin)/);
|
||
if (fileNameMatch) {
|
||
const fileName = fileNameMatch[0]; // 获取匹配到的文件名
|
||
newUrl = MIRROR_SPECIFIC_FILENAME_PREFIX + fileName; // 拼接新的镜像 URL
|
||
isIntercepted = true;
|
||
break; // 找到匹配的域名和文件,停止循环
|
||
}
|
||
}
|
||
}
|
||
|
||
if (isIntercepted) {
|
||
console.warn(`[TFJS Fetch Intercepted] Original: ${url}`);
|
||
console.warn(`[TFJS Fetch Intercepted] Redirecting to: ${newUrl}`);
|
||
|
||
if (input instanceof Request) {
|
||
input = new Request(newUrl, {
|
||
method: input.method,
|
||
headers: input.headers,
|
||
body: input.body,
|
||
referrer: input.referrer,
|
||
referrerPolicy: input.referrerPolicy,
|
||
mode: 'cors',
|
||
credentials: input.credentials,
|
||
cache: 'default',
|
||
redirect: 'follow',
|
||
integrity: undefined, // 移除 integrity 属性以避免校验失败
|
||
signal: input.signal,
|
||
});
|
||
} else {
|
||
input = newUrl;
|
||
}
|
||
}
|
||
|
||
return originalFetch(input, init).catch(error => {
|
||
console.error(`[TFJS Fetch Intercepted Error] Failed to load ${url} (redirected to ${newUrl || url || input}):`, error);
|
||
throw error;
|
||
});
|
||
};
|
||
|
||
// -------------------- 劫持 XMLHttpRequest API (备用安全网) --------------------
|
||
// 尽管 TF.js 主要用 fetch,但安全起见保留 XHR 劫持
|
||
const originalXHR = window.XMLHttpRequest;
|
||
window.XMLHttpRequest = function() {
|
||
const xhr = new originalXHR();
|
||
const originalOpen = xhr.open;
|
||
xhr.open = function(method, url, async = true, user = null, password = null) {
|
||
let newUrl = url;
|
||
let isIntercepted = false;
|
||
|
||
for (const domain of INTERCEPT_DOMAINS) {
|
||
if (url.startsWith(domain)) {
|
||
const fileNameMatch = url.match(/(model\.json|group1-shard\dof\d\.bin)/);
|
||
if (fileNameMatch) {
|
||
const fileName = fileNameMatch[0];
|
||
newUrl = MIRROR_SPECIFIC_FILENAME_PREFIX + fileName;
|
||
isIntercepted = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (isIntercepted) {
|
||
console.warn(`[TFJS XHR Intercepted] Original: ${url}`);
|
||
console.warn(`[TFJS XHR Intercepted] Redirecting to: ${newUrl}`);
|
||
url = newUrl; // 修改传入 open 的 URL
|
||
}
|
||
|
||
return originalOpen.apply(this, arguments);
|
||
};
|
||
|
||
for (const key in originalXHR) {
|
||
if (originalXHR.hasOwnProperty(key)) {
|
||
window.XMLHttpRequest[key] = originalXHR[key];
|
||
}
|
||
}
|
||
return xhr;
|
||
};
|
||
|
||
})();
|
||
</script>
|
||
<!-- 引入 TensorFlow.js 核心库 -->
|
||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.20.0/dist/tf.min.js"></script>
|
||
<!-- 引入 pose-detection 库 (包含 MoveNet) -->
|
||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/pose-detection@2.1.3/dist/pose-detection.min.js"></script>
|
||
<!-- 引入 KNN 分类器 -->
|
||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/knn-classifier@1.2.2/dist/knn-classifier.min.js"></script>
|
||
</head>
|
||
<body>
|
||
<h1>AI 姿态控制贪吃蛇</h1>
|
||
|
||
<div id="game-area">
|
||
<div id="video-feed-container">
|
||
<video id="videoFeed" autoplay muted playsinline></video>
|
||
<canvas id="poseCanvas"></canvas>
|
||
</div>
|
||
|
||
<div id="snake-canvas-container">
|
||
<canvas id="snakeCanvas"></canvas>
|
||
</div>
|
||
|
||
<div id="game-info-panel">
|
||
<div class="info-item"><span>得分:</span> <span id="score">0</span></div>
|
||
<div class="info-item"><span>姿态识别:</span> <span id="currentGesture">未识别</span></div>
|
||
<div class="info-item"><span>控制指令:</span> <span id="controlCommand">静止</span></div>
|
||
<div id="status-display">等待模型导入...</div>
|
||
<div class="gesture-indicator" id="gestureConfidence"></div>
|
||
<div class="control-buttons">
|
||
<button id="startBtn" disabled>开始游戏 (Enter)</button>
|
||
<input type="file" id="fileImporter" accept=".json" style="display: none;">
|
||
<button id="importModelBtn">导入模型</button>
|
||
</div>
|
||
<div class="instructions">
|
||
<h3>姿态控制说明</h3>
|
||
<ul>
|
||
<li><strong>向上:</strong> <span>双手举过头顶 (分类0)</span></li>
|
||
<li><strong>向下:</strong> <span>双手放在身体两侧或下垂 (分类1)</span></li>
|
||
<li><strong>向左:</strong> <span>左手平举 (分类2)</span></li>
|
||
<li><strong>向右:</strong> <span>右手平举 (分类3)</span></li>
|
||
<li><strong>静止:</strong> <span>无特定动作 (游戏开始前/结束后姿态)</span></li>
|
||
</ul>
|
||
<p style="font-size:0.8em;text-align:center;margin-top:10px;color:#ccc;">(确保在摄像头画面中能清晰识别全身)</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 游戏结束/开始 Overlay -->
|
||
<div id="gameOverOverlay" class="overlay">
|
||
<h2 id="overlayText">游戏结束!</h2>
|
||
<p>最终得分: <span id="finalScore">0</span></p>
|
||
<button id="restartBtn">重新开始 (Enter)</button>
|
||
</div>
|
||
|
||
<script>
|
||
// ==========================================================
|
||
// 全局变量和 DOM 引用
|
||
// ==========================================================
|
||
const videoElement = document.getElementById('videoFeed');
|
||
const poseCanvas = document.getElementById('poseCanvas');
|
||
const poseCtx = poseCanvas.getContext('2d');
|
||
const snakeCanvas = document.getElementById('snakeCanvas');
|
||
const snakeCtx = snakeCanvas.getContext('2d');
|
||
|
||
const scoreDisplay = document.getElementById('score');
|
||
const currentGestureDisplay = document.getElementById('currentGesture');
|
||
const controlCommandDisplay = document.getElementById('controlCommand');
|
||
const statusDisplay = document.getElementById('status-display');
|
||
const gestureConfidenceDisplay = document.getElementById('gestureConfidence');
|
||
|
||
const startBtn = document.getElementById('startBtn');
|
||
const importModelBtn = document.getElementById('importModelBtn');
|
||
const fileImporter = document.getElementById('fileImporter');
|
||
const gameOverOverlay = document.getElementById('gameOverOverlay');
|
||
const overlayText = document.getElementById('overlayText');
|
||
const finalScoreDisplay = document.getElementById('finalScore');
|
||
const restartBtn = document.getElementById('restartBtn');
|
||
|
||
|
||
let detector; // MoveNet 姿态检测器
|
||
let classifier; // KNN 分类器
|
||
|
||
let isPoseDetectionReady = false; // 姿态检测系统是否就绪
|
||
let isModelLoaded = false; // KNN 模型是否已加载
|
||
|
||
let animationFrameId; // 用于姿态检测的 requestAnimationFrame ID
|
||
let gameLoopId; // 用于贪吃蛇游戏循环的 setInterval ID
|
||
|
||
let gameStatus = 'initial'; // 'initial', 'loading', 'ready', 'playing', 'paused', 'gameOver'
|
||
let currentDetectedClassId = null; // 当前检测到的姿态分类ID
|
||
let currentConfidence = 0; // 当前检测到的置信度
|
||
|
||
// 用于映射姿态分类ID到游戏方向
|
||
// 请确保这些ID与您训练模型时使用的类别ID一致
|
||
const gestureClassToGameDirection = {
|
||
'0': 'UP', // 类别0 -> 向上
|
||
'1': 'DOWN', // 类别1 -> 向下
|
||
'2': 'LEFT', // 类别2 -> 向左
|
||
'3': 'RIGHT' // 类别3 -> 向右
|
||
};
|
||
// 用于显示给用户的类别名称(加载模型时从JSON中读取)
|
||
// 确保你的训练模型有这四个类别
|
||
let importedClassNames = {
|
||
'0': '举手',
|
||
'1': '下蹲',
|
||
'2': '左抬手',
|
||
'3': '右抬手'
|
||
};
|
||
let snakeDirection = 'RIGHT'; // 贪吃蛇当前方向
|
||
|
||
// ==========================================================
|
||
// 贪吃蛇游戏设置
|
||
// ==========================================================
|
||
const GRID_SIZE = 20; // 网格大小(像素)
|
||
const CANVAS_WIDTH = 600;
|
||
const CANVAS_HEIGHT = 400;
|
||
snakeCanvas.width = CANVAS_WIDTH;
|
||
snakeCanvas.height = CANVAS_HEIGHT;
|
||
|
||
let snake = [{ x: 10, y: 10 }]; // 蛇的身体 (以网格坐标表示)
|
||
let food = {}; // 食物位置
|
||
let score = 0;
|
||
let gameSpeed = 300; // 游戏速度(毫秒)
|
||
|
||
// ==========================================================
|
||
// 初始化函数
|
||
// ==========================================================
|
||
|
||
document.addEventListener('DOMContentLoaded', initApp);
|
||
|
||
async function initApp() {
|
||
updateGameStatus('initial'); // 初始状态设为 'initial'
|
||
statusDisplay.textContent = '正在加载 MoveNet 模型和摄像头...';
|
||
startBtn.disabled = true;
|
||
importModelBtn.disabled = true;
|
||
|
||
try {
|
||
// 初始化 KNN 分类器
|
||
classifier = knnClassifier.create();
|
||
|
||
// 初始化 MoveNet 检测器
|
||
const detectorConfig = { modelType: poseDetection.movenet.modelType.SINGLEPOSE_LIGHTNING };
|
||
detector = await poseDetection.createDetector(poseDetection.SupportedModels.MoveNet, detectorConfig);
|
||
|
||
// 设置摄像头
|
||
await setupCamera();
|
||
isPoseDetectionReady = true;
|
||
|
||
statusDisplay.textContent = 'MoveNet 和摄像头已就绪。'; // 状态更新
|
||
importModelBtn.disabled = false; // 启用导入按钮 (因为CDN加载可能失败,需要手动导入)
|
||
|
||
// 启动姿态检测循环(只进行检测和绘制,不预测,直到模型导入)
|
||
startPoseDetectionLoop();
|
||
|
||
// 绑定按钮事件
|
||
startBtn.addEventListener('click', startGame);
|
||
importModelBtn.addEventListener('click', () => fileImporter.click());
|
||
fileImporter.addEventListener('change', handleModelImport);
|
||
restartBtn.addEventListener('click', resetGame);
|
||
|
||
// 键盘快捷键
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
// 确保只有当游戏处于“准备好”或“游戏结束”状态才能启动或重新开始
|
||
if (gameStatus === 'ready' || gameStatus === 'gameOver') {
|
||
// 如果是游戏结束状态且按钮可用,点击 restartBtn
|
||
if (gameStatus === 'gameOver' && !restartBtn.disabled) {
|
||
restartBtn.click();
|
||
} else if (gameStatus === 'ready' && !startBtn.disabled) {
|
||
// 如果是准备就绪状态且按钮可用,点击 startBtn
|
||
startBtn.click();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// --- 新增:尝试自动从 CDN 加载 KNN 模型数据 ---
|
||
// !!! 请替换为你的实际 CDN 模型 URL !!!
|
||
const cdnModelJsonUrl = 'https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/models/pose-knn-model.json';
|
||
|
||
console.log(`尝试从 CDN 自动加载 KNN 模型数据: ${cdnModelJsonUrl}`);
|
||
statusDisplay.textContent = '正在尝试从 CDN 加载姿态识别模型...';
|
||
|
||
try {
|
||
await loadKNNModelData(null, cdnModelJsonUrl); // 传入 CDN URL, file 为 null
|
||
statusDisplay.textContent = 'CDN 姿态识别模型加载成功!可以开始游戏了。';
|
||
isModelLoaded = true; // 标记模型已加载
|
||
startBtn.disabled = false; // 启用开始游戏按钮
|
||
importModelBtn.disabled = true; // 自动加载成功后,禁用手动导入按钮
|
||
updateGameStatus('ready'); // 更新游戏状态
|
||
} catch (cdnError) {
|
||
console.warn('CDN KNN 模型数据自动加载失败:', cdnError);
|
||
statusDisplay.textContent = `CDN 模型加载失败: ${cdnError.message}。请手动导入模型。`;
|
||
isModelLoaded = false; // 标记模型未加载
|
||
startBtn.disabled = true; // 模型未加载,开始按钮仍禁用
|
||
importModelBtn.disabled = false; // 允许手动导入
|
||
updateGameStatus('initial'); // 保持初始状态
|
||
}
|
||
// --- 结束 CDN 自动加载 ---
|
||
|
||
updateGameUI(); // 确保 UI 在初始化结束时更新一次
|
||
|
||
} catch (error) {
|
||
console.error("应用初始化失败:", error);
|
||
statusDisplay.textContent = `初始化失败: ${error.message}`;
|
||
alert(`应用初始化失败: ${error.message}\n请检查摄像头权限、网络连接或刷新页面。`);
|
||
updateGameStatus('initial');
|
||
}
|
||
}
|
||
|
||
// ==========================================================
|
||
// 摄像头和姿态检测相关
|
||
// ==========================================================
|
||
|
||
async function setupCamera() {
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({
|
||
video: {
|
||
width: { ideal: videoElement.width }, // 使用videoFeed的width/height
|
||
height: { ideal: videoElement.height },
|
||
facingMode: 'user'
|
||
}
|
||
});
|
||
videoElement.srcObject = stream;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
videoElement.onloadedmetadata = () => {
|
||
videoElement.play().then(() => {
|
||
// 确保 poseCanvas 和 videoFeed 尺寸匹配
|
||
poseCanvas.width = videoElement.videoWidth;
|
||
poseCanvas.height = videoElement.videoHeight;
|
||
resolve();
|
||
}).catch(reject);
|
||
};
|
||
setTimeout(() => reject(new Error('摄像头元数据加载或播放超时')), 10000);
|
||
});
|
||
} catch (error) {
|
||
if (error.name === 'NotAllowedError') {
|
||
throw new Error('用户拒绝了摄像头权限。');
|
||
} else if (error.name === 'NotFoundError') {
|
||
throw new Error('未找到摄像头设备。');
|
||
} else {
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function startPoseDetectionLoop() {
|
||
if (!isPoseDetectionReady) return;
|
||
|
||
async function detectAndPredict() {
|
||
try {
|
||
const poses = await detector.estimatePoses(videoElement, { flipHorizontal: true }); // 镜像摄像头
|
||
poseCtx.clearRect(0, 0, poseCanvas.width, poseCanvas.height);
|
||
|
||
if (poses && poses.length > 0) {
|
||
drawPose(poses[0]); // 绘制检测到的姿态
|
||
|
||
// 仅在游戏进行中且模型已加载时才预测并控制贪吃蛇
|
||
if (isModelLoaded && gameStatus === 'playing') {
|
||
const poseTensor = flattenPose(poses[0]);
|
||
if (classifier.getNumClasses() > 0) {
|
||
const prediction = await classifier.predictClass(poseTensor);
|
||
poseTensor.dispose(); // 释放张量内存
|
||
|
||
const predictedClassId = prediction.label;
|
||
const confidence = prediction.confidences[predictedClassId] || 0; // 确保有默认值
|
||
currentConfidence = (confidence * 100).toFixed(1);
|
||
|
||
// 设定一个置信度阈值,例如 70%
|
||
const MIN_PREDICT_CONFIDENCE = 70;
|
||
if (confidence * 100 > MIN_PREDICT_CONFIDENCE) {
|
||
currentDetectedClassId = predictedClassId;
|
||
const gameDirection = gestureClassToGameDirection[predictedClassId];
|
||
if (gameDirection) {
|
||
changeSnakeDirection(gameDirection); // 控制贪吃蛇移动
|
||
controlCommandDisplay.textContent = gameDirection;
|
||
} else {
|
||
controlCommandDisplay.textContent = '未知方向';
|
||
}
|
||
} else {
|
||
currentDetectedClassId = null; // 置信度不足,视为未识别
|
||
currentConfidence = 0;
|
||
controlCommandDisplay.textContent = '静止 (置信度不足)';
|
||
}
|
||
} else {
|
||
poseTensor.dispose(); // 确保张量被释放
|
||
currentDetectedClassId = null;
|
||
currentConfidence = 0;
|
||
controlCommandDisplay.textContent = '静止 (模型无数据)';
|
||
}
|
||
|
||
} else { // 如果游戏未进行或模型未加载,则不进行预测控制
|
||
currentDetectedClassId = null;
|
||
currentConfidence = 0;
|
||
controlCommandDisplay.textContent = '静止';
|
||
}
|
||
} else {
|
||
currentDetectedClassId = null;
|
||
currentConfidence = 0;
|
||
currentGestureDisplay.textContent = '未检测到姿态';
|
||
controlCommandDisplay.textContent = '静止';
|
||
}
|
||
|
||
updateGameUI(); // 更新显示
|
||
} catch (error) {
|
||
console.error('姿态检测或预测出错:', error);
|
||
statusDisplay.textContent = `检测错误: ${error.message}`;
|
||
// 如果错误持续发生,考虑停止循环或提供更多反馈
|
||
} finally {
|
||
animationFrameId = requestAnimationFrame(detectAndPredict);
|
||
}
|
||
}
|
||
animationFrameId = requestAnimationFrame(detectAndPredict); // 启动循环
|
||
}
|
||
|
||
// 展平姿态关键点并归一化
|
||
function flattenPose(pose) {
|
||
// 将所有关键点的 (x, y) 坐标展平为一个一维数组
|
||
// 并进行归一化 (除以视频的宽度和高度),使得特征独立于图像大小
|
||
const keypoints = pose.keypoints.map(p => [p.x / videoElement.videoWidth, p.y / videoElement.videoHeight]).flat();
|
||
return tf.tensor(keypoints); // 转换为 TensorFlow 张量
|
||
}
|
||
|
||
// 绘制 MoveNet 姿态骨骼图
|
||
function drawPose(pose) {
|
||
if (pose.keypoints) {
|
||
// 绘制关键点
|
||
for (const keypoint of pose.keypoints) {
|
||
if (keypoint.score > 0.3) { // 仅绘制置信度高的关键点
|
||
poseCtx.beginPath();
|
||
poseCtx.arc(keypoint.x, keypoint.y, 5, 0, 2 * Math.PI);
|
||
poseCtx.fillStyle = '#00f2fe'; // 浅蓝色
|
||
poseCtx.fill();
|
||
}
|
||
}
|
||
// 绘制骨骼连接线
|
||
const adjacentPairs = poseDetection.util.getAdjacentPairs(poseDetection.SupportedModels.MoveNet);
|
||
adjacentPairs.forEach(([i, j]) => {
|
||
const kp1 = pose.keypoints[i];
|
||
const kp2 = pose.keypoints[j];
|
||
if (kp1.score > 0.3 && kp2.score > 0.3) { // 仅连接置信度高的关键点
|
||
poseCtx.beginPath();
|
||
poseCtx.moveTo(kp1.x, kp1.y);
|
||
poseCtx.lineTo(kp2.x, kp2.y);
|
||
poseCtx.strokeStyle = '#a8c0ff'; // 紫蓝色
|
||
poseCtx.lineWidth = 2;
|
||
poseCtx.stroke();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// ==========================================================
|
||
// 模型导入导出 (改造为支持文件和 CDN URL)
|
||
// ==========================================================
|
||
|
||
/**
|
||
* 加载 KNN 模型数据,支持从文件或 CDN URL 加载。
|
||
* @param {File} [file] - 可选,用户选择的 KNN 模型 JSON 文件。
|
||
* @param {string} [cdnUrl] - 可选,KNN 模型 JSON 文件的 CDN URL。
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async function loadKNNModelData(file = null, cdnUrl = null) {
|
||
|
||
statusDisplay.textContent = '正在加载模型数据...';
|
||
startBtn.disabled = true; // 加载中禁用开始按钮
|
||
importModelBtn.disabled = true; // 加载中禁用导入按钮
|
||
|
||
try {
|
||
let loadedModelData;
|
||
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
const fileReadPromise = new Promise((resolve, reject) => {
|
||
reader.onload = e => resolve(JSON.parse(e.target.result));
|
||
reader.onerror = error => reject(new Error('文件读取失败。'));
|
||
reader.readAsText(file);
|
||
});
|
||
loadedModelData = await fileReadPromise;
|
||
} else if (cdnUrl) {
|
||
const response = await fetch(cdnUrl);
|
||
if (!response.ok) {
|
||
throw new Error(`无法从 ${cdnUrl} 加载模型数据: ${response.statusText}`);
|
||
}
|
||
loadedModelData = await response.json();
|
||
} else {
|
||
throw new Error('未提供模型文件或 CDN URL。');
|
||
}
|
||
|
||
if (!loadedModelData || !loadedModelData.dataset || !loadedModelData.classMap) {
|
||
throw new Error('模型数据结构不正确 (缺少 classMap 或 dataset)。');
|
||
}
|
||
|
||
classifier.clearAllClasses();
|
||
|
||
const dataset = {};
|
||
let totalExamples = 0;
|
||
|
||
for (const classId in loadedModelData.dataset) {
|
||
const classData = loadedModelData.dataset[classId];
|
||
if (classData && classData.length > 0) {
|
||
if (classData.some(sample => !Array.isArray(sample) || sample.some(val => typeof val !== 'number'))) {
|
||
throw new Error(`类别 ${classId} 包含无效的样本数据 (不是数字数组)。`);
|
||
}
|
||
const tensors = classData.map(data => tf.tensor1d(data));
|
||
const stacked = tf.stack(tensors);
|
||
dataset[classId] = stacked;
|
||
totalExamples += classData.length;
|
||
tensors.forEach(t => t.dispose());
|
||
} else {
|
||
console.warn(`类别 ${classId} 没有样本数据。`);
|
||
}
|
||
}
|
||
|
||
classifier.setClassifierDataset(dataset);
|
||
importedClassNames = loadedModelData.classMap; // 更新类别名称映射
|
||
|
||
console.log(`模型加载成功!共加载 ${totalExamples} 个样本。`);
|
||
console.log('类别映射 (导入):', importedClassNames);
|
||
|
||
// 更新页面上的映射显示,如果需要的话。
|
||
// 确保 instructions 中的分类名称与 importedClassNames 保持一致
|
||
const instructionListItems = document.querySelectorAll('.instructions li');
|
||
instructionListItems.forEach((item, index) => {
|
||
const classId = String(index); // 假定指令顺序与分类ID一致
|
||
const classNameFromModel = importedClassNames[classId] || '未定义';
|
||
let controlDesc = '';
|
||
switch(classId) {
|
||
case '0': controlDesc = '双手举过头顶'; break;
|
||
case '1': controlDesc = '双手放在身体两侧或下垂'; break;
|
||
case '2': controlDesc = '左手平举'; break;
|
||
case '3': controlDesc = '右手平举'; break;
|
||
default: controlDesc = '未知动作';
|
||
}
|
||
item.querySelector('span').innerHTML = `${controlDesc} (<strong>分类${classId}</strong>: ${classNameFromModel})`;
|
||
// 如果你的 instructions 区域已经有预设的文字,并且你只希望更新 Classification ID,
|
||
// 可以根据 HTML 结构微调这里的更新逻辑。
|
||
});
|
||
|
||
|
||
statusDisplay.textContent = '姿态模型导入成功!可以开始游戏了。';
|
||
isModelLoaded = true; // 设置模型已加载状态
|
||
startBtn.disabled = false; // 启用开始游戏按钮
|
||
importModelBtn.disabled = true; // 导入按钮禁用
|
||
updateGameStatus('ready');
|
||
|
||
} catch (error) {
|
||
console.error('模型加载失败:', error);
|
||
statusDisplay.textContent = `模型加载失败: ${error.message}`;
|
||
isModelLoaded = false;
|
||
startBtn.disabled = true; // 失败后保持禁用
|
||
importModelBtn.disabled = false; // 失败后可再次导入
|
||
updateGameStatus('initial');
|
||
throw error; // 重新抛出错误,以便调用者能捕获
|
||
} finally {
|
||
fileImporter.value = ''; // 清空文件输入
|
||
}
|
||
}
|
||
|
||
|
||
// 文件选择事件处理器 (现在它调用通用加载函数)
|
||
async function handleModelImport(event) {
|
||
const file = event.target.files[0];
|
||
if (!file) {
|
||
statusDisplay.textContent = '未选择文件。';
|
||
return;
|
||
}
|
||
|
||
statusDisplay.textContent = '正在从本地文件导入模型...';
|
||
startBtn.disabled = true; // 导入中禁用开始按钮
|
||
importModelBtn.disabled = true; // 导入中禁用导入按钮
|
||
|
||
try {
|
||
await loadKNNModelData(file, null); // 传入文件对象,cdnUrl 为 null
|
||
} catch (error) {
|
||
// 错误信息已在 loadKNNModelData 内部处理并设置状态
|
||
alert(error.message); // 弹出错误提示
|
||
}
|
||
}
|
||
|
||
// ===================================
|
||
// 贪吃蛇游戏逻辑
|
||
// ===================================
|
||
|
||
function startGame() {
|
||
if (!isPoseDetectionReady) {
|
||
alert('姿态检测系统未准备就绪,请稍候。');
|
||
return;
|
||
}
|
||
if (!isModelLoaded) {
|
||
alert('请先导入姿态模型!');
|
||
return;
|
||
}
|
||
if (!classifier || classifier.getNumClasses() === 0) {
|
||
alert('已导入的模型中没有训练数据,请导入一个有效的模型文件。');
|
||
return;
|
||
}
|
||
|
||
gameOverOverlay.classList.remove('active');
|
||
updateGameStatus('playing');
|
||
resetGameState();
|
||
generateFood();
|
||
gameLoopId = setInterval(drawGame, gameSpeed); // 开始游戏循环
|
||
startBtn.disabled = true;
|
||
importModelBtn.disabled = true;
|
||
}
|
||
|
||
function resetGame() {
|
||
gameOverOverlay.classList.remove('active');
|
||
updateGameStatus('ready');
|
||
resetGameState(); // 重置游戏状态
|
||
snakeDirection = 'RIGHT'; // 重新开始时默认为向右
|
||
startBtn.disabled = false;
|
||
importModelBtn.disabled = true; // 模型加载后禁用导入按钮
|
||
updateGameUI();
|
||
}
|
||
|
||
function resetGameState() {
|
||
clearInterval(gameLoopId);
|
||
snake = [{ x: 10, y: 10 }]; // 蛇回到起始位置,长度为1
|
||
food = {};
|
||
score = 0;
|
||
updateScoreDisplay();
|
||
}
|
||
|
||
function generateFood() {
|
||
// 计算可用的网格单元数量
|
||
const numCols = CANVAS_WIDTH / GRID_SIZE;
|
||
const numRows = CANVAS_HEIGHT / GRID_SIZE;
|
||
|
||
// 循环直到找到一个不在蛇身体上的食物位置
|
||
let newFoodX, newFoodY;
|
||
do {
|
||
newFoodX = Math.floor(Math.random() * numCols);
|
||
newFoodY = Math.floor(Math.random() * numRows);
|
||
} while (isFoodOnSnake(newFoodX, newFoodY)); // 检查是否与蛇重叠
|
||
|
||
food = {
|
||
x: newFoodX,
|
||
y: newFoodY
|
||
};
|
||
}
|
||
|
||
// 辅助函数:检查给定坐标是否在蛇的身体上
|
||
function isFoodOnSnake(x, y) {
|
||
for (let i = 0; i < snake.length; i++) {
|
||
if (x === snake[i].x && y === snake[i].y) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function drawGame() {
|
||
// 清空 snakeCanvas
|
||
snakeCtx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
|
||
|
||
// === 修改点 2:绘制网格线 (增强视觉效果) ===
|
||
snakeCtx.strokeStyle = '#444'; // 深灰色网格线
|
||
snakeCtx.lineWidth = 0.5; // 网格线细一点
|
||
for (let x = 0; x < CANVAS_WIDTH; x += GRID_SIZE) {
|
||
snakeCtx.beginPath();
|
||
snakeCtx.moveTo(x, 0);
|
||
snakeCtx.lineTo(x, CANVAS_HEIGHT);
|
||
snakeCtx.stroke();
|
||
}
|
||
for (let y = 0; y < CANVAS_HEIGHT; y += GRID_SIZE) {
|
||
snakeCtx.beginPath();
|
||
snakeCtx.moveTo(0, y);
|
||
snakeCtx.lineTo(CANVAS_WIDTH, y);
|
||
snakeCtx.stroke();
|
||
}
|
||
|
||
// 绘制食物
|
||
snakeCtx.fillStyle = '#ff4444'; // 更鲜艳的红色
|
||
snakeCtx.fillRect(food.x * GRID_SIZE, food.y * GRID_SIZE, GRID_SIZE, GRID_SIZE);
|
||
snakeCtx.strokeStyle = '#ff0000'; // 食物边框
|
||
snakeCtx.lineWidth = 2; // 微粗边框
|
||
snakeCtx.strokeRect(food.x * GRID_SIZE, food.y * GRID_SIZE, GRID_SIZE, GRID_SIZE);
|
||
|
||
|
||
// 绘制蛇
|
||
for (let i = 0; i < snake.length; i++) {
|
||
snakeCtx.fillStyle = (i === 0) ? '#33ff33' : '#00cc00'; // 蛇头亮绿色,身体普通绿色
|
||
snakeCtx.fillRect(snake[i].x * GRID_SIZE, snake[i].y * GRID_SIZE, GRID_SIZE, GRID_SIZE);
|
||
|
||
// === 修改点 2:增强蛇身的方格显示效果:添加描边 ===
|
||
snakeCtx.strokeStyle = '#006600'; // 深绿色描边
|
||
snakeCtx.lineWidth = 1.5; // 描边宽度
|
||
snakeCtx.strokeRect(snake[i].x * GRID_SIZE, snake[i].y * GRID_SIZE, GRID_SIZE, GRID_SIZE);
|
||
}
|
||
|
||
// 移动蛇头
|
||
let headX = snake[0].x;
|
||
let headY = snake[0].y;
|
||
|
||
switch (snakeDirection) {
|
||
case 'LEFT': headX--; break;
|
||
case 'UP': headY--; break;
|
||
case 'RIGHT': headX++; break;
|
||
case 'DOWN': headY++; break;
|
||
}
|
||
|
||
// 碰撞检测
|
||
// 撞墙
|
||
if (headX < 0 || headX >= CANVAS_WIDTH / GRID_SIZE ||
|
||
headY < 0 || headY >= CANVAS_HEIGHT / GRID_SIZE) {
|
||
endGame();
|
||
return;
|
||
}
|
||
// 撞自己
|
||
for (let i = 1; i < snake.length; i++) {
|
||
if (headX === snake[i].x && headY === snake[i].y) {
|
||
endGame();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 吃到食物
|
||
if (headX === food.x && headY === food.y) {
|
||
score++;
|
||
updateScoreDisplay();
|
||
generateFood();
|
||
// 暂时不增加速度,保持稳定
|
||
} else {
|
||
// 如果没吃到食物,移除蛇尾,实现移动效果
|
||
snake.pop();
|
||
}
|
||
|
||
// 添加新蛇头
|
||
snake.unshift({ x: headX, y: headY });
|
||
}
|
||
|
||
function changeSnakeDirection(newDirection) {
|
||
// 防止蛇立即反向掉头
|
||
if (newDirection === 'LEFT' && snakeDirection !== 'RIGHT') snakeDirection = 'LEFT';
|
||
else if (newDirection === 'UP' && snakeDirection !== 'DOWN') snakeDirection = 'UP';
|
||
else if (newDirection === 'RIGHT' && snakeDirection !== 'LEFT') snakeDirection = 'RIGHT';
|
||
else if (newDirection === 'DOWN' && snakeDirection !== 'UP') snakeDirection = 'DOWN';
|
||
// console.log("Snake direction changed to:", snakeDirection); // 调试用
|
||
}
|
||
|
||
function endGame() {
|
||
updateGameStatus('gameOver');
|
||
clearInterval(gameLoopId);
|
||
overlayText.textContent = '游戏结束!';
|
||
finalScoreDisplay.textContent = score;
|
||
gameOverOverlay.classList.add('active');
|
||
startBtn.disabled = true; // 游戏结束后开始按钮不可用,直到点击重玩
|
||
importModelBtn.disabled = true; // 保持禁用
|
||
}
|
||
|
||
|
||
// ==========================================================
|
||
// UI 更新
|
||
// ==========================================================
|
||
|
||
function updateGameStatus(status) {
|
||
gameStatus = status;
|
||
// console.log("Game status updated to:", gameStatus); // 调试用
|
||
}
|
||
|
||
function updateScoreDisplay() {
|
||
scoreDisplay.textContent = score;
|
||
}
|
||
|
||
function updateGameUI() {
|
||
// 更新姿态识别显示
|
||
if (currentDetectedClassId !== null) {
|
||
const className = importedClassNames[currentDetectedClassId] || `未知类别 ${currentDetectedClassId}`;
|
||
currentGestureDisplay.textContent = `${className}`;
|
||
gestureConfidenceDisplay.textContent = `置信度: ${currentConfidence}%`;
|
||
gestureConfidenceDisplay.style.color = currentConfidence > 70 ? '#00ff00' : '#ffaa00';
|
||
} else {
|
||
currentGestureDisplay.textContent = '未识别';
|
||
gestureConfidenceDisplay.textContent = '';
|
||
}
|
||
|
||
// 根据游戏状态更新按钮和文本
|
||
if (gameStatus === 'initial') {
|
||
statusDisplay.textContent = '等待模型导入...';
|
||
startBtn.disabled = true;
|
||
importModelBtn.disabled = false;
|
||
restartBtn.disabled = true;
|
||
} else if (gameStatus === 'loading') {
|
||
// 状态文本已经在加载函数中设置
|
||
startBtn.disabled = true;
|
||
importModelBtn.disabled = true;
|
||
restartBtn.disabled = true;
|
||
} else if (gameStatus === 'ready') {
|
||
statusDisplay.textContent = '模型已加载,点击“开始游戏”或按“Enter”键。';
|
||
startBtn.disabled = false;
|
||
importModelBtn.disabled = true;
|
||
restartBtn.disabled = true; // 只有在gameOver后才启用重新开始
|
||
} else if (gameStatus === 'playing') {
|
||
statusDisplay.textContent = '游戏进行中...';
|
||
startBtn.disabled = true;
|
||
importModelBtn.disabled = true;
|
||
restartBtn.disabled = true;
|
||
} else if (gameStatus === 'gameOver') {
|
||
statusDisplay.textContent = '游戏结束!点击“重新开始”或按“Enter”键。';
|
||
startBtn.disabled = true;
|
||
importModelBtn.disabled = true;
|
||
restartBtn.disabled = false; // 游戏结束时启用重新开始
|
||
}
|
||
}
|
||
|
||
// --- 应用启动 ---
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
console.log('DOM content loaded. Initializing hand detection...');
|
||
initApp().catch(error => {
|
||
console.error('App initialization failed:', error);
|
||
// 错误信息已在 initApp 内部处理并alert
|
||
});
|
||
|
||
// 绑定导入模型事件
|
||
fileImporter.addEventListener('change', handleModelImport);
|
||
importModelBtn.addEventListener('click', () => {
|
||
fileImporter.click(); // 点击按钮触发文件输入
|
||
});
|
||
});
|
||
|
||
// 页面关闭时清理资源 (可选,但推荐)
|
||
window.onbeforeunload = () => {
|
||
if (animationFrameId) {
|
||
cancelAnimationFrame(animationFrameId);
|
||
}
|
||
if (gameLoopId) {
|
||
clearInterval(gameLoopId);
|
||
}
|
||
if (detector) {
|
||
// MediaPipe runtime 通常会管理其自己的 WebGL 资源,
|
||
// 明确调用 dispose() 可能会导致后续异常,如果 MediaPipe 内部未预期再次使用。
|
||
// 如果遇到问题,可以注释掉这行。
|
||
// detector.dispose();
|
||
}
|
||
if (classifier) {
|
||
classifier.clearAllClasses();
|
||
}
|
||
tf.disposeAll(); // 释放所有TensorFlow.js张量,防止内存泄露
|
||
console.log('Resources cleaned up.');
|
||
};
|
||
</script>
|
||
</body>
|
||
</html>
|