[CF]完成姿态分类
This commit is contained in:
parent
507e4bf9c0
commit
6b9c260767
101
姿态分类/README.md
Normal file
101
姿态分类/README.md
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# Web-Based Real-Time Pose Classifier
|
||||||
|
|
||||||
|
这是一个功能强大的、完全在浏览器中运行的实时姿态识别与训练工具。它利用 TensorFlow.js 和姿态检测模型 (MoveNet),允许用户通过摄像头实时定义、训练和识别自定义的身体姿态,而无需任何服务器端处理。
|
||||||
|
|
||||||
|
## ✨ 功能亮点
|
||||||
|
|
||||||
|
- **实时姿态检测**: 使用高效的 **MoveNet** 模型在视频流中实时捕捉人体的17个关键点。
|
||||||
|
- **在线模型训练**: 通过 **K-近邻 (KNN)** 算法,用户可以直接在浏览器中为不同的姿态(例如“站立”、“举手”、“深蹲”)采集样本并训练分类模型。
|
||||||
|
- **实时姿态预测**: 训练完成后,应用可以立即对摄像头前的姿态进行分类,并显示预测结果及置信度。
|
||||||
|
- **精准视觉反馈**: 实时在用户画面上绘制姿态骨架,确保关键点被正确识别,实现了视频与Canvas的完美对齐与重叠。
|
||||||
|
- **模型持久化**:
|
||||||
|
- **导出模型**: 用户可以将训练好的模型一键导出为 `.json` 文件,方便保存和备份。
|
||||||
|
- **导入模型**: 用户可以随时将之前导出的模型文件导入应用,瞬间恢复训练状态,无需重新采集样本。
|
||||||
|
- **纯前端实现**: 所有计算(包括模型推理和训练)都在客户端浏览器中完成,保护用户隐私且无需服务器成本。
|
||||||
|
|
||||||
|
## 🚀 如何使用
|
||||||
|
|
||||||
|
1. **打开页面**: 用支持现代JavaScript的浏览器(如Chrome, Firefox)打开 `index.html`。
|
||||||
|
2. **授予权限**: 浏览器会请求摄像头访问权限,请点击“允许”。应用会自动加载模型并启动摄像头。
|
||||||
|
3. **采集样本 (训练)**:
|
||||||
|
- 面对摄像头,摆出你想定义的第一个姿态(例如“姿态A”)。
|
||||||
|
- 多次点击“采集样本”按钮,建议从不同角度采集至少20个样本以保证准确性。
|
||||||
|
- 对其他姿态(“姿态B”、“姿态C”)重复此过程。样本数量会实时更新。
|
||||||
|
4. **模型管理 (可选)**:
|
||||||
|
- **导出**: 训练完成后,点击“导出模型”按钮,会将当前训练好的模型保存为一个`pose-knn-model.json`文件。
|
||||||
|
- **导入**: 在新的会话或新设备上,点击“导入模型”,选择之前导出的`.json`文件,即可恢复模型。
|
||||||
|
5. **开始预测 (推理)**:
|
||||||
|
- 点击“开始预测”按钮。
|
||||||
|
- 应用将开始实时分析你的姿态,并在下方显示预测结果。
|
||||||
|
- 再次点击“停止预测”可暂停。
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── index.html # 应用主页面,包含所有UI元素
|
||||||
|
├── style.css # 页面样式文件
|
||||||
|
└── script.js # 核心逻辑脚本,包含所有功能实现
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 🤖 技术栈
|
||||||
|
|
||||||
|
- **TensorFlow.js**: 核心机器学习库,用于在浏览器中运行模型和处理张量(Tensors)。
|
||||||
|
- **Pose Detection API (`@tensorflow-models/pose-detection`)**: 用于运行姿态检测模型。
|
||||||
|
- **MoveNet.SinglePose.Lightning**: 我们选择的具体模型,它在速度和精度之间取得了极佳的平衡。
|
||||||
|
- **KNN Classifier (`@tensorflow-models/knn-classifier`)**: 一个基于TensorFlow.js实现的K-近邻分类器,用于在线学习。
|
||||||
|
- **原生 HTML5 / CSS3 / JavaScript (ES6+)**: 构建用户界面和实现应用逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📜 `script.js` 核心函数解析
|
||||||
|
|
||||||
|
|
||||||
|
### 主应用逻辑
|
||||||
|
|
||||||
|
`async function init()`
|
||||||
|
- **职责**: 应用的入口函数。负责初始化KNN分类器、加载MoveNet姿态检测模型、设置摄像头、绑定所有事件监听器,并启动主循环。
|
||||||
|
|
||||||
|
`async function setupCamera()`
|
||||||
|
- **职责**: 请求并获取用户摄像头的视频流。成功后,将视频流赋给`<video>`元素,并设置`<canvas>`的绘图分辨率与视频的原始分辨率(`videoWidth`/`videoHeight`)完全一致,这是解决坐标偏移的关键。
|
||||||
|
|
||||||
|
`function setupEventListeners()`
|
||||||
|
- **职责**: 为所有UI元素(如“采集样本”、“开始预测”、“导入/导出模型”按钮)绑定`click`或`change`事件,将用户交互与业务逻辑连接起来。
|
||||||
|
|
||||||
|
`async function mainLoop()`
|
||||||
|
- **职责**: 这是应用的核心循环,通过`requestAnimationFrame`实现。在每一帧中,它会从视频中获取当前姿态,清空并重绘Canvas,如果处于预测状态,则进行姿态分类并更新结果。
|
||||||
|
|
||||||
|
`async function addExample(classId)`
|
||||||
|
- **职责**: 当用户点击“采集样本”时调用。它获取当前帧的姿态,将其转换为用于分类的特征张量,并添加到KNN分类器中,与指定的`classId`关联。
|
||||||
|
|
||||||
|
`function togglePrediction()`
|
||||||
|
- **职责**: 控制预测状态的开关。它会检查模型是否已训练,然后切换`isPredicting`标志位,并更新UI。
|
||||||
|
|
||||||
|
### 模型管理函数
|
||||||
|
|
||||||
|
`function exportModel()`
|
||||||
|
- **职责**: 将训练好的模型导出。它通过`classifier.getClassifierDataset()`获取内部数据,将Tensor对象转换为可序列化的普通数组,最后生成一个JSON字符串并触发浏览器下载。
|
||||||
|
|
||||||
|
`function importModel(event)`
|
||||||
|
- **职责**: 从用户选择的`.json`文件导入模型。它使用`FileReader`读取文件内容,将JSON字符串解析回对象,再将普通数组转换回Tensor对象,最终通过`classifier.setClassifierDataset()`加载到分类器中。
|
||||||
|
|
||||||
|
### 辅助与UI更新函数
|
||||||
|
|
||||||
|
`function flattenPose(pose)`
|
||||||
|
- **职责**: 数据预处理函数。它提取姿态中的所有关键点坐标,进行归一化(除以视频宽高),并“扁平化”为一个一维的Tensor。这是KNN分类器需要的输入格式。
|
||||||
|
|
||||||
|
`function drawPose(pose)`
|
||||||
|
- **职责**: 视觉反馈函数。它在Canvas上绘制姿态的关键点(圆点)和骨骼连接线,让用户能直观地看到模型识别出的姿态。
|
||||||
|
|
||||||
|
`function updateSampleCounts()`
|
||||||
|
- **职责**: 更新界面上显示的每个姿态类别的样本数量。
|
||||||
|
|
||||||
|
`function updatePredictionUI()`
|
||||||
|
- **职责**: 根据当前是否正在预测,动态地启用/禁用相关按钮(如“采集样本”按钮在预测时应被禁用),并更新按钮文本。
|
||||||
|
|
||||||
|
`function enableControls()`
|
||||||
|
- **职责**: 在应用成功初始化后,启用所有交互按钮。
|
||||||
|
|
||||||
|
`function cleanup()`
|
||||||
|
- **职责**: 在页面关闭或刷新前释放资源,主要是清理TensorFlow.js模型和分类器占用的内存,防止内存泄漏。
|
75
姿态分类/index.html
Normal file
75
姿态分类/index.html
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>动态姿态分类器</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>动态姿态分类器</h1>
|
||||||
|
<p>一个使用 TensorFlow.js 和 MoveNet 实现的实时姿态训练与推理工具</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main-container">
|
||||||
|
<div id="video-wrapper">
|
||||||
|
<div id="status">正在加载模型,请稍候...</div>
|
||||||
|
<div id="video-container">
|
||||||
|
<video id="video" width="640" height="480" autoplay muted playsinline></video>
|
||||||
|
<canvas id="canvas" width="640" height="480"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="controls-panel">
|
||||||
|
<h2>控制面板</h2>
|
||||||
|
|
||||||
|
<!-- ==================== 训练区域重大更新 ==================== -->
|
||||||
|
<div class="control-section" id="training-section">
|
||||||
|
<h3>第一步: 训练模型</h3>
|
||||||
|
<p>点击下方按钮添加姿态分类,为每个姿态采集足够样本。</p>
|
||||||
|
|
||||||
|
<!-- 📌 新增: 这是动态添加的姿态类别的容器 -->
|
||||||
|
<div id="pose-classes-container">
|
||||||
|
<!-- JavaScript 将在此处动态生成类别UI -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 📌 新增: 添加新类别的按钮 -->
|
||||||
|
<div class="add-class-wrapper">
|
||||||
|
<button id="btn-add-class" class="btn-add-class" disabled>+ 增加分类</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- ========================================================= -->
|
||||||
|
|
||||||
|
<div class="control-section">
|
||||||
|
<h3>模型管理</h3>
|
||||||
|
<div class="model-controls">
|
||||||
|
<button id="btn-export" class="btn-sample" disabled>导出模型</button>
|
||||||
|
<button id="btn-import" class="btn-sample" disabled>导入模型</button>
|
||||||
|
<!-- 这个input是隐藏的,由上面的“导入模型”按钮触发 -->
|
||||||
|
<input type="file" id="file-importer" accept=".json" style="display: none;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="control-section" id="inference-section">
|
||||||
|
<h3>第二步: 开始推理</h3>
|
||||||
|
<p>训练完成后,点击下方按钮开始实时预测。</p>
|
||||||
|
<button id="btn-predict" class="btn-predict" disabled>开始预测</button>
|
||||||
|
<div id="result-container">
|
||||||
|
<strong>预测结果:</strong>
|
||||||
|
<div id="result-text">尚未开始</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 引入所有依赖库 -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.20.0/dist/tf.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/pose-detection@2.1.3/dist/pose-detection.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/knn-classifier@1.2.2/dist/knn-classifier.min.js"></script>
|
||||||
|
|
||||||
|
<!-- 引入我们自己的逻辑脚本 -->
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
406
姿态分类/script.js
Normal file
406
姿态分类/script.js
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
/**
|
||||||
|
* =============================================================================
|
||||||
|
* 动态版 - 姿态识别与模型管理脚本 (v2.0)
|
||||||
|
* =============================================================================
|
||||||
|
* 功能列表:
|
||||||
|
* - 实时姿态检测 (MoveNet)
|
||||||
|
* - KNN 分类器训练
|
||||||
|
* - 实时姿态预测
|
||||||
|
* - 坐标完美对齐 (Canvas与Video重叠)
|
||||||
|
* - ✅ 动态添加/删除/重命名姿态类别
|
||||||
|
* - ✅ 模型导出为包含类别信息的 JSON 文件
|
||||||
|
* - ✅ 从 JSON 文件导入模型并恢复类别状态
|
||||||
|
* =============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// --- 全局变量和常量 ---
|
||||||
|
const videoElement = document.getElementById('video');
|
||||||
|
const canvasElement = document.getElementById('canvas');
|
||||||
|
const canvasCtx = canvasElement.getContext('2d');
|
||||||
|
const statusElement = document.getElementById('status');
|
||||||
|
const resultElement = document.getElementById('result-text');
|
||||||
|
|
||||||
|
// UI元素
|
||||||
|
const poseClassesContainer = document.getElementById('pose-classes-container');
|
||||||
|
const addClassButton = document.getElementById('btn-add-class');
|
||||||
|
const predictButton = document.getElementById('btn-predict');
|
||||||
|
const exportButton = document.getElementById('btn-export');
|
||||||
|
const importButton = document.getElementById('btn-import');
|
||||||
|
const fileImporter = document.getElementById('file-importer');
|
||||||
|
|
||||||
|
let detector, classifier, animationFrameId;
|
||||||
|
let isPredicting = false;
|
||||||
|
|
||||||
|
// 📌 核心状态管理: 使用一个对象来管理所有动态状态
|
||||||
|
const appState = {
|
||||||
|
classMap: {}, // 存储 classId -> className 的映射, e.g., {0: '姿态 A', 1: '姿态 B'}
|
||||||
|
nextClassId: 0 // 用于生成唯一的 classId
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 主应用逻辑 ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化应用,加载模型并设置摄像头
|
||||||
|
*/
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
classifier = knnClassifier.create();
|
||||||
|
const detectorConfig = { modelType: poseDetection.movenet.modelType.SINGLEPOSE_LIGHTNING };
|
||||||
|
detector = await poseDetection.createDetector(poseDetection.SupportedModels.MoveNet, detectorConfig);
|
||||||
|
|
||||||
|
await setupCamera();
|
||||||
|
setupEventListeners();
|
||||||
|
mainLoop();
|
||||||
|
|
||||||
|
statusElement.innerText = "模型和摄像头已就绪!";
|
||||||
|
enableControls();
|
||||||
|
addNewClass(); // 默认创建第一个类别
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("初始化失败:", error);
|
||||||
|
statusElement.innerText = "初始化失败,请检查摄像头权限或刷新。";
|
||||||
|
statusElement.style.backgroundColor = '#fce8e6';
|
||||||
|
statusElement.style.color = '#d93025';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置和启动用户摄像头
|
||||||
|
*/
|
||||||
|
async function setupCamera() {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||||
|
videoElement.srcObject = stream;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
videoElement.onloadedmetadata = () => {
|
||||||
|
videoElement.play();
|
||||||
|
canvasElement.width = videoElement.videoWidth;
|
||||||
|
canvasElement.height = videoElement.videoHeight;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为所有交互式元素绑定事件监听器
|
||||||
|
*/
|
||||||
|
function setupEventListeners() {
|
||||||
|
addClassButton.addEventListener('click', addNewClass);
|
||||||
|
predictButton.addEventListener('click', togglePrediction);
|
||||||
|
exportButton.addEventListener('click', exportModel);
|
||||||
|
importButton.addEventListener('click', () => fileImporter.click());
|
||||||
|
fileImporter.addEventListener('change', importModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 动态类别管理 ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态创建一个新类别的UI元素并添加到页面
|
||||||
|
* @param {number} classId - 类别的唯一ID
|
||||||
|
* @param {string} className - 类别的名称
|
||||||
|
*/
|
||||||
|
function createClassUI(classId, className) {
|
||||||
|
const poseClassDiv = document.createElement('div');
|
||||||
|
poseClassDiv.className = 'pose-class';
|
||||||
|
poseClassDiv.dataset.classId = classId;
|
||||||
|
|
||||||
|
poseClassDiv.innerHTML = `
|
||||||
|
<div class="class-info">
|
||||||
|
<input type="text" class="class-name-input" value="${className}" data-class-id="${classId}">
|
||||||
|
<span class="sample-count">(0 样本)</span>
|
||||||
|
</div>
|
||||||
|
<div class="class-actions">
|
||||||
|
<button class="btn-sample" data-class-id="${classId}">采集样本</button>
|
||||||
|
<button class="btn-delete-class" title="删除类别" data-class-id="${classId}">×</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
poseClassesContainer.appendChild(poseClassDiv);
|
||||||
|
|
||||||
|
// 为新创建的元素绑定事件
|
||||||
|
const nameInput = poseClassDiv.querySelector('.class-name-input');
|
||||||
|
nameInput.addEventListener('change', (e) => {
|
||||||
|
appState.classMap[classId] = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sampleButton = poseClassDiv.querySelector('.btn-sample');
|
||||||
|
sampleButton.addEventListener('click', () => addExample(classId));
|
||||||
|
|
||||||
|
if (isPredicting) sampleButton.disabled = true; // 如果在预测中,禁用新按钮
|
||||||
|
|
||||||
|
const deleteButton = poseClassDiv.querySelector('.btn-delete-class');
|
||||||
|
deleteButton.addEventListener('click', () => deleteClass(classId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加一个新的姿态类别
|
||||||
|
*/
|
||||||
|
function addNewClass() {
|
||||||
|
const classId = appState.nextClassId;
|
||||||
|
const className = `Class ${classId + 1}`;
|
||||||
|
appState.classMap[classId] = className;
|
||||||
|
appState.nextClassId++;
|
||||||
|
createClassUI(classId, className);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除一个指定的姿态类别
|
||||||
|
* @param {number} classId - 要删除的类别的ID
|
||||||
|
*/
|
||||||
|
function deleteClass(classId) {
|
||||||
|
if (confirm(`确定要删除类别 "${appState.classMap[classId]}" 吗?所有样本都将丢失。`)) {
|
||||||
|
// 从UI中移除
|
||||||
|
const elementToRemove = poseClassesContainer.querySelector(`[data-class-id="${classId}"]`);
|
||||||
|
if (elementToRemove) elementToRemove.remove();
|
||||||
|
|
||||||
|
// 从状态和分类器中移除
|
||||||
|
delete appState.classMap[classId];
|
||||||
|
classifier.clearClass(classId);
|
||||||
|
|
||||||
|
updateSampleCounts();
|
||||||
|
updatePredictionUI(); // 检查是否还有类别可以预测
|
||||||
|
checkExportAbility();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采集一个姿态样本并添加到KNN分类器
|
||||||
|
* @param {number} classId 类别的ID
|
||||||
|
*/
|
||||||
|
async function addExample(classId) {
|
||||||
|
const poses = await detector.estimatePoses(videoElement, { flipHorizontal: true });
|
||||||
|
if (poses && poses.length > 0) {
|
||||||
|
const poseTensor = flattenPose(poses[0]);
|
||||||
|
classifier.addExample(poseTensor, classId);
|
||||||
|
poseTensor.dispose();
|
||||||
|
|
||||||
|
updateSampleCounts();
|
||||||
|
checkExportAbility();
|
||||||
|
} else {
|
||||||
|
console.warn(`为类别 ${appState.classMap[classId]} 采集样本失败,未检测到姿态。`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 模型与预测逻辑 ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始或停止姿态预测
|
||||||
|
*/
|
||||||
|
function togglePrediction() {
|
||||||
|
if (classifier.getNumClasses() === 0) {
|
||||||
|
alert("请先为至少一个姿态采集样本后再开始预测!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isPredicting = !isPredicting;
|
||||||
|
updatePredictionUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用的主循环
|
||||||
|
*/
|
||||||
|
async function mainLoop() {
|
||||||
|
const poses = await detector.estimatePoses(videoElement, { flipHorizontal: true });
|
||||||
|
canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height);
|
||||||
|
|
||||||
|
if (poses && poses.length > 0) {
|
||||||
|
drawPose(poses[0]);
|
||||||
|
if (isPredicting && classifier.getNumClasses() > 0) {
|
||||||
|
const poseTensor = flattenPose(poses[0]);
|
||||||
|
const result = await classifier.predictClass(poseTensor, 3);
|
||||||
|
poseTensor.dispose();
|
||||||
|
|
||||||
|
const confidence = Math.round(result.confidences[result.label] * 100);
|
||||||
|
// 📌 动态获取类别名称
|
||||||
|
const predictedClassName = appState.classMap[result.label] || '未知类别';
|
||||||
|
resultElement.innerText = `姿态: ${predictedClassName} (${confidence}%)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animationFrameId = requestAnimationFrame(mainLoop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 模型管理函数 (已更新以支持动态类别) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出KNN模型为包含类别信息的JSON文件
|
||||||
|
*/
|
||||||
|
function exportModel() {
|
||||||
|
if (classifier.getNumClasses() === 0) {
|
||||||
|
alert('模型中还没有任何样本,无法导出!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataset = classifier.getClassifierDataset();
|
||||||
|
const datasetObj = {};
|
||||||
|
Object.keys(dataset).forEach((key) => {
|
||||||
|
const data = dataset[key];
|
||||||
|
datasetObj[key] = data.arraySync();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 📌 导出格式大更新: 同时保存 classMap 和 dataset
|
||||||
|
const modelData = {
|
||||||
|
classMap: appState.classMap,
|
||||||
|
dataset: datasetObj
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonStr = JSON.stringify(modelData);
|
||||||
|
const blob = new Blob([jsonStr], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `pose-knn-model.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从JSON文件导入KNN模型并恢复类别状态
|
||||||
|
* @param {Event} event
|
||||||
|
*/
|
||||||
|
function importModel(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const modelData = JSON.parse(e.target.result);
|
||||||
|
|
||||||
|
// 📌 导入格式验证
|
||||||
|
if (!modelData.classMap || !modelData.dataset) {
|
||||||
|
throw new Error("无效的模型文件格式。");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 清理现有状态
|
||||||
|
classifier.clearAllClasses();
|
||||||
|
poseClassesContainer.innerHTML = '';
|
||||||
|
appState.classMap = {};
|
||||||
|
|
||||||
|
// 2. 加载新状态
|
||||||
|
appState.classMap = modelData.classMap;
|
||||||
|
const classIds = Object.keys(appState.classMap).map(Number);
|
||||||
|
appState.nextClassId = classIds.length > 0 ? Math.max(...classIds) + 1 : 0;
|
||||||
|
|
||||||
|
// 3. 恢复UI
|
||||||
|
classIds.forEach(id => {
|
||||||
|
createClassUI(id, appState.classMap[id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 加载模型数据
|
||||||
|
const newDataset = {};
|
||||||
|
Object.keys(modelData.dataset).forEach((key) => {
|
||||||
|
newDataset[key] = tf.tensor(modelData.dataset[key]);
|
||||||
|
});
|
||||||
|
classifier.setClassifierDataset(newDataset);
|
||||||
|
|
||||||
|
updateSampleCounts();
|
||||||
|
checkExportAbility();
|
||||||
|
alert('模型导入成功!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("导入模型失败:", error);
|
||||||
|
alert(`导入失败!请确保文件是正确的模型JSON文件。\n错误: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
fileImporter.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- 辅助和UI更新函数 ---
|
||||||
|
|
||||||
|
function flattenPose(pose) {
|
||||||
|
const keypoints = pose.keypoints.map(p => [p.x / videoElement.videoWidth, p.y / videoElement.videoHeight]).flat();
|
||||||
|
return tf.tensor(keypoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPose(pose) {
|
||||||
|
// ... (此函数无需修改, 省略以保持简洁)
|
||||||
|
// 绘制关键点和骨骼...
|
||||||
|
if (pose.keypoints) {
|
||||||
|
// 绘制关键点
|
||||||
|
for (const keypoint of pose.keypoints) {
|
||||||
|
if (keypoint.score > 0.3) {
|
||||||
|
canvasCtx.beginPath();
|
||||||
|
canvasCtx.arc(keypoint.x, keypoint.y, 5, 0, 2 * Math.PI);
|
||||||
|
canvasCtx.fillStyle = '#1a73e8';
|
||||||
|
canvasCtx.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) {
|
||||||
|
canvasCtx.beginPath();
|
||||||
|
canvasCtx.moveTo(kp1.x, kp1.y);
|
||||||
|
canvasCtx.lineTo(kp2.x, kp2.y);
|
||||||
|
canvasCtx.strokeStyle = 'blue';
|
||||||
|
canvasCtx.lineWidth = 2;
|
||||||
|
canvasCtx.stroke();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新所有类别UI上的样本数量
|
||||||
|
*/
|
||||||
|
function updateSampleCounts() {
|
||||||
|
const dataset = classifier.getClassifierDataset();
|
||||||
|
const allClassElements = document.querySelectorAll('.pose-class');
|
||||||
|
allClassElements.forEach(el => {
|
||||||
|
const classId = parseInt(el.dataset.classId, 10);
|
||||||
|
const classInfo = dataset[classId];
|
||||||
|
const count = classInfo ? classInfo.shape[0] : 0;
|
||||||
|
el.querySelector('.sample-count').innerText = `(${count} 样本)`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据状态更新UI
|
||||||
|
*/
|
||||||
|
function updatePredictionUI() {
|
||||||
|
const allActionButtons = document.querySelectorAll('.btn-sample, .btn-delete-class, .btn-add-class, #btn-import');
|
||||||
|
if (isPredicting) {
|
||||||
|
predictButton.innerText = "停止预测";
|
||||||
|
predictButton.classList.add('stop');
|
||||||
|
resultElement.innerText = "正在分析...";
|
||||||
|
allActionButtons.forEach(btn => btn.disabled = true);
|
||||||
|
document.querySelectorAll('.class-name-input').forEach(input => input.disabled = true);
|
||||||
|
checkExportAbility();
|
||||||
|
} else {
|
||||||
|
predictButton.innerText = "开始预测";
|
||||||
|
predictButton.classList.remove('stop');
|
||||||
|
resultElement.innerText = "已停止";
|
||||||
|
allActionButtons.forEach(btn => btn.disabled = false);
|
||||||
|
document.querySelectorAll('.class-name-input').forEach(input => input.disabled = false);
|
||||||
|
checkExportAbility();
|
||||||
|
}
|
||||||
|
// 只有在有类别且有样本时才能预测
|
||||||
|
predictButton.disabled = isPredicting ? false : classifier.getNumClasses() === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableControls() {
|
||||||
|
[predictButton, importButton, exportButton, addClassButton].forEach(btn => btn.disabled = false);
|
||||||
|
checkExportAbility();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查是否可以导出模型并更新按钮状态 */
|
||||||
|
function checkExportAbility() {
|
||||||
|
exportButton.disabled = isPredicting || classifier.getNumClasses() === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (detector) detector.dispose();
|
||||||
|
if (classifier) classifier.clearAllClasses();
|
||||||
|
if (animationFrameId) cancelAnimationFrame(animationFrameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 启动应用 ---
|
||||||
|
window.onbeforeunload = cleanup;
|
||||||
|
init();
|
256
姿态分类/style.css
Normal file
256
姿态分类/style.css
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
/* style.css */
|
||||||
|
:root {
|
||||||
|
--primary-color: #1a73e8; /* 谷歌蓝 */
|
||||||
|
--secondary-color: #34a853; /* 谷歌绿 */
|
||||||
|
--background-color: #f8f9fa;
|
||||||
|
--text-color: #3c4043;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--border-color: #dadce0;
|
||||||
|
--button-hover-bg: #e8f0fe;
|
||||||
|
--button-hover-text: #174ea6;
|
||||||
|
--stop-color: #d93025; /* 谷歌红,用于删除和停止 */
|
||||||
|
--stop-hover-bg: #fce8e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||||
|
margin: 0;
|
||||||
|
padding: 2rem;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #5f6368;
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#video-wrapper, #controls-panel {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#video-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 680px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#video-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
margin: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||||
|
aspect-ratio: 640 / 480;
|
||||||
|
}
|
||||||
|
|
||||||
|
#video, #canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#video {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
#canvas {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
background-color: #e8f0fe;
|
||||||
|
color: var(--primary-color);
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 动态类别UI样式更新 ==================== */
|
||||||
|
|
||||||
|
#pose-classes-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem; /* 类别之间的间距 */
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pose-class {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-info {
|
||||||
|
flex-grow: 1; /* 让信息区域占据更多空间 */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 📌 新增: 类别名称输入框样式 */
|
||||||
|
.class-name-input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 2px 4px;
|
||||||
|
margin: -2px -4px; /* 抵消padding,使其对齐 */
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-name-input:focus {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-count {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #5f6368;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem; /* 按钮间距 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 📌 新增: 删除按钮样式 */
|
||||||
|
.btn-delete-class {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--stop-color);
|
||||||
|
font-size: 1.5rem; /* 让 '×' 更大更清晰 */
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-class:hover {
|
||||||
|
background-color: var(--stop-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================= */
|
||||||
|
|
||||||
|
.btn-sample, .btn-predict, .btn-add-class {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sample:hover, .btn-predict:hover, .btn-add-class:hover {
|
||||||
|
background-color: var(--button-hover-bg);
|
||||||
|
color: var(--button-hover-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sample:disabled, .btn-predict:disabled, .btn-add-class:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 📌 新增: 为“增加分类”按钮添加特定样式 */
|
||||||
|
.add-class-wrapper {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-class {
|
||||||
|
width: 100%;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.btn-predict.stop {
|
||||||
|
background-color: var(--stop-color);
|
||||||
|
border-color: var(--stop-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-predict.stop:hover {
|
||||||
|
background-color: #a50e0e;
|
||||||
|
}
|
||||||
|
|
||||||
|
#result-container {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #e8f0fe;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#result-text {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user