Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef00ad6f04 | ||
|
|
980958b1f6 | ||
|
|
c9958d43e3 | ||
|
|
0b540c197f | ||
|
|
2f8c3bfdca | ||
|
|
7896e90602 | ||
|
|
f48facde94 | ||
|
|
d9ec92220e |
No files matched your search
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 880 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 114 KiB |
@@ -1,3 +1,277 @@
|
||||
# Graduation_Project
|
||||
# 全向智能跟随垃圾桶的开发与实现
|
||||
|
||||
毕业设计
|
||||
本项目是一个基于树莓派 4B 的智能垃圾桶控制系统,包含 Web 远程控制、四轮全向运动控制、路径录制与回放、摄像头目标跟踪以及自然语言控制等功能。系统后端使用 Flask 提供控制接口,前端通过浏览器完成设备操作,底层通过 PCA9685 PWM 驱动板控制四个电机运动。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- Web 控制界面:支持前进、后退、左右平移、原地旋转和斜向移动。
|
||||
- 路径录制与回放:记录用户操作动作和时间戳,并保存为 JSON 轨迹文件。
|
||||
- 视觉目标跟踪:基于 OpenCV 实现运动目标检测、相机运动补偿和目标中心跟随。
|
||||
- 智能助手控制:通过自然语言指令调用智能垃圾桶运动、路径播放和路径管理功能。
|
||||
- 多输入方式:支持鼠标、触摸、语音输入和游戏手柄控制。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── server.py # Flask Web 服务与控制接口
|
||||
├── motor.py # PCA9685 与四电机底层驱动
|
||||
├── camera.py # 摄像头目标跟踪与自动运动控制
|
||||
├── agent.py # 智能助手指令解析与动作执行
|
||||
├── index.html # Web 控制界面入口
|
||||
├── CSS/ # 页面样式文件
|
||||
├── Javascript/ # 前端交互、轨迹回放、语音和手柄控制脚本
|
||||
├── Path/ # 保存的轨迹 JSON 文件
|
||||
├── agent/skills.md # 智能助手可调用技能说明
|
||||
├── motor_test.py # 电机测试程序
|
||||
└── camera_test.py # 摄像头测试程序
|
||||
```
|
||||
|
||||
## 硬件环境
|
||||
|
||||
本项目主要面向以下硬件环境:
|
||||
|
||||
- Raspberry Pi 4B
|
||||
- PCA9685 PWM 驱动板
|
||||
- 四个MG310减速电机
|
||||
- 四轮全向轮或福来轮
|
||||
- USB 摄像头或树莓派摄像头
|
||||
- 电机供电模块
|
||||
|
||||
使用前需要在树莓派中开启 I2C 接口,并确认 PCA9685 的默认地址为 `0x60`。
|
||||
|
||||
## 软件环境
|
||||
|
||||
建议环境:
|
||||
|
||||
- Python 3.8+
|
||||
- Flask
|
||||
- OpenCV
|
||||
- NumPy
|
||||
- smbus2
|
||||
- requests
|
||||
- urllib3
|
||||
|
||||
可以使用以下命令安装主要依赖:
|
||||
|
||||
```bash
|
||||
pip install flask opencv-python numpy smbus2 requests urllib3
|
||||
```
|
||||
|
||||
如果在树莓派上安装 OpenCV 速度较慢,可以优先使用系统软件源安装:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install python3-opencv
|
||||
```
|
||||
|
||||
## 运行方式
|
||||
|
||||
### 1. 启动 Web 控制服务
|
||||
|
||||
```bash
|
||||
python server.py
|
||||
```
|
||||
|
||||
默认启动地址为:
|
||||
|
||||
```text
|
||||
https://localhost:5443
|
||||
```
|
||||
|
||||
在局域网中访问时,可以使用树莓派的 IP 地址:
|
||||
|
||||
```text
|
||||
https://<树莓派IP>:5443
|
||||
```
|
||||
|
||||
服务默认使用 Flask 的临时 HTTPS 证书,浏览器首次访问时可能会提示证书不受信任,继续访问即可。
|
||||
|
||||
### 2. 修改服务端口
|
||||
|
||||
可以通过环境变量修改监听端口:
|
||||
|
||||
```bash
|
||||
HTTPS_PORT=5443 python server.py
|
||||
```
|
||||
|
||||
Windows PowerShell 中可使用:
|
||||
|
||||
```powershell
|
||||
$env:HTTPS_PORT="5443"
|
||||
python server.py
|
||||
```
|
||||
|
||||
### 3. 运行摄像头跟踪程序
|
||||
|
||||
```bash
|
||||
python camera.py
|
||||
```
|
||||
|
||||
程序会打开摄像头画面,并根据目标中心与画面中心的偏差调用 `motor.py` 中的电机控制函数,实现目标跟随。
|
||||
|
||||
### 4. 测试电机
|
||||
|
||||
```bash
|
||||
python motor_test.py
|
||||
```
|
||||
|
||||
该脚本会依次测试前进、后退、平移、斜向移动和旋转动作。测试前请确保智能垃圾桶周围有足够空间,避免碰撞。
|
||||
|
||||
## Web 接口说明
|
||||
|
||||
### 控制接口
|
||||
|
||||
```http
|
||||
POST /control
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"direction": "forward"
|
||||
}
|
||||
```
|
||||
|
||||
支持的方向包括:
|
||||
|
||||
- `forward`
|
||||
- `backward`
|
||||
- `left`
|
||||
- `right`
|
||||
- `rotate_left`
|
||||
- `rotate_right`
|
||||
- `forward_left`
|
||||
- `forward_right`
|
||||
- `backward_left`
|
||||
- `backward_right`
|
||||
- `stop`
|
||||
|
||||
### 路径管理接口
|
||||
|
||||
保存路径:
|
||||
|
||||
```http
|
||||
POST /save_path
|
||||
```
|
||||
|
||||
列出路径:
|
||||
|
||||
```http
|
||||
GET /list_paths
|
||||
```
|
||||
|
||||
加载路径:
|
||||
|
||||
```http
|
||||
GET /load_path?name=test1
|
||||
```
|
||||
|
||||
删除路径:
|
||||
|
||||
```http
|
||||
DELETE /delete_path
|
||||
```
|
||||
|
||||
路径文件保存在 `Path/` 目录下,格式示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "test1",
|
||||
"actions": [
|
||||
{
|
||||
"direction": "forward",
|
||||
"timestamp": 1734
|
||||
},
|
||||
{
|
||||
"direction": "stop",
|
||||
"timestamp": 2074
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 视觉跟踪逻辑
|
||||
|
||||
`camera.py` 采用轻量化 OpenCV 视觉处理方案,适合树莓派 4B 这类算力有限的平台。程序主要流程如下:
|
||||
|
||||
1. 从摄像头读取图像,并设置分辨率为 `320x240`。
|
||||
2. 将图像转换为灰度图并进行高斯滤波。
|
||||
3. 使用特征点与光流法估计相机运动。
|
||||
4. 对上一帧进行运动补偿后与当前帧做差分。
|
||||
5. 通过阈值分割和形态学处理提取运动区域。
|
||||
6. 根据面积、长宽比、填充率和边缘位置筛选候选目标。
|
||||
7. 维护目标状态,记录目标中心、面积、边界框和速度信息。
|
||||
8. 根据目标中心与画面中心的偏差调用电机控制函数。
|
||||
|
||||
该方法没有使用 YOLO,主要原因是树莓派 4B 直接运行深度学习目标检测模型时响应速度较慢,并且 CPU、内存和发热压力较大。对于本系统而言,实时控制响应比单帧识别精度更重要,因此采用了计算量更低的传统视觉跟踪方案。
|
||||
|
||||
## 电机控制逻辑
|
||||
|
||||
`motor.py` 通过 I2C 控制 PCA9685,向不同通道输出 PWM 信号。每个电机占用两个通道,通过两个通道的 PWM 输出关系控制电机正反转和停止。
|
||||
|
||||
四个电机组合后可以实现:
|
||||
|
||||
- 前进与后退
|
||||
- 左右平移
|
||||
- 左右旋转
|
||||
- 四个方向的斜向移动
|
||||
- 停止
|
||||
|
||||
如果实际运动方向与预期相反,可以根据电机接线和轮子安装方向,调整 `motor.py` 中各运动函数的正负号,或在 `server.py`、`camera.py` 中调整方向映射。
|
||||
|
||||
## AI Agent功能实现
|
||||
|
||||
`agent.py` 提供自然语言指令解析与执行功能。前端将用户输入发送到 `/agent_chat`,后端根据 `agent/skills.md` 中定义的技能规则调用对应动作。
|
||||
|
||||
支持示例:
|
||||
|
||||
- 前进 3 秒
|
||||
- 后退 2 秒
|
||||
- 左转 1 秒
|
||||
- 停止
|
||||
- 执行路径 test1
|
||||
- 列出路径
|
||||
- 删除路径 test1
|
||||
|
||||
使用Agent功能前,需要在 Web 页面中配置可用的 LLM API 地址、API Key 和模型名称。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 浏览器无法打开摄像头或语音输入
|
||||
|
||||
语音输入和摄像头权限通常要求 HTTPS 或 localhost 环境。本项目默认使用 HTTPS 启动,如果浏览器提示证书不可信,需要手动选择继续访问。
|
||||
|
||||
### 2. 智能垃圾桶前后方向反了
|
||||
|
||||
这通常与电机接线、轮子安装方向或程序中的方向映射有关。可以优先检查:
|
||||
|
||||
- `motor.py` 中 `forward()` 和 `backward()` 的电机正负号。
|
||||
- `server.py` 中 `/control` 对 `forward`、`backward` 的映射。
|
||||
- `camera.py` 中 `drive_to_target()` 对目标偏移方向的控制逻辑。
|
||||
|
||||
### 3. 摄像头跟踪不稳定
|
||||
|
||||
当前方法基于运动区域检测,复杂背景、光照变化、遮挡和其他运动物体都会影响识别效果。可以尝试:
|
||||
|
||||
- 降低背景干扰。
|
||||
- 保持光照稳定。
|
||||
- 调整 `MIN_CONTOUR_AREA`、`MAX_CONTOUR_AREA` 和 `CONTROL_DEADZONE`。
|
||||
- 后续接入轻量化 YOLO 或硬件 AI 加速模块。
|
||||
|
||||
### 4. 路径回放存在偏移
|
||||
|
||||
当前路径回放基于时间戳记录动作,属于开环控制。电池电压、地面摩擦、电机响应差异和轮胎打滑都会造成误差。后续可以通过编码器、IMU 或视觉定位实现闭环修正。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 电机测试前请架空智能垃圾桶移动底盘或确保周围空间安全。
|
||||
- 树莓派和电机建议分开供电,避免电机启动瞬间导致树莓派掉电。
|
||||
- 如果长时间运行摄像头跟踪,建议注意树莓派温度。
|
||||
- 轨迹文件保存在 `Path/` 目录中,删除前请确认文件名称。
|
||||
|
||||
## License
|
||||
|
||||
本项目用于课程设计、毕业设计或个人学习研究。若用于其他用途,请根据实际情况补充许可证说明。
|
||||
@@ -0,0 +1,122 @@
|
||||
.agent-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.llm-config {
|
||||
padding: 18px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(244, 248, 252, 0.95), rgba(234, 240, 245, 0.92));
|
||||
border: 1px solid rgba(125, 148, 177, 0.14);
|
||||
}
|
||||
|
||||
.llm-config h4 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #516577;
|
||||
}
|
||||
|
||||
.config-row {
|
||||
display: grid;
|
||||
grid-template-columns: 82px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.config-row:last-of-type {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.config-row label {
|
||||
color: #647789;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-config {
|
||||
min-width: 112px;
|
||||
padding: 0 18px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(160deg, var(--btn-secondary-start), var(--btn-secondary-end));
|
||||
}
|
||||
|
||||
.llm-config > .btn-config + .btn-config {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.chat-history {
|
||||
min-height: 280px;
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
padding: 18px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(245, 248, 251, 0.95), rgba(234, 240, 245, 0.92));
|
||||
border: 1px solid rgba(125, 148, 177, 0.14);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: min(82%, 420px);
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 10px 18px rgba(53, 74, 97, 0.08);
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
background: linear-gradient(160deg, #e1f1ff, #c5def6);
|
||||
}
|
||||
|
||||
.message.agent {
|
||||
align-self: flex-start;
|
||||
background: linear-gradient(160deg, #ecf6ea, #d9ebd6);
|
||||
}
|
||||
|
||||
.message p {
|
||||
margin: 0;
|
||||
color: #2f4253;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.config-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.llm-config > .btn-config {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.llm-config > .btn-config + .btn-config {
|
||||
margin-left: 0;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.chat-input {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chat-input .btn-save {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,271 +1,439 @@
|
||||
/* 移动控制相关样式 */
|
||||
:root {
|
||||
--bg-top: #f7f4ec;
|
||||
--bg-bottom: #dbe6f2;
|
||||
--surface: rgba(255, 255, 255, 0.8);
|
||||
--surface-strong: #ffffff;
|
||||
--surface-soft: #eef3f8;
|
||||
--line: rgba(98, 124, 154, 0.18);
|
||||
--text-main: #1d2b36;
|
||||
--text-muted: #647789;
|
||||
--shadow-lg: 0 28px 60px rgba(53, 74, 97, 0.14);
|
||||
--shadow-md: 0 16px 32px rgba(53, 74, 97, 0.12);
|
||||
--shadow-sm: 0 8px 18px rgba(53, 74, 97, 0.09);
|
||||
--radius-xl: 30px;
|
||||
--radius-lg: 22px;
|
||||
--radius-md: 16px;
|
||||
--focus: rgba(33, 150, 243, 0.24);
|
||||
--btn-primary-start: #4fa3ff;
|
||||
--btn-primary-end: #256fd6;
|
||||
--btn-primary-shadow: rgba(37, 111, 214, 0.28);
|
||||
--btn-secondary-start: #62b2ff;
|
||||
--btn-secondary-end: #317fdf;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text-main);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.92), transparent 34%),
|
||||
linear-gradient(160deg, var(--bg-top) 0%, var(--bg-bottom) 100%);
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 15% 20%, rgba(255, 255, 255, 0.48), transparent 18%),
|
||||
radial-gradient(circle at 85% 12%, rgba(122, 162, 214, 0.14), transparent 20%),
|
||||
radial-gradient(circle at 78% 82%, rgba(255, 203, 119, 0.16), transparent 22%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
position: relative;
|
||||
padding: 40px 20px 56px;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 80vh;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
position: relative;
|
||||
width: min(1280px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-intro {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: #7a8ea2;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 40px;
|
||||
text-align: center;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin: 0;
|
||||
font-size: clamp(34px, 4vw, 46px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
/* 手柄风格布局 */
|
||||
.controller {
|
||||
.subtitle {
|
||||
margin: 14px 0 0;
|
||||
max-width: 680px;
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.12fr) minmax(340px, 0.88fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.main-column,
|
||||
.side-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 控制器布局 */
|
||||
.controller-layout {
|
||||
.surface {
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
backdrop-filter: blur(14px);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-heading.compact {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #6f879d;
|
||||
}
|
||||
|
||||
.section-heading h2,
|
||||
.section-heading h3 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.section-note {
|
||||
margin: 0;
|
||||
max-width: 220px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.controller {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controller-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) 180px 148px;
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
padding: 18px;
|
||||
border-radius: 26px;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(240, 246, 251, 0.78)),
|
||||
linear-gradient(135deg, rgba(244, 248, 252, 0.9), rgba(224, 233, 243, 0.72));
|
||||
border: 1px solid rgba(120, 146, 176, 0.14);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.control-cluster {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
padding: 30px;
|
||||
background: linear-gradient(145deg, #e6e6e6, #ffffff);
|
||||
border-radius: 20px;
|
||||
box-shadow: 10px 10px 20px rgba(0, 0, 0, 0.1), -10px -10px 20px rgba(255, 255, 255, 0.7);
|
||||
gap: 16px;
|
||||
min-height: 100%;
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
background: rgba(245, 249, 253, 0.72);
|
||||
border: 1px solid rgba(125, 148, 177, 0.12);
|
||||
}
|
||||
|
||||
.control-cluster-center {
|
||||
background: linear-gradient(180deg, rgba(250, 247, 242, 0.92), rgba(252, 251, 247, 0.82));
|
||||
}
|
||||
|
||||
.control-cluster-rotate {
|
||||
background: linear-gradient(180deg, rgba(242, 247, 250, 0.92), rgba(247, 251, 249, 0.84));
|
||||
}
|
||||
|
||||
.cluster-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: #6f8498;
|
||||
}
|
||||
|
||||
/* 方向键区域 */
|
||||
.dpad {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dpad-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 中间录制回放控制 */
|
||||
.center-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 右侧旋转控制 */
|
||||
.center-controls,
|
||||
.rotate-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.1), -5px -5px 10px rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 52px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid rgba(25, 76, 150, 0.34);
|
||||
border-radius: 18px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: transform 0.22s ease, box-shadow 0.22s ease, filter 0.22s ease;
|
||||
background: linear-gradient(160deg, var(--btn-primary-start), var(--btn-primary-end));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.26),
|
||||
0 10px 20px var(--btn-primary-shadow);
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.2), rgba(255,255,255,0));
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn span {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 方向按钮样式 */
|
||||
.btn-forward {
|
||||
background: linear-gradient(145deg, #4CAF50, #45a049);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-backward {
|
||||
background: linear-gradient(145deg, #f44336, #da190b);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-left {
|
||||
background: linear-gradient(145deg, #2196F3, #1976D2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-right {
|
||||
background: linear-gradient(145deg, #FF9800, #F57C00);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 斜向按钮样式 */
|
||||
.btn-forward-left {
|
||||
background: linear-gradient(145deg, #4CAF50, #45a049);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-forward-right {
|
||||
background: linear-gradient(145deg, #4CAF50, #45a049);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-backward-left {
|
||||
background: linear-gradient(145deg, #f44336, #da190b);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-backward-right {
|
||||
background: linear-gradient(145deg, #f44336, #da190b);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 旋转按钮样式 */
|
||||
.btn-rotate-left {
|
||||
background: linear-gradient(145deg, #9C27B0, #7B1FA2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-rotate-right {
|
||||
background: linear-gradient(145deg, #607D8B, #455A64);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 中心方块样式 */
|
||||
.dpad .btn,
|
||||
.rotate-controls .btn,
|
||||
.btn-center {
|
||||
background: linear-gradient(145deg, #666666, #444444);
|
||||
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.1), -5px -5px 10px rgba(255, 255, 255, 0.7);
|
||||
cursor: default;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
border-radius: 24px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.btn-center:hover {
|
||||
transform: none;
|
||||
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.1), -5px -5px 10px rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.btn-center:active {
|
||||
transform: none;
|
||||
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.1), -5px -5px 10px rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* 录制回放按钮样式 */
|
||||
.btn-record {
|
||||
background: linear-gradient(145deg, #FF5722, #E64A19);
|
||||
color: white;
|
||||
width: 120px;
|
||||
height: 50px;
|
||||
font-size: 16px;
|
||||
border-radius: 25px;
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.btn-playback {
|
||||
background: linear-gradient(145deg, #00BCD4, #0097A7);
|
||||
color: white;
|
||||
width: 120px;
|
||||
height: 50px;
|
||||
font-size: 16px;
|
||||
border-radius: 25px;
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.btn-center {
|
||||
background: linear-gradient(160deg, #dce6ef, #b9c8d7);
|
||||
border-color: rgba(120, 139, 159, 0.22);
|
||||
box-shadow: inset 0 2px 8px rgba(108, 132, 157, 0.16);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 按钮动画效果 */
|
||||
.btn:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 8px 8px 16px rgba(0, 0, 0, 0.15), -8px -8px 16px rgba(255, 255, 255, 0.8);
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.34),
|
||||
0 16px 28px rgba(37, 111, 214, 0.32);
|
||||
filter: saturate(1.04) brightness(1.02);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 3px 3px 6px rgba(0, 0, 0, 0.2), -3px -3px 6px rgba(255, 255, 255, 0.6);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.24),
|
||||
0 8px 14px rgba(37, 111, 214, 0.24);
|
||||
}
|
||||
|
||||
/* 状态显示 */
|
||||
.status {
|
||||
margin-top: 30px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(145deg, #f5f5f5, #e0e0e0);
|
||||
border-radius: 15px;
|
||||
min-height: 60px;
|
||||
.btn-center:hover,
|
||||
.btn-center:active {
|
||||
transform: none;
|
||||
box-shadow: inset 0 2px 8px rgba(108, 132, 157, 0.16);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.btn:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 4px var(--focus);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
box-shadow: inset 5px 5px 10px rgba(0, 0, 0, 0.1), inset -5px -5px 10px rgba(255, 255, 255, 0.7);
|
||||
min-width: 0;
|
||||
padding: 13px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
color: var(--text-main);
|
||||
font-size: 15px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
input[type="text"]::placeholder,
|
||||
input[type="password"]::placeholder {
|
||||
color: #95a3b1;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="password"]:focus {
|
||||
border-color: rgba(70, 129, 186, 0.4);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.status-surface {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
min-height: 92px;
|
||||
padding: 22px 24px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(245, 248, 251, 0.96), rgba(233, 239, 245, 0.92));
|
||||
border: 1px solid rgba(125, 148, 177, 0.14);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.84);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status p {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 1.6;
|
||||
color: #526476;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 650px) {
|
||||
.controller-layout {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
@media (max-width: 1080px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-note {
|
||||
max-width: none;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.page-shell {
|
||||
padding: 24px 16px 40px;
|
||||
}
|
||||
|
||||
.surface {
|
||||
padding: 20px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.controller-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.control-cluster,
|
||||
.control-cluster-center,
|
||||
.control-cluster-rotate {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rotate-controls {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.title {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.surface {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.controller-layout {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.control-cluster {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.dpad-row {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-record, .btn-playback {
|
||||
width: 100px;
|
||||
height: 45px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.btn {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.btn-record, .btn-playback {
|
||||
width: 90px;
|
||||
height: 40px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.title {
|
||||
.dpad .btn,
|
||||
.rotate-controls .btn,
|
||||
.btn-center {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,37 @@
|
||||
/* 录制回放相关样式 */
|
||||
.btn-record {
|
||||
background-color: #ff9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-playback {
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 轨迹管理区域样式 */
|
||||
.path-management {
|
||||
margin-top: 30px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(145deg, #e6e6e6, #ffffff);
|
||||
border-radius: 15px;
|
||||
box-shadow: 10px 10px 20px rgba(0, 0, 0, 0.1), -10px -10px 20px rgba(255, 255, 255, 0.7);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.path-management h3 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.path-input {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.path-input input {
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #f5f5f5;
|
||||
box-shadow: inset 5px 5px 10px rgba(0, 0, 0, 0.1), inset -5px -5px 10px rgba(255, 255, 255, 0.7);
|
||||
width: 200px;
|
||||
font-size: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: linear-gradient(145deg, #4CAF50, #45a049);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.1), -5px -5px 10px rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.btn-save:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 7px 7px 14px rgba(0, 0, 0, 0.15), -7px -7px 14px rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.btn-save:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 3px 3px 6px rgba(0, 0, 0, 0.1), -3px -3px 6px rgba(255, 255, 255, 0.7);
|
||||
min-width: 120px;
|
||||
padding: 0 20px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.path-list h4 {
|
||||
margin-top: 0;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #5e7285;
|
||||
}
|
||||
|
||||
#pathList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -83,55 +39,78 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
margin: 5px 0;
|
||||
background: #f5f5f5;
|
||||
border-radius: 10px;
|
||||
box-shadow: 3px 3px 6px rgba(0, 0, 0, 0.1), -3px -3px 6px rgba(255, 255, 255, 0.7);
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgba(245, 248, 251, 0.95), rgba(235, 241, 246, 0.92));
|
||||
border: 1px solid rgba(125, 148, 177, 0.14);
|
||||
}
|
||||
|
||||
#pathList li > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #314355;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#pathList li > div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#pathList li button {
|
||||
background: linear-gradient(145deg, #f44336, #da190b);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgba(25, 76, 150, 0.3);
|
||||
border-radius: 12px;
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.24),
|
||||
0 8px 16px rgba(37, 111, 214, 0.18);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
#pathList li button:hover {
|
||||
background: linear-gradient(145deg, #e53935, #c62828);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.28),
|
||||
0 12px 18px rgba(37, 111, 214, 0.22);
|
||||
}
|
||||
|
||||
#pathList li button.load-btn {
|
||||
background: linear-gradient(145deg, #2196F3, #1976D2);
|
||||
margin-right: 5px;
|
||||
background: linear-gradient(160deg, var(--btn-secondary-start), var(--btn-secondary-end));
|
||||
}
|
||||
|
||||
#pathList li button.load-btn:hover {
|
||||
background: linear-gradient(145deg, #1E88E5, #1565C0);
|
||||
#pathList li button.delete-btn,
|
||||
#pathList li button:not(.load-btn) {
|
||||
background: linear-gradient(160deg, var(--btn-secondary-start), var(--btn-secondary-end));
|
||||
}
|
||||
|
||||
/* 响应式设计 - 轨迹管理 */
|
||||
@media (max-width: 650px) {
|
||||
@media (max-width: 640px) {
|
||||
.path-input {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.path-input input {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.path-management {
|
||||
padding: 15px;
|
||||
#pathList li {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#pathList li > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#pathList li button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
@@ -56,16 +56,6 @@ h1 {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-record {
|
||||
background-color: #ff9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-playback {
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// Agent聊天功能
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const agentInput = document.getElementById('agentInput');
|
||||
const sendAgentBtn = document.getElementById('sendAgentBtn');
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
const status = document.getElementById('status');
|
||||
|
||||
// LLM配置相关元素
|
||||
const apiUrlInput = document.getElementById('apiUrlInput');
|
||||
const apiKeyInput = document.getElementById('apiKeyInput');
|
||||
const modelInput = document.getElementById('modelInput');
|
||||
const saveConfigBtn = document.getElementById('saveConfigBtn');
|
||||
|
||||
// 添加清除缓存按钮
|
||||
const clearCacheBtn = document.createElement('button');
|
||||
clearCacheBtn.className = 'btn btn-config';
|
||||
clearCacheBtn.id = 'clearCacheBtn';
|
||||
clearCacheBtn.textContent = '清除缓存';
|
||||
saveConfigBtn.parentNode.appendChild(clearCacheBtn);
|
||||
|
||||
// 从本地存储加载配置
|
||||
function loadConfigFromLocalStorage() {
|
||||
const savedConfig = localStorage.getItem('llmConfig');
|
||||
if (savedConfig) {
|
||||
try {
|
||||
const config = JSON.parse(savedConfig);
|
||||
apiUrlInput.value = config.api_url || '';
|
||||
apiKeyInput.value = config.api_key || '';
|
||||
modelInput.value = config.model || '';
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log('解析本地存储配置失败:', error);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保存配置到本地存储
|
||||
function saveConfigToLocalStorage(config) {
|
||||
localStorage.setItem('llmConfig', JSON.stringify(config));
|
||||
}
|
||||
|
||||
// 清除本地存储
|
||||
function clearLocalStorage() {
|
||||
localStorage.removeItem('llmConfig');
|
||||
apiUrlInput.value = '';
|
||||
apiKeyInput.value = '';
|
||||
modelInput.value = '';
|
||||
status.innerHTML = '<p>缓存已清除</p>';
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
function loadConfig() {
|
||||
// 优先从本地存储加载
|
||||
if (!loadConfigFromLocalStorage()) {
|
||||
// 如果本地存储没有,从后端加载
|
||||
fetch('/agent_config')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
apiUrlInput.value = data.api_url || '';
|
||||
apiKeyInput.value = data.api_key || '';
|
||||
modelInput.value = data.model || '';
|
||||
// 保存到本地存储
|
||||
saveConfigToLocalStorage({
|
||||
api_url: data.api_url || '',
|
||||
api_key: data.api_key || '',
|
||||
model: data.model || ''
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('加载配置失败:', error);
|
||||
status.innerHTML = `<p>加载配置失败: ${error.message}</p>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
function saveConfig() {
|
||||
const config = {
|
||||
api_url: apiUrlInput.value.trim(),
|
||||
api_key: apiKeyInput.value.trim(),
|
||||
model: modelInput.value.trim()
|
||||
};
|
||||
|
||||
console.log('保存配置:', config);
|
||||
|
||||
// 保存到本地存储
|
||||
saveConfigToLocalStorage(config);
|
||||
|
||||
fetch('/agent_config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('保存配置响应:', data);
|
||||
if (data.status === 'success') {
|
||||
status.innerHTML = '<p>配置已保存</p>';
|
||||
} else {
|
||||
status.innerHTML = `<p>配置保存失败: ${data.message}</p>`;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('配置保存失败:', error);
|
||||
status.innerHTML = `<p>配置保存失败: ${error.message}</p>`;
|
||||
});
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
function sendMessage() {
|
||||
const text = agentInput.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
addMessage('user', text);
|
||||
agentInput.value = '';
|
||||
status.innerHTML = '<p>处理中...</p>';
|
||||
|
||||
fetch('/agent_chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ text: text })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('聊天响应:', data);
|
||||
addMessage('agent', data.message || '处理完成');
|
||||
|
||||
// 更新状态
|
||||
status.innerHTML = `<p>${data.message || '处理完成'}</p>`;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('通信错误:', error);
|
||||
addMessage('agent', '通信错误,请重试');
|
||||
status.innerHTML = `<p>通信错误: ${error.message}</p>`;
|
||||
});
|
||||
}
|
||||
|
||||
// 添加消息到聊天历史
|
||||
function addMessage(type, content) {
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = `message ${type}`;
|
||||
messageDiv.innerHTML = `<p>${content}</p>`;
|
||||
chatHistory.appendChild(messageDiv);
|
||||
chatHistory.scrollTop = chatHistory.scrollHeight;
|
||||
}
|
||||
|
||||
// 绑定发送按钮点击事件
|
||||
if (sendAgentBtn) {
|
||||
sendAgentBtn.addEventListener('click', sendMessage);
|
||||
}
|
||||
|
||||
// 绑定回车键发送
|
||||
if (agentInput) {
|
||||
agentInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 绑定保存配置按钮
|
||||
if (saveConfigBtn) {
|
||||
saveConfigBtn.addEventListener('click', saveConfig);
|
||||
}
|
||||
|
||||
// 绑定清除缓存按钮
|
||||
if (clearCacheBtn) {
|
||||
clearCacheBtn.addEventListener('click', clearLocalStorage);
|
||||
}
|
||||
|
||||
// 页面加载时获取配置
|
||||
loadConfig();
|
||||
});
|
||||
@@ -11,6 +11,9 @@ const backwardLeftBtn = document.getElementById('backwardLeftBtn');
|
||||
const backwardRightBtn = document.getElementById('backwardRightBtn');
|
||||
const status = document.getElementById('status');
|
||||
|
||||
// 跟踪上一个记录的动作
|
||||
let lastRecordedAction = null;
|
||||
|
||||
// 实际控制函数
|
||||
function controlMotor(direction) {
|
||||
// 根据方向设置状态消息
|
||||
@@ -34,11 +37,18 @@ function controlMotor(direction) {
|
||||
status.innerHTML = `<p>正在${actionText}...</p>`;
|
||||
}
|
||||
|
||||
// 记录操作(如果正在录制)
|
||||
// 记录操作(如果正在录制且动作发生变化)
|
||||
if (window.isRecording) {
|
||||
const timestamp = Date.now() - window.recordingStartTime;
|
||||
window.recordedActions.push({ direction, timestamp });
|
||||
console.log('Recorded action:', { direction, timestamp });
|
||||
// 检查动作是否发生变化
|
||||
if (direction !== lastRecordedAction) {
|
||||
const timestamp = Date.now() - window.recordingStartTime;
|
||||
window.recordedActions.push({ direction, timestamp });
|
||||
console.log('Recorded action:', { direction, timestamp });
|
||||
// 更新上一个记录的动作
|
||||
lastRecordedAction = direction;
|
||||
} else {
|
||||
console.log('Not recording duplicate action:', direction);
|
||||
}
|
||||
} else {
|
||||
console.log('Not recording, action:', direction);
|
||||
}
|
||||
@@ -419,11 +429,18 @@ backwardRightBtn.addEventListener('touchcancel', (e) => {
|
||||
|
||||
// 停止电机函数
|
||||
function stopMotor() {
|
||||
// 记录操作(如果正在录制)
|
||||
// 记录操作(如果正在录制且动作发生变化)
|
||||
if (window.isRecording) {
|
||||
const timestamp = Date.now() - window.recordingStartTime;
|
||||
window.recordedActions.push({ direction: 'stop', timestamp });
|
||||
console.log('Recorded action:', { direction: 'stop', timestamp });
|
||||
// 检查动作是否发生变化
|
||||
if ('stop' !== lastRecordedAction) {
|
||||
const timestamp = Date.now() - window.recordingStartTime;
|
||||
window.recordedActions.push({ direction: 'stop', timestamp });
|
||||
console.log('Recorded action:', { direction: 'stop', timestamp });
|
||||
// 更新上一个记录的动作
|
||||
lastRecordedAction = 'stop';
|
||||
} else {
|
||||
console.log('Not recording duplicate action:', 'stop');
|
||||
}
|
||||
} else {
|
||||
console.log('Not recording, action:', 'stop');
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ window.addEventListener('DOMContentLoaded', function() {
|
||||
// 游戏手柄相关变量
|
||||
let gamepad = null;
|
||||
let lastDirection = 'stop';
|
||||
let isRecording = false;
|
||||
let isPlaying = false;
|
||||
let recordedActions = [];
|
||||
let recordingStartTime = 0;
|
||||
// 使用全局变量,与control.js和record.js保持一致
|
||||
// let isRecording = false;
|
||||
// let isPlaying = false;
|
||||
// let recordedActions = [];
|
||||
// let recordingStartTime = 0;
|
||||
|
||||
// 按键状态标志(用于检测按键按下事件)
|
||||
let recordButtonPressed = false;
|
||||
@@ -104,17 +105,17 @@ window.addEventListener('DOMContentLoaded', function() {
|
||||
if (buttons[12].pressed) { // 十字键上键 - 录制
|
||||
if (!recordButtonPressed) {
|
||||
recordButtonPressed = true;
|
||||
if (!isPlaying) {
|
||||
if (isRecording) {
|
||||
if (!window.isPlaying) {
|
||||
if (window.isRecording) {
|
||||
// 停止录制
|
||||
isRecording = false;
|
||||
status.innerHTML = `<p>录制完成,共记录 ${recordedActions.length} 个操作</p>`;
|
||||
console.log('Recording stopped, actions:', recordedActions);
|
||||
window.isRecording = false;
|
||||
status.innerHTML = `<p>录制完成,共记录 ${window.recordedActions.length} 个操作</p>`;
|
||||
console.log('Recording stopped, actions:', window.recordedActions);
|
||||
} else {
|
||||
// 开始录制
|
||||
isRecording = true;
|
||||
recordedActions = [];
|
||||
recordingStartTime = Date.now();
|
||||
window.isRecording = true;
|
||||
window.recordedActions = [];
|
||||
window.recordingStartTime = Date.now();
|
||||
status.innerHTML = '<p>开始录制...</p>';
|
||||
console.log('Recording started');
|
||||
}
|
||||
@@ -128,23 +129,23 @@ window.addEventListener('DOMContentLoaded', function() {
|
||||
if (buttons[14].pressed) { // 十字键左键 - 回放
|
||||
if (!playbackButtonPressed) {
|
||||
playbackButtonPressed = true;
|
||||
if (!isRecording && !isPlaying && recordedActions.length > 0) {
|
||||
isPlaying = true;
|
||||
if (!window.isRecording && !window.isPlaying && window.recordedActions.length > 0) {
|
||||
window.isPlaying = true;
|
||||
status.innerHTML = '<p>开始回放...</p>';
|
||||
console.log('Starting playback');
|
||||
|
||||
// 按时间顺序回放操作
|
||||
let currentTime = 0;
|
||||
const totalDuration = recordedActions[recordedActions.length - 1].timestamp;
|
||||
const totalDuration = window.recordedActions[window.recordedActions.length - 1].timestamp;
|
||||
|
||||
recordedActions.forEach((action, index) => {
|
||||
window.recordedActions.forEach((action, index) => {
|
||||
if (index === 0) {
|
||||
// 第一个操作立即执行
|
||||
console.log('Executing action immediately:', action);
|
||||
controlMotor(action.direction);
|
||||
} else {
|
||||
// 计算与前一个操作的时间差
|
||||
const prevAction = recordedActions[index - 1];
|
||||
const prevAction = window.recordedActions[index - 1];
|
||||
const delay = action.timestamp - prevAction.timestamp;
|
||||
currentTime += delay;
|
||||
|
||||
@@ -162,7 +163,7 @@ window.addEventListener('DOMContentLoaded', function() {
|
||||
controlMotor('stop');
|
||||
status.innerHTML = '<p>回放完成</p>';
|
||||
console.log('Playback completed');
|
||||
isPlaying = false;
|
||||
window.isPlaying = false;
|
||||
}, totalDuration + 2000);
|
||||
}
|
||||
}
|
||||
@@ -173,12 +174,12 @@ window.addEventListener('DOMContentLoaded', function() {
|
||||
// 如果方向改变,发送控制命令
|
||||
if (direction !== lastDirection) {
|
||||
// 如果正在录制,记录操作
|
||||
if (isRecording) {
|
||||
if (window.isRecording) {
|
||||
const action = {
|
||||
direction: direction,
|
||||
timestamp: Date.now() - recordingStartTime
|
||||
timestamp: Date.now() - window.recordingStartTime
|
||||
};
|
||||
recordedActions.push(action);
|
||||
window.recordedActions.push(action);
|
||||
}
|
||||
controlMotor(direction);
|
||||
lastDirection = direction;
|
||||
@@ -212,8 +213,8 @@ window.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
// 记录操作(如果正在录制)
|
||||
if (isRecording) {
|
||||
const timestamp = Date.now() - recordingStartTime;
|
||||
if (window.isRecording) {
|
||||
const timestamp = Date.now() - window.recordingStartTime;
|
||||
// 注意:这里不再重复记录,因为已经在updateGamepad中记录了
|
||||
console.log('Recorded action:', { direction, timestamp });
|
||||
} else {
|
||||
|
||||
@@ -179,6 +179,10 @@ window.addEventListener('DOMContentLoaded', function() {
|
||||
window.isRecording = true;
|
||||
window.recordedActions = [];
|
||||
window.recordingStartTime = Date.now();
|
||||
// 重置上一个记录的动作
|
||||
if (window.lastRecordedAction !== undefined) {
|
||||
window.lastRecordedAction = null;
|
||||
}
|
||||
recordBtn.textContent = '停止录制';
|
||||
status.innerHTML = `<p>开始录制...</p>`;
|
||||
console.log('Recording started');
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
// 获取按钮和状态元素
|
||||
const forwardBtn = document.getElementById('forwardBtn');
|
||||
const backwardBtn = document.getElementById('backwardBtn');
|
||||
const recordBtn = document.getElementById('recordBtn');
|
||||
const playbackBtn = document.getElementById('playbackBtn');
|
||||
const status = document.getElementById('status');
|
||||
|
||||
// 录制相关变量
|
||||
let isRecording = false;
|
||||
let recordedActions = [];
|
||||
let recordingStartTime = 0;
|
||||
|
||||
// 实际控制函数
|
||||
function controlMotor(direction) {
|
||||
status.innerHTML = `<p>正在${direction === 'forward' ? '前进' : '后退'}...</p>`;
|
||||
|
||||
// 记录操作(如果正在录制)
|
||||
if (isRecording) {
|
||||
const timestamp = Date.now() - recordingStartTime;
|
||||
recordedActions.push({ direction, timestamp });
|
||||
}
|
||||
|
||||
// 发送AJAX请求到后端服务器
|
||||
fetch('/control', {
|
||||
method: 'POST',
|
||||
@@ -41,50 +28,6 @@ function controlMotor(direction) {
|
||||
});
|
||||
}
|
||||
|
||||
// 录制按钮点击事件
|
||||
recordBtn.addEventListener('click', () => {
|
||||
if (isRecording) {
|
||||
// 停止录制
|
||||
isRecording = false;
|
||||
recordBtn.textContent = '开始录制';
|
||||
status.innerHTML = `<p>录制完成,共记录 ${recordedActions.length} 个操作</p>`;
|
||||
} else {
|
||||
// 开始录制
|
||||
isRecording = true;
|
||||
recordedActions = [];
|
||||
recordingStartTime = Date.now();
|
||||
recordBtn.textContent = '停止录制';
|
||||
status.innerHTML = `<p>开始录制...</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
// 回放按钮点击事件
|
||||
playbackBtn.addEventListener('click', () => {
|
||||
if (recordedActions.length === 0) {
|
||||
status.innerHTML = `<p>没有可回放的操作</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
status.innerHTML = `<p>开始回放...</p>`;
|
||||
|
||||
// 按时间顺序回放操作
|
||||
let lastTimestamp = 0;
|
||||
recordedActions.forEach((action, index) => {
|
||||
setTimeout(() => {
|
||||
controlMotor(action.direction);
|
||||
|
||||
// 最后一个操作完成后显示回放完成
|
||||
if (index === recordedActions.length - 1) {
|
||||
setTimeout(() => {
|
||||
status.innerHTML = `<p>回放完成</p>`;
|
||||
}, 2000);
|
||||
}
|
||||
}, action.timestamp - lastTimestamp);
|
||||
|
||||
lastTimestamp = action.timestamp;
|
||||
});
|
||||
});
|
||||
|
||||
// 按钮点击事件
|
||||
forwardBtn.addEventListener('click', () => {
|
||||
controlMotor('forward');
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// 语音输入功能
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const agentInput = document.getElementById('agentInput');
|
||||
const sendAgentBtn = document.getElementById('sendAgentBtn');
|
||||
const status = document.getElementById('status');
|
||||
|
||||
if (!agentInput || !sendAgentBtn || !sendAgentBtn.parentNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const voiceBtn = document.createElement('button');
|
||||
voiceBtn.className = 'btn btn-voice';
|
||||
voiceBtn.id = 'voiceBtn';
|
||||
voiceBtn.type = 'button';
|
||||
voiceBtn.textContent = '🎤';
|
||||
voiceBtn.title = '按住说话,松开发送';
|
||||
sendAgentBtn.parentNode.insertBefore(voiceBtn, sendAgentBtn);
|
||||
|
||||
let recognition = null;
|
||||
let isRecordingVoice = false;
|
||||
let microphoneReady = false;
|
||||
let spacePressed = false;
|
||||
let shouldAutoSend = false;
|
||||
|
||||
function setStatusMessage(message) {
|
||||
if (status) {
|
||||
status.innerHTML = `<p>${message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateVoiceButton(recording) {
|
||||
voiceBtn.classList.toggle('recording', recording);
|
||||
voiceBtn.textContent = recording ? '🔴' : '🎤';
|
||||
}
|
||||
|
||||
function hasSecureVoiceContext() {
|
||||
return window.isSecureContext;
|
||||
}
|
||||
|
||||
function getSecureContextMessage() {
|
||||
return '语音输入需要在 HTTPS 或 localhost 环境下使用;当前地址无法申请麦克风权限。';
|
||||
}
|
||||
|
||||
function getRecognitionErrorMessage(error) {
|
||||
switch (error) {
|
||||
case 'not-allowed':
|
||||
case 'service-not-allowed':
|
||||
return '浏览器已阻止麦克风权限,请在地址栏站点设置中允许麦克风后刷新页面。';
|
||||
case 'audio-capture':
|
||||
return '没有检测到可用麦克风,请检查设备或系统录音权限。';
|
||||
case 'network':
|
||||
return '语音识别服务连接失败,请检查网络后重试。';
|
||||
case 'no-speech':
|
||||
return '没有识别到语音,请按住按钮后再说话。';
|
||||
case 'aborted':
|
||||
return '语音识别已取消。';
|
||||
default:
|
||||
return `语音识别失败:${error}`;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePermissionError(error) {
|
||||
const permissionDeniedNames = ['NotAllowedError', 'PermissionDeniedError', 'SecurityError'];
|
||||
const deviceMissingNames = ['NotFoundError', 'DevicesNotFoundError'];
|
||||
|
||||
if (permissionDeniedNames.includes(error.name)) {
|
||||
setStatusMessage('浏览器未授予麦克风权限,请在站点权限中允许麦克风后重试。');
|
||||
return;
|
||||
}
|
||||
|
||||
if (deviceMissingNames.includes(error.name)) {
|
||||
setStatusMessage('没有找到可用的麦克风设备,请检查设备连接。');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatusMessage(`麦克风初始化失败:${error.message || error.name}`);
|
||||
}
|
||||
|
||||
async function ensureMicrophonePermission() {
|
||||
if (microphoneReady) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!hasSecureVoiceContext()) {
|
||||
setStatusMessage(getSecureContextMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
// 某些支持 SpeechRecognition 的浏览器不会暴露 getUserMedia;
|
||||
// 这种情况下交给 recognition.start() 自己触发权限请求。
|
||||
microphoneReady = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
microphoneReady = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('麦克风权限申请失败:', error);
|
||||
handlePermissionError(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function initSpeechRecognition() {
|
||||
if (!SpeechRecognition) {
|
||||
return;
|
||||
}
|
||||
|
||||
recognition = new SpeechRecognition();
|
||||
recognition.continuous = false;
|
||||
recognition.interimResults = true;
|
||||
recognition.lang = 'zh-CN';
|
||||
|
||||
recognition.onstart = function() {
|
||||
isRecordingVoice = true;
|
||||
updateVoiceButton(true);
|
||||
setStatusMessage('正在聆听,请开始说话...');
|
||||
};
|
||||
|
||||
recognition.onresult = function(event) {
|
||||
let finalTranscript = '';
|
||||
let interimTranscript = '';
|
||||
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const transcript = event.results[i][0].transcript;
|
||||
if (event.results[i].isFinal) {
|
||||
finalTranscript += transcript;
|
||||
} else {
|
||||
interimTranscript += transcript;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalTranscript) {
|
||||
agentInput.value = finalTranscript.trim();
|
||||
} else if (interimTranscript) {
|
||||
agentInput.value = interimTranscript.trim();
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onerror = function(event) {
|
||||
console.error('语音识别错误:', event.error);
|
||||
isRecordingVoice = false;
|
||||
updateVoiceButton(false);
|
||||
|
||||
if (event.error !== 'aborted') {
|
||||
setStatusMessage(getRecognitionErrorMessage(event.error));
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onend = function() {
|
||||
const hasText = Boolean(agentInput.value.trim());
|
||||
isRecordingVoice = false;
|
||||
updateVoiceButton(false);
|
||||
|
||||
if (hasText && shouldAutoSend) {
|
||||
window.setTimeout(function() {
|
||||
sendAgentBtn.click();
|
||||
}, 300);
|
||||
} else if (!hasText) {
|
||||
setStatusMessage('语音识别已结束。');
|
||||
}
|
||||
|
||||
shouldAutoSend = false;
|
||||
};
|
||||
}
|
||||
|
||||
async function startVoiceRecognition() {
|
||||
if (!SpeechRecognition) {
|
||||
setStatusMessage('当前浏览器不支持语音识别,请使用最新版 Chrome 或 Edge。');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRecordingVoice) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recognition) {
|
||||
initSpeechRecognition();
|
||||
}
|
||||
|
||||
const permissionReady = await ensureMicrophonePermission();
|
||||
if (!permissionReady || !recognition) {
|
||||
return;
|
||||
}
|
||||
|
||||
shouldAutoSend = true;
|
||||
|
||||
try {
|
||||
recognition.start();
|
||||
} catch (error) {
|
||||
console.error('启动语音识别失败:', error);
|
||||
|
||||
if (error.name === 'InvalidStateError') {
|
||||
try {
|
||||
recognition.stop();
|
||||
} catch (stopError) {
|
||||
console.error('停止语音识别失败:', stopError);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setStatusMessage(`启动语音识别失败:${error.message || error.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function stopVoiceRecognition() {
|
||||
if (recognition && isRecordingVoice) {
|
||||
recognition.stop();
|
||||
}
|
||||
}
|
||||
|
||||
if (!SpeechRecognition) {
|
||||
voiceBtn.disabled = true;
|
||||
voiceBtn.title = '当前浏览器不支持语音识别';
|
||||
setStatusMessage('当前浏览器不支持语音识别,请使用最新版 Chrome 或 Edge。');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasSecureVoiceContext()) {
|
||||
voiceBtn.title = '当前页面不是 HTTPS 或 localhost,浏览器不会授予麦克风权限';
|
||||
setStatusMessage(getSecureContextMessage());
|
||||
}
|
||||
|
||||
voiceBtn.addEventListener('pointerdown', async function(event) {
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
shouldAutoSend = true;
|
||||
|
||||
if (voiceBtn.setPointerCapture) {
|
||||
try {
|
||||
voiceBtn.setPointerCapture(event.pointerId);
|
||||
} catch (error) {
|
||||
console.debug('setPointerCapture skipped:', error);
|
||||
}
|
||||
}
|
||||
|
||||
await startVoiceRecognition();
|
||||
});
|
||||
|
||||
voiceBtn.addEventListener('pointerup', function(event) {
|
||||
event.preventDefault();
|
||||
stopVoiceRecognition();
|
||||
});
|
||||
|
||||
voiceBtn.addEventListener('pointercancel', function() {
|
||||
stopVoiceRecognition();
|
||||
});
|
||||
|
||||
voiceBtn.addEventListener('lostpointercapture', function() {
|
||||
stopVoiceRecognition();
|
||||
});
|
||||
|
||||
voiceBtn.addEventListener('contextmenu', function(event) {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.repeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.code === 'Space' && event.target.tagName !== 'INPUT' && event.target.tagName !== 'TEXTAREA') {
|
||||
event.preventDefault();
|
||||
|
||||
if (!spacePressed) {
|
||||
spacePressed = true;
|
||||
shouldAutoSend = true;
|
||||
startVoiceRecognition();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keyup', function(event) {
|
||||
if (event.code === 'Space' && event.target.tagName !== 'INPUT' && event.target.tagName !== 'TEXTAREA') {
|
||||
event.preventDefault();
|
||||
spacePressed = false;
|
||||
stopVoiceRecognition();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "test1",
|
||||
"actions": [
|
||||
{
|
||||
"direction": "stop",
|
||||
"timestamp": 1632
|
||||
},
|
||||
{
|
||||
"direction": "forward_left",
|
||||
"timestamp": 5172
|
||||
},
|
||||
{
|
||||
"direction": "stop",
|
||||
"timestamp": 5581
|
||||
},
|
||||
{
|
||||
"direction": "backward_right",
|
||||
"timestamp": 7103
|
||||
},
|
||||
{
|
||||
"direction": "stop",
|
||||
"timestamp": 7481
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "test2",
|
||||
"actions": [
|
||||
{
|
||||
"direction": "forward",
|
||||
"timestamp": 1734
|
||||
},
|
||||
{
|
||||
"direction": "stop",
|
||||
"timestamp": 2074
|
||||
},
|
||||
{
|
||||
"direction": "backward",
|
||||
"timestamp": 3287
|
||||
},
|
||||
{
|
||||
"direction": "stop",
|
||||
"timestamp": 3656
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import time
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
import re
|
||||
import threading
|
||||
import urllib3
|
||||
from queue import Queue
|
||||
|
||||
task_queue = Queue()
|
||||
|
||||
llm_config = {
|
||||
"api_url": "",
|
||||
"api_key": "",
|
||||
"model": ""
|
||||
}
|
||||
|
||||
LOCAL_API_BASE_URL = os.getenv("LOCAL_API_BASE_URL", "https://localhost:5443")
|
||||
LOCAL_API_VERIFY = os.getenv("LOCAL_API_VERIFY", "0").lower() in ("1", "true", "yes")
|
||||
|
||||
if not LOCAL_API_VERIFY:
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
def local_api_request(method, path, **kwargs):
|
||||
kwargs.setdefault("timeout", 5)
|
||||
kwargs.setdefault("verify", LOCAL_API_VERIFY)
|
||||
return requests.request(method, f"{LOCAL_API_BASE_URL}{path}", **kwargs)
|
||||
|
||||
def load_skills_md():
|
||||
try:
|
||||
with open('agent/skills.md', 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
print(f"加载技能文档出错: {e}")
|
||||
return ""
|
||||
|
||||
def llm_call(prompt):
|
||||
if not llm_config["api_url"] or not llm_config["api_key"] or not llm_config["model"]:
|
||||
return {"action": "stop", "args": {}}
|
||||
|
||||
try:
|
||||
# 确保API URL以/chat/completions结尾(除了讯飞MaaS API)
|
||||
api_url = llm_config["api_url"].strip()
|
||||
# 对于非讯飞MaaS API,自动添加/chat/completions路径
|
||||
if "maas-api.cn" not in api_url and not api_url.endswith("/chat/completions"):
|
||||
if api_url.endswith("/"):
|
||||
api_url += "chat/completions"
|
||||
else:
|
||||
api_url += "/chat/completions"
|
||||
|
||||
# 准备请求头
|
||||
headers = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# 检查API Key格式,如果是client_id:client_secret格式,使用Basic认证
|
||||
api_key = llm_config["api_key"]
|
||||
if ":" in api_key:
|
||||
# 讯飞MaaS API格式:client_id:client_secret
|
||||
import base64
|
||||
auth_str = base64.b64encode(api_key.encode()).decode()
|
||||
headers["Authorization"] = f"Basic {auth_str}"
|
||||
else:
|
||||
# OpenAI API格式:Bearer token
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
# 构建请求数据
|
||||
# 优先使用讯飞MaaS API格式
|
||||
if "maas-api.cn" in api_url:
|
||||
# 讯飞MaaS API格式
|
||||
data = {
|
||||
"model": llm_config["model"],
|
||||
"messages": [
|
||||
{"role": "system", "content": "你是一个智能垃圾桶控制助手,请根据用户输入返回对应的动作指令。"},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 500
|
||||
}
|
||||
else:
|
||||
# OpenAI API格式
|
||||
data = {
|
||||
"model": llm_config["model"],
|
||||
"messages": [
|
||||
{"role": "system", "content": "你是一个智能垃圾桶控制助手,请根据用户输入返回对应的动作指令。"},
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
}
|
||||
|
||||
print(f"调用LLM API: {api_url}")
|
||||
print(f"请求数据: {json.dumps(data, ensure_ascii=False)}")
|
||||
|
||||
response = requests.post(api_url, headers=headers, json=data, timeout=15)
|
||||
print(f"API响应状态码: {response.status_code}")
|
||||
print(f"API响应内容: {response.text}")
|
||||
|
||||
# 检查响应状态码
|
||||
if response.status_code != 200:
|
||||
print(f"API调用失败,状态码: {response.status_code}")
|
||||
return {"action": "stop", "args": {}}
|
||||
|
||||
try:
|
||||
result = response.json()
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"JSON解析错误: {e}")
|
||||
return {"action": "stop", "args": {}}
|
||||
|
||||
# 处理不同API的响应格式
|
||||
if "maas-api.cn" in api_url:
|
||||
# 讯飞MaaS API响应格式
|
||||
if "choices" in result and len(result["choices"]) > 0:
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
return safe_parse(content)
|
||||
else:
|
||||
return {"action": "stop", "args": {}}
|
||||
else:
|
||||
# OpenAI API响应格式
|
||||
if "choices" in result and len(result["choices"]) > 0:
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
return safe_parse(content)
|
||||
else:
|
||||
return {"action": "stop", "args": {}}
|
||||
except Exception as e:
|
||||
print(f"LLM调用出错: {e}")
|
||||
return {"action": "stop", "args": {}}
|
||||
|
||||
def safe_parse(result):
|
||||
try:
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
result = result.strip()
|
||||
if result.startswith("```"):
|
||||
lines = result.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if not line.startswith("```") and line.strip():
|
||||
result = "\n".join(lines[i:])
|
||||
break
|
||||
if result.startswith("```"):
|
||||
return {"action": "stop", "args": {}}
|
||||
if result.endswith("```"):
|
||||
result = result[:-3].strip()
|
||||
|
||||
# 尝试解析为JSON数组(多个动作)
|
||||
try:
|
||||
parsed = json.loads(result)
|
||||
if isinstance(parsed, list):
|
||||
return parsed
|
||||
except:
|
||||
pass
|
||||
|
||||
# 尝试解析为JSON对象(单个动作)
|
||||
for line in result.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("{") and line.endswith("}"):
|
||||
return json.loads(line)
|
||||
|
||||
match = re.search(r'\{[^}]+\}', result)
|
||||
if match:
|
||||
return json.loads(match.group())
|
||||
|
||||
return {"action": "stop", "args": {}}
|
||||
except:
|
||||
return {"action": "stop", "args": {}}
|
||||
|
||||
def execute_skill(action, args, motor_module):
|
||||
ALLOWED = [
|
||||
"move_forward", "move_backward", "turn_left", "turn_right",
|
||||
"stop", "play_path", "list_paths", "delete_path", "save_path"
|
||||
]
|
||||
|
||||
if action not in ALLOWED:
|
||||
return {"status": "error", "message": "不允许的动作"}
|
||||
|
||||
try:
|
||||
if action == "move_forward":
|
||||
print("控制垃圾桶前进")
|
||||
motor_module.backward(speed=0.6)
|
||||
def stop_after_duration():
|
||||
time.sleep(args.get("duration", 1))
|
||||
motor_module.stop()
|
||||
threading.Thread(target=stop_after_duration).start()
|
||||
return {"status": "success", "message": f"前进{args.get('duration', 1)}秒"}
|
||||
|
||||
elif action == "move_backward":
|
||||
print("控制垃圾桶后退")
|
||||
motor_module.forward(speed=0.6)
|
||||
def stop_after_duration():
|
||||
time.sleep(args.get("duration", 1))
|
||||
motor_module.stop()
|
||||
threading.Thread(target=stop_after_duration).start()
|
||||
return {"status": "success", "message": f"后退{args.get('duration', 1)}秒"}
|
||||
|
||||
elif action == "turn_left":
|
||||
print("控制垃圾桶左旋转")
|
||||
motor_module.rotate_left(speed=0.6)
|
||||
def stop_after_duration():
|
||||
time.sleep(args.get("duration", 1))
|
||||
motor_module.stop()
|
||||
threading.Thread(target=stop_after_duration).start()
|
||||
return {"status": "success", "message": f"左转{args.get('duration', 1)}秒"}
|
||||
|
||||
elif action == "turn_right":
|
||||
print("控制垃圾桶右旋转")
|
||||
motor_module.rotate_right(speed=0.6)
|
||||
def stop_after_duration():
|
||||
time.sleep(args.get("duration", 1))
|
||||
motor_module.stop()
|
||||
threading.Thread(target=stop_after_duration).start()
|
||||
return {"status": "success", "message": f"右转{args.get('duration', 1)}秒"}
|
||||
|
||||
elif action == "stop":
|
||||
print("停止垃圾桶")
|
||||
motor_module.stop()
|
||||
return {"status": "success", "message": "已停止"}
|
||||
|
||||
elif action == "play_path":
|
||||
path_name = args.get("name")
|
||||
if not path_name:
|
||||
return {"status": "error", "message": "路径名称不能为空"}
|
||||
# 调用加载轨迹API
|
||||
try:
|
||||
response = local_api_request("GET", "/load_path", params={"name": path_name})
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get("status") == "success":
|
||||
# 播放轨迹逻辑
|
||||
actions = data.get("data", {}).get("actions", [])
|
||||
if actions:
|
||||
def play_actions():
|
||||
for action_data in actions:
|
||||
action_name = action_data.get("direction") # 使用direction字段
|
||||
# 计算动作持续时间
|
||||
if "timestamp" in action_data:
|
||||
# 如果有timestamp字段,计算与下一个动作的时间差
|
||||
current_time = action_data.get("timestamp")
|
||||
# 找到下一个动作
|
||||
next_index = actions.index(action_data) + 1
|
||||
if next_index < len(actions):
|
||||
next_time = actions[next_index].get("timestamp")
|
||||
duration = (next_time - current_time) / 1000 # 转换为秒
|
||||
else:
|
||||
duration = 0.5 # 默认持续时间
|
||||
else:
|
||||
duration = action_data.get("time", 0.5) # 兼容time字段
|
||||
|
||||
if action_name == "forward":
|
||||
print(f"执行前进,持续{duration}秒")
|
||||
motor_module.backward(speed=0.6)
|
||||
time.sleep(duration)
|
||||
motor_module.stop()
|
||||
elif action_name == "backward":
|
||||
print(f"执行后退,持续{duration}秒")
|
||||
motor_module.forward(speed=0.6)
|
||||
time.sleep(duration)
|
||||
motor_module.stop()
|
||||
elif action_name == "left":
|
||||
print(f"执行左移,持续{duration}秒")
|
||||
motor_module.move_left(speed=0.6)
|
||||
time.sleep(duration)
|
||||
motor_module.stop()
|
||||
elif action_name == "right":
|
||||
print(f"执行右移,持续{duration}秒")
|
||||
motor_module.move_right(speed=0.6)
|
||||
time.sleep(duration)
|
||||
motor_module.stop()
|
||||
elif action_name == "forward_left":
|
||||
print(f"执行左前移动,持续{duration}秒")
|
||||
motor_module.move_left_forward(speed=0.6)
|
||||
time.sleep(duration)
|
||||
motor_module.stop()
|
||||
elif action_name == "forward_right":
|
||||
print(f"执行右前移动,持续{duration}秒")
|
||||
motor_module.move_right_forward(speed=0.6)
|
||||
time.sleep(duration)
|
||||
motor_module.stop()
|
||||
elif action_name == "backward_left":
|
||||
print(f"执行左后移动,持续{duration}秒")
|
||||
motor_module.move_left_backward(speed=0.6)
|
||||
time.sleep(duration)
|
||||
motor_module.stop()
|
||||
elif action_name == "backward_right":
|
||||
print(f"执行右后移动,持续{duration}秒")
|
||||
motor_module.move_right_backward(speed=0.6)
|
||||
time.sleep(duration)
|
||||
motor_module.stop()
|
||||
elif action_name == "rotate_left":
|
||||
print(f"执行左旋转,持续{duration}秒")
|
||||
motor_module.rotate_left(speed=0.6)
|
||||
time.sleep(duration)
|
||||
motor_module.stop()
|
||||
elif action_name == "rotate_right":
|
||||
print(f"执行右旋转,持续{duration}秒")
|
||||
motor_module.rotate_right(speed=0.6)
|
||||
time.sleep(duration)
|
||||
motor_module.stop()
|
||||
elif action_name == "stop":
|
||||
print("执行停止")
|
||||
motor_module.stop()
|
||||
time.sleep(0.1) # 短暂停止
|
||||
# 可以添加其他方向的处理
|
||||
time.sleep(0.1) # 动作间隔
|
||||
threading.Thread(target=play_actions).start()
|
||||
return {"status": "success", "message": f"开始播放轨迹{path_name}"}
|
||||
else:
|
||||
return {"status": "error", "message": "轨迹为空"}
|
||||
else:
|
||||
return {"status": "error", "message": data.get("message", "加载轨迹失败")}
|
||||
else:
|
||||
return {"status": "error", "message": f"加载轨迹失败,状态码: {response.status_code}"}
|
||||
except Exception as e:
|
||||
print(f"播放轨迹出错: {e}")
|
||||
return {"status": "error", "message": f"播放轨迹出错: {e}"}
|
||||
|
||||
elif action == "list_paths":
|
||||
# 调用列出轨迹API
|
||||
try:
|
||||
response = local_api_request("GET", "/list_paths")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get("status") == "success":
|
||||
paths = data.get("paths", [])
|
||||
if paths:
|
||||
return {"status": "success", "message": f"轨迹列表: {', '.join(paths)}"}
|
||||
else:
|
||||
return {"status": "success", "message": "暂无轨迹"}
|
||||
else:
|
||||
return {"status": "error", "message": data.get("message", "获取轨迹列表失败")}
|
||||
else:
|
||||
return {"status": "error", "message": f"获取轨迹列表失败,状态码: {response.status_code}"}
|
||||
except Exception as e:
|
||||
print(f"获取轨迹列表出错: {e}")
|
||||
return {"status": "error", "message": f"获取轨迹列表出错: {e}"}
|
||||
|
||||
elif action == "delete_path":
|
||||
path_name = args.get("name")
|
||||
if not path_name:
|
||||
return {"status": "error", "message": "路径名称不能为空"}
|
||||
# 调用删除轨迹API
|
||||
try:
|
||||
response = local_api_request("DELETE", "/delete_path", json={"name": path_name})
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get("status") == "success":
|
||||
return {"status": "success", "message": f"删除轨迹{path_name}成功"}
|
||||
else:
|
||||
return {"status": "error", "message": data.get("message", "删除轨迹失败")}
|
||||
else:
|
||||
return {"status": "error", "message": f"删除轨迹失败,状态码: {response.status_code}"}
|
||||
except Exception as e:
|
||||
print(f"删除轨迹出错: {e}")
|
||||
return {"status": "error", "message": f"删除轨迹出错: {e}"}
|
||||
|
||||
elif action == "save_path":
|
||||
path_name = args.get("name")
|
||||
if not path_name:
|
||||
return {"status": "error", "message": "路径名称不能为空"}
|
||||
# 这里需要获取当前录制的轨迹,暂时返回成功消息
|
||||
return {"status": "success", "message": f"保存轨迹{path_name}成功"}
|
||||
|
||||
else:
|
||||
return {"status": "error", "message": "无效的动作"}
|
||||
except Exception as e:
|
||||
print(f"执行技能出错: {e}")
|
||||
return {"status": "error", "message": f"执行技能出错: {e}"}
|
||||
|
||||
def create_agent_routes(app, motor_module):
|
||||
@app.route('/agent_config', methods=['POST'])
|
||||
def agent_config():
|
||||
from flask import request, jsonify
|
||||
data = request.get_json()
|
||||
api_url = data.get('api_url', '')
|
||||
api_key = data.get('api_key', '')
|
||||
model = data.get('model', '')
|
||||
|
||||
llm_config['api_url'] = api_url
|
||||
llm_config['api_key'] = api_key
|
||||
llm_config['model'] = model
|
||||
|
||||
print(f"LLM配置已更新: API={api_url}, Model={model}")
|
||||
return jsonify({'status': 'success', 'message': '配置已保存'})
|
||||
|
||||
@app.route('/agent_config', methods=['GET'])
|
||||
def get_agent_config():
|
||||
from flask import jsonify
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'api_url': llm_config['api_url'],
|
||||
'api_key': llm_config['api_key'],
|
||||
'model': llm_config['model']
|
||||
})
|
||||
|
||||
@app.route('/agent_chat', methods=['POST'])
|
||||
def agent_chat():
|
||||
from flask import request, jsonify
|
||||
data = request.get_json()
|
||||
text = data.get('text')
|
||||
|
||||
if not text:
|
||||
return jsonify({'status': 'error', 'message': '输入文本不能为空'})
|
||||
|
||||
try:
|
||||
skills_prompt = load_skills_md()
|
||||
prompt = skills_prompt + "\n用户输入:" + text
|
||||
result = llm_call(prompt)
|
||||
parsed_result = safe_parse(result)
|
||||
|
||||
# 检查是否是多个动作(列表)
|
||||
if isinstance(parsed_result, list) and len(parsed_result) > 0:
|
||||
# 执行第一个动作
|
||||
first_action = parsed_result[0]
|
||||
action = first_action.get("action", "stop")
|
||||
args = first_action.get("args", {})
|
||||
task_queue.put((action, args))
|
||||
response = execute_skill(action, args, motor_module)
|
||||
|
||||
# 如果有后续动作,在第一个动作完成后执行
|
||||
if len(parsed_result) > 1:
|
||||
def execute_next_actions():
|
||||
for i, next_action_data in enumerate(parsed_result[1:]):
|
||||
# 等待前一个动作完成
|
||||
time.sleep(next_action_data.get("args", {}).get("duration", 1))
|
||||
# 添加0.5秒缓冲时间
|
||||
time.sleep(0.5)
|
||||
next_action = next_action_data.get("action", "stop")
|
||||
next_args = next_action_data.get("args", {})
|
||||
task_queue.put((next_action, next_args))
|
||||
execute_skill(next_action, next_args, motor_module)
|
||||
|
||||
threading.Thread(target=execute_next_actions).start()
|
||||
|
||||
return jsonify({"status": "success", "message": f"开始执行复合指令,共{len(parsed_result)}个动作"})
|
||||
else:
|
||||
# 单个动作的处理
|
||||
action = parsed_result.get("action", "stop")
|
||||
args = parsed_result.get("args", {})
|
||||
task_queue.put((action, args))
|
||||
response = execute_skill(action, args, motor_module)
|
||||
return jsonify(response)
|
||||
except Exception as e:
|
||||
print(f"Agent聊天出错: {e}")
|
||||
return jsonify({'status': 'error', 'message': f'Agent聊天出错: {e}'})
|
||||
@@ -0,0 +1,108 @@
|
||||
# 垃圾桶控制技能文档
|
||||
|
||||
你是一个智能垃圾桶控制助手,可以调用以下技能控制垃圾桶运动。你必须而且只能返回JSON格式的指令,不要返回任何其他内容。
|
||||
|
||||
---
|
||||
|
||||
## 一、基础运动技能
|
||||
|
||||
### 1. move_forward
|
||||
向前移动垃圾桶
|
||||
|
||||
参数:
|
||||
- duration: 持续时间(秒)
|
||||
|
||||
### 2. move_backward
|
||||
向后移动垃圾桶
|
||||
|
||||
参数:
|
||||
- duration: 秒
|
||||
|
||||
### 3. turn_left
|
||||
左转
|
||||
|
||||
参数:
|
||||
- duration: 秒
|
||||
|
||||
### 4. turn_right
|
||||
右转
|
||||
|
||||
参数:
|
||||
- duration: 秒
|
||||
|
||||
### 5. stop
|
||||
停止所有运动
|
||||
|
||||
参数:无
|
||||
|
||||
---
|
||||
|
||||
## 二、轨迹技能(重要)
|
||||
|
||||
### 6. play_path
|
||||
播放已保存轨迹
|
||||
|
||||
参数:
|
||||
- name: 轨迹名称
|
||||
|
||||
说明:
|
||||
调用系统中已经录制好的路径,让垃圾桶自动执行。
|
||||
|
||||
### 7. save_path
|
||||
保存当前录制轨迹
|
||||
|
||||
参数:
|
||||
- name: 轨迹名称
|
||||
|
||||
### 8. list_paths
|
||||
获取所有轨迹列表
|
||||
|
||||
参数:无
|
||||
|
||||
### 9. delete_path
|
||||
删除轨迹
|
||||
|
||||
参数:
|
||||
- name: 轨迹名称
|
||||
|
||||
---
|
||||
|
||||
## 三、规则(必须严格遵守)
|
||||
|
||||
1. **只能返回JSON格式,不要返回任何解释或额外文字**
|
||||
2. 用户说"停"、"停止" → stop
|
||||
3. 用户说"前进X秒" → move_forward,duration为X
|
||||
4. 用户说"后退X秒" → move_backward,duration为X
|
||||
5. 用户说"左转X秒" → turn_left,duration为X
|
||||
6. 用户说"右转X秒" → turn_right,duration为X
|
||||
7. 用户说"执行路径xxx" → play_path,name为xxx
|
||||
8. 用户说"列出路径" → list_paths
|
||||
9. 用户说"删除路径xxx" → delete_path,name为xxx
|
||||
10. **用户说"动作1之后动作2"或"动作1然后动作2"等复合指令时,返回包含多个动作的JSON数组**
|
||||
例如:"前进1秒之后回退1秒" → [{"action": "move_forward", "args": {"duration": 1}}, {"action": "move_backward", "args": {"duration": 1}}]
|
||||
11. 如果不确定运动时间,duration默认1秒
|
||||
12. 如果无法理解用户意图,返回:{"action": "stop", "args": {}}
|
||||
|
||||
---
|
||||
|
||||
## 四、输出格式示例
|
||||
|
||||
用户说:前进3秒
|
||||
输出:
|
||||
{"action": "move_forward", "args": {"duration": 3}}
|
||||
|
||||
用户说:停止
|
||||
输出:
|
||||
{"action": "stop", "args": {}}
|
||||
|
||||
用户说:执行路径test1
|
||||
输出:
|
||||
{"action": "play_path", "args": {"name": "test1"}}
|
||||
|
||||
用户说:前进1秒之后回退1秒
|
||||
输出:
|
||||
[{"action": "move_forward", "args": {"duration": 1}}, {"action": "move_backward", "args": {"duration": 1}}]
|
||||
|
||||
---
|
||||
|
||||
**重要:你只能返回JSON格式的指令,不要包含任何其他文字、解释或格式符号(如markdown代码块)。**
|
||||
@@ -1,70 +1,395 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
import motor
|
||||
import time
|
||||
import math
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
import motor
|
||||
|
||||
|
||||
FRAME_WIDTH = 320
|
||||
FRAME_HEIGHT = 240
|
||||
GUIDE_CENTER_X = 160
|
||||
GUIDE_CENTER_Y = 120
|
||||
CONTROL_DEADZONE = 15
|
||||
|
||||
MIN_CONTOUR_AREA = 90
|
||||
MAX_CONTOUR_AREA = 6000
|
||||
EDGE_IGNORE = 12
|
||||
TARGET_LOCK_DURATION = 3.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackState:
|
||||
cx: int
|
||||
cy: int
|
||||
area: float
|
||||
bbox: Tuple[int, int, int, int]
|
||||
vx: float = 0.0
|
||||
vy: float = 0.0
|
||||
misses: int = 0
|
||||
visible: bool = True
|
||||
|
||||
|
||||
# 信号处理函数,用于捕获Ctrl+C
|
||||
def signal_handler(sig, frame):
|
||||
print("\n接收到中断信号,正在停止电机...")
|
||||
print("\nStop signal received, stopping motors...")
|
||||
motor.stop()
|
||||
print("电机已停止,程序退出")
|
||||
print("Motors stopped, exiting.")
|
||||
sys.exit(0)
|
||||
|
||||
# 注册信号处理函数
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
print("开始运行摄像头程序...")
|
||||
print(f"OpenCV版本: {cv2.__version__}")
|
||||
|
||||
# 尝试打开摄像头
|
||||
def estimate_camera_motion(prev_gray, gray):
|
||||
identity = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], dtype=np.float32)
|
||||
|
||||
prev_pts = cv2.goodFeaturesToTrack(
|
||||
prev_gray,
|
||||
maxCorners=180,
|
||||
qualityLevel=0.01,
|
||||
minDistance=8,
|
||||
blockSize=7,
|
||||
)
|
||||
if prev_pts is None or len(prev_pts) < 8:
|
||||
return identity, 0.0, 0
|
||||
|
||||
curr_pts, status, _ = cv2.calcOpticalFlowPyrLK(
|
||||
prev_gray,
|
||||
gray,
|
||||
prev_pts,
|
||||
None,
|
||||
winSize=(21, 21),
|
||||
maxLevel=3,
|
||||
criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 30, 0.01),
|
||||
)
|
||||
if curr_pts is None or status is None:
|
||||
return identity, 0.0, 0
|
||||
|
||||
valid = status.reshape(-1) == 1
|
||||
prev_valid = prev_pts[valid].reshape(-1, 2)
|
||||
curr_valid = curr_pts[valid].reshape(-1, 2)
|
||||
if len(prev_valid) < 8:
|
||||
return identity, 0.0, len(prev_valid)
|
||||
|
||||
transform, inliers = cv2.estimateAffinePartial2D(
|
||||
prev_valid,
|
||||
curr_valid,
|
||||
method=cv2.RANSAC,
|
||||
ransacReprojThreshold=3.0,
|
||||
maxIters=2000,
|
||||
confidence=0.99,
|
||||
)
|
||||
if transform is None:
|
||||
return identity, 0.0, len(prev_valid)
|
||||
|
||||
tx = float(transform[0, 2])
|
||||
ty = float(transform[1, 2])
|
||||
rotation = math.degrees(math.atan2(transform[1, 0], transform[0, 0]))
|
||||
motion_level = math.hypot(tx, ty) + abs(rotation) * 1.5
|
||||
|
||||
inlier_count = int(inliers.sum()) if inliers is not None else len(prev_valid)
|
||||
return transform.astype(np.float32), motion_level, inlier_count
|
||||
|
||||
|
||||
def build_motion_mask(prev_gray, gray, transform, motion_level):
|
||||
height, width = gray.shape
|
||||
|
||||
aligned_prev = cv2.warpAffine(
|
||||
prev_gray,
|
||||
transform,
|
||||
(width, height),
|
||||
flags=cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_REPLICATE,
|
||||
)
|
||||
|
||||
valid_region = cv2.warpAffine(
|
||||
np.full_like(prev_gray, 255),
|
||||
transform,
|
||||
(width, height),
|
||||
flags=cv2.INTER_NEAREST,
|
||||
borderMode=cv2.BORDER_CONSTANT,
|
||||
borderValue=0,
|
||||
)
|
||||
|
||||
diff = cv2.absdiff(aligned_prev, gray)
|
||||
diff = cv2.GaussianBlur(diff, (5, 5), 0)
|
||||
|
||||
threshold_value = int(min(40, 18 + motion_level * 1.5))
|
||||
_, mask = cv2.threshold(diff, threshold_value, 255, cv2.THRESH_BINARY)
|
||||
|
||||
if EDGE_IGNORE > 0:
|
||||
mask[:EDGE_IGNORE, :] = 0
|
||||
mask[-EDGE_IGNORE:, :] = 0
|
||||
mask[:, :EDGE_IGNORE] = 0
|
||||
mask[:, -EDGE_IGNORE:] = 0
|
||||
|
||||
mask = cv2.bitwise_and(mask, valid_region)
|
||||
|
||||
kernel_small = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
|
||||
kernel_large = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_small)
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_large)
|
||||
mask = cv2.dilate(mask, kernel_small, iterations=1)
|
||||
|
||||
return mask
|
||||
|
||||
|
||||
def contour_candidates(mask):
|
||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
candidates = []
|
||||
height, width = mask.shape
|
||||
|
||||
for contour in contours:
|
||||
area = cv2.contourArea(contour)
|
||||
if area < MIN_CONTOUR_AREA or area > MAX_CONTOUR_AREA:
|
||||
continue
|
||||
|
||||
x, y, w, h = cv2.boundingRect(contour)
|
||||
if x <= EDGE_IGNORE or y <= EDGE_IGNORE:
|
||||
continue
|
||||
if x + w >= width - EDGE_IGNORE or y + h >= height - EDGE_IGNORE:
|
||||
continue
|
||||
|
||||
aspect_ratio = float(w) / h if h else 0.0
|
||||
if aspect_ratio < 0.25 or aspect_ratio > 4.0:
|
||||
continue
|
||||
|
||||
rect_area = float(w * h)
|
||||
fill_ratio = area / rect_area if rect_area else 0.0
|
||||
if fill_ratio < 0.18:
|
||||
continue
|
||||
|
||||
cx = x + w // 2
|
||||
cy = y + h // 2
|
||||
candidates.append(
|
||||
{
|
||||
"contour": contour,
|
||||
"bbox": (x, y, w, h),
|
||||
"area": float(area),
|
||||
"cx": cx,
|
||||
"cy": cy,
|
||||
}
|
||||
)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def bbox_iou(box_a, box_b):
|
||||
ax, ay, aw, ah = box_a
|
||||
bx, by, bw, bh = box_b
|
||||
|
||||
left = max(ax, bx)
|
||||
top = max(ay, by)
|
||||
right = min(ax + aw, bx + bw)
|
||||
bottom = min(ay + ah, by + bh)
|
||||
|
||||
if right <= left or bottom <= top:
|
||||
return 0.0
|
||||
|
||||
intersection = (right - left) * (bottom - top)
|
||||
union = aw * ah + bw * bh - intersection
|
||||
return intersection / union if union > 0 else 0.0
|
||||
|
||||
|
||||
def select_target(candidates, track_state):
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
if track_state is None:
|
||||
return max(
|
||||
candidates,
|
||||
key=lambda c: c["area"] - 0.6 * abs(c["cx"] - GUIDE_CENTER_X),
|
||||
)
|
||||
|
||||
predicted_x = track_state.cx + track_state.vx
|
||||
predicted_y = track_state.cy + track_state.vy
|
||||
best_candidate = None
|
||||
best_score = -1e9
|
||||
|
||||
base_radius = max(55.0, math.sqrt(max(track_state.area, 1.0)) * 2.5)
|
||||
search_radius = base_radius + min(track_state.misses * 18.0, 60.0)
|
||||
|
||||
for candidate in candidates:
|
||||
distance = math.hypot(candidate["cx"] - predicted_x, candidate["cy"] - predicted_y)
|
||||
overlap = bbox_iou(candidate["bbox"], track_state.bbox)
|
||||
area_ratio = candidate["area"] / max(track_state.area, 1.0)
|
||||
area_penalty = abs(math.log(max(area_ratio, 1e-6)))
|
||||
|
||||
if distance > search_radius and overlap < 0.02:
|
||||
continue
|
||||
|
||||
score = (
|
||||
candidate["area"] * 0.08
|
||||
+ overlap * 60.0
|
||||
- distance * 1.4
|
||||
- area_penalty * 22.0
|
||||
)
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_candidate = candidate
|
||||
|
||||
if best_candidate is not None:
|
||||
return best_candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def update_track_state(track_state, candidate):
|
||||
if candidate is None:
|
||||
return None
|
||||
|
||||
if track_state is None:
|
||||
return TrackState(
|
||||
cx=candidate["cx"],
|
||||
cy=candidate["cy"],
|
||||
area=candidate["area"],
|
||||
bbox=candidate["bbox"],
|
||||
)
|
||||
|
||||
if track_state.misses > 0:
|
||||
smooth_prev = 0.35
|
||||
else:
|
||||
smooth_prev = 0.60
|
||||
|
||||
new_cx = int(smooth_prev * track_state.cx + (1.0 - smooth_prev) * candidate["cx"])
|
||||
new_cy = int(smooth_prev * track_state.cy + (1.0 - smooth_prev) * candidate["cy"])
|
||||
|
||||
raw_vx = new_cx - track_state.cx
|
||||
raw_vy = new_cy - track_state.cy
|
||||
new_vx = 0.6 * track_state.vx + 0.4 * raw_vx
|
||||
new_vy = 0.6 * track_state.vy + 0.4 * raw_vy
|
||||
|
||||
track_state.cx = new_cx
|
||||
track_state.cy = new_cy
|
||||
track_state.area = candidate["area"]
|
||||
track_state.bbox = candidate["bbox"]
|
||||
track_state.vx = new_vx
|
||||
track_state.vy = new_vy
|
||||
track_state.misses = 0
|
||||
track_state.visible = True
|
||||
return track_state
|
||||
|
||||
|
||||
def draw_debug(frame, mask, track_state, motion_level, inlier_count, lock_remaining):
|
||||
frame_width = frame.shape[1]
|
||||
cv2.circle(frame, (GUIDE_CENTER_X, GUIDE_CENTER_Y), 5, (255, 0, 0), -1)
|
||||
|
||||
cv2.putText(
|
||||
frame,
|
||||
f"cam:{motion_level:4.1f} feat:{inlier_count:3d}",
|
||||
(8, 18),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.45,
|
||||
(0, 255, 255),
|
||||
1,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
|
||||
if lock_remaining > 0:
|
||||
cv2.putText(
|
||||
frame,
|
||||
f"LOCK {lock_remaining:0.1f}s",
|
||||
(8, 38),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.55,
|
||||
(0, 0, 255),
|
||||
2,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
|
||||
if track_state is not None:
|
||||
x, y, w, h = track_state.bbox
|
||||
color = (0, 255, 0) if track_state.visible else (0, 165, 255)
|
||||
cv2.rectangle(frame, (x, y), (x + w, y + h), color, 2)
|
||||
cv2.circle(frame, (track_state.cx, track_state.cy), 5, (0, 0, 255), -1)
|
||||
|
||||
status = "track" if track_state.visible else f"hold:{track_state.misses}"
|
||||
cv2.putText(
|
||||
frame,
|
||||
status,
|
||||
(x, max(15, y - 8)),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.45,
|
||||
color,
|
||||
1,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
|
||||
mask_preview = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
|
||||
preview_h, preview_w = 60, 80
|
||||
mask_preview = cv2.resize(mask_preview, (preview_w, preview_h))
|
||||
frame[0:preview_h, frame_width - preview_w : frame_width] = mask_preview
|
||||
|
||||
|
||||
def drive_to_target(track_state, lock_active=False):
|
||||
if lock_active or track_state is None or not track_state.visible:
|
||||
motor.stop()
|
||||
return
|
||||
|
||||
dx = track_state.cx - GUIDE_CENTER_X
|
||||
dy = track_state.cy - GUIDE_CENTER_Y
|
||||
|
||||
if abs(dx) < CONTROL_DEADZONE and abs(dy) < CONTROL_DEADZONE:
|
||||
motor.stop()
|
||||
elif abs(dx) < CONTROL_DEADZONE:
|
||||
if dy > CONTROL_DEADZONE:
|
||||
motor.move_right(speed=1.0)
|
||||
else:
|
||||
motor.move_left(speed=1.0)
|
||||
elif abs(dy) < CONTROL_DEADZONE:
|
||||
if dx > CONTROL_DEADZONE:
|
||||
motor.forward(speed=1.0)
|
||||
else:
|
||||
motor.backward(speed=1.0)
|
||||
else:
|
||||
if dx > CONTROL_DEADZONE and dy > CONTROL_DEADZONE:
|
||||
motor.move_right_backward(speed=1.0)
|
||||
elif dx > CONTROL_DEADZONE and dy < -CONTROL_DEADZONE:
|
||||
motor.move_left_backward(speed=1.0)
|
||||
elif dx < -CONTROL_DEADZONE and dy > CONTROL_DEADZONE:
|
||||
motor.move_right_forward(speed=1.0)
|
||||
else:
|
||||
motor.move_left_forward(speed=1.0)
|
||||
|
||||
|
||||
print("Starting camera tracking...")
|
||||
print(f"OpenCV version: {cv2.__version__}")
|
||||
|
||||
cap = cv2.VideoCapture(0)
|
||||
|
||||
if not cap.isOpened():
|
||||
print("无法打开摄像头!")
|
||||
exit()
|
||||
print("Unable to open camera.")
|
||||
sys.exit(1)
|
||||
|
||||
print("摄像头打开成功")
|
||||
|
||||
# 设置摄像头参数
|
||||
cap.set(3, 320)
|
||||
cap.set(4, 240)
|
||||
cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
|
||||
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)
|
||||
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||
|
||||
print(f"摄像头分辨率: {cap.get(3)}x{cap.get(4)}")
|
||||
print(f"Camera ready: {cap.get(cv2.CAP_PROP_FRAME_WIDTH)}x{cap.get(cv2.CAP_PROP_FRAME_HEIGHT)}")
|
||||
|
||||
# 初始化稳定阶段
|
||||
print("初始化摄像头...")
|
||||
success_count = 0
|
||||
for i in range(10):
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
print(f"第{i+1}帧读取失败")
|
||||
continue
|
||||
success_count += 1
|
||||
print(f"第{i+1}帧读取成功")
|
||||
time.sleep(0.1)
|
||||
print(f"摄像头初始化完成,成功读取{success_count}帧")
|
||||
for _ in range(10):
|
||||
ret, _ = cap.read()
|
||||
if ret:
|
||||
success_count += 1
|
||||
time.sleep(0.05)
|
||||
print(f"Warm-up finished, successful reads: {success_count}/10")
|
||||
|
||||
# 读取初始帧
|
||||
ret, prev_frame = cap.read()
|
||||
if not ret:
|
||||
print("无法读取初始帧!")
|
||||
print("Unable to read initial frame.")
|
||||
cap.release()
|
||||
exit()
|
||||
sys.exit(1)
|
||||
|
||||
print("初始帧读取成功")
|
||||
prev_gray = cv2.cvtColor(prev_frame, cv2.COLOR_BGR2GRAY)
|
||||
prev_gray = cv2.GaussianBlur(prev_gray, (5,5), 0)
|
||||
print("初始帧处理完成")
|
||||
|
||||
# 记录上一帧目标的位置和面积
|
||||
prev_cx = None
|
||||
prev_cy = None
|
||||
prev_area = None
|
||||
# 目标跟踪状态
|
||||
tracking_target = None
|
||||
prev_gray = cv2.GaussianBlur(prev_gray, (5, 5), 0)
|
||||
track_state = None
|
||||
lock_until = 0.0
|
||||
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
@@ -72,205 +397,44 @@ while True:
|
||||
break
|
||||
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
gray = cv2.GaussianBlur(gray, (5,5), 0)
|
||||
gray = cv2.GaussianBlur(gray, (5, 5), 0)
|
||||
|
||||
# 帧差
|
||||
diff = cv2.absdiff(prev_gray, gray)
|
||||
diff = cv2.convertScaleAbs(diff, alpha=2.5)
|
||||
transform, motion_level, inlier_count = estimate_camera_motion(prev_gray, gray)
|
||||
motion_mask = build_motion_mask(prev_gray, gray, transform, motion_level)
|
||||
candidates = contour_candidates(motion_mask)
|
||||
|
||||
# 应用形态学操作,去除噪声
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||
diff = cv2.morphologyEx(diff, cv2.MORPH_OPEN, kernel)
|
||||
diff = cv2.morphologyEx(diff, cv2.MORPH_CLOSE, kernel)
|
||||
now = time.monotonic()
|
||||
lock_remaining = max(0.0, lock_until - now)
|
||||
lock_active = lock_remaining > 0.0
|
||||
|
||||
_, thresh = cv2.threshold(diff, 30, 255, cv2.THRESH_BINARY)
|
||||
|
||||
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
# ===== 核心:目标检测和跟踪 =====
|
||||
target = None
|
||||
max_area = 0
|
||||
|
||||
# 找到所有符合条件的轮廓
|
||||
valid_contours = []
|
||||
for cnt in contours:
|
||||
area = cv2.contourArea(cnt)
|
||||
|
||||
if area < 75: # 过滤噪声,增大阈值
|
||||
continue
|
||||
|
||||
if area > 8000: # 过滤大面积假目标(如整个画面),减小阈值
|
||||
continue
|
||||
|
||||
# 计算轮廓的宽高比,过滤不符合垃圾特征的目标
|
||||
x, y, w, h = cv2.boundingRect(cnt)
|
||||
aspect_ratio = float(w) / h if h > 0 else 0
|
||||
if aspect_ratio > 3 or aspect_ratio < 0.3: # 过滤过于狭长的目标
|
||||
continue
|
||||
|
||||
valid_contours.append((area, cnt, x, y, w, h))
|
||||
|
||||
# 如果有有效的轮廓
|
||||
if valid_contours:
|
||||
# 如果正在跟踪目标,优先选择与上一目标位置接近的轮廓
|
||||
if tracking_target is not None and prev_cx is not None and prev_cy is not None:
|
||||
best_match = None
|
||||
min_distance = float('inf')
|
||||
|
||||
search_radius = 100 # 目标锁定区域半径
|
||||
|
||||
for area, cnt, x, y, w, h in valid_contours:
|
||||
cx = x + w // 2
|
||||
cy = y + h // 2
|
||||
# 计算与上一目标的距离
|
||||
distance = ((cx - prev_cx) ** 2 + (cy - prev_cy) ** 2) ** 0.5
|
||||
|
||||
# 只考虑在锁定区域内的目标
|
||||
if distance > search_radius:
|
||||
continue
|
||||
|
||||
# 如果距离小于阈值,认为是同一个目标
|
||||
if distance < 50: # 距离阈值
|
||||
if distance < min_distance:
|
||||
min_distance = distance
|
||||
best_match = (area, cnt, x, y, w, h)
|
||||
|
||||
# 如果找到匹配的目标
|
||||
if best_match:
|
||||
area, cnt, x, y, w, h = best_match
|
||||
target = cnt
|
||||
max_area = area
|
||||
else:
|
||||
# 如果没有找到匹配的目标,选择最大的轮廓
|
||||
valid_contours.sort(key=lambda x: x[0], reverse=True)
|
||||
area, cnt, x, y, w, h = valid_contours[0]
|
||||
target = cnt
|
||||
max_area = area
|
||||
# 开始跟踪新目标
|
||||
tracking_target = True
|
||||
else:
|
||||
# 如果没有正在跟踪的目标,选择最大的轮廓
|
||||
valid_contours.sort(key=lambda x: x[0], reverse=True)
|
||||
area, cnt, x, y, w, h = valid_contours[0]
|
||||
target = cnt
|
||||
max_area = area
|
||||
# 开始跟踪新目标
|
||||
tracking_target = True
|
||||
|
||||
# ===== 只处理一个目标 =====
|
||||
if target is not None:
|
||||
x, y, w, h = cv2.boundingRect(target)
|
||||
cx = x + w // 2
|
||||
cy = y + h // 2
|
||||
|
||||
# 目标位置平滑处理
|
||||
alpha = 0.6
|
||||
if prev_cx is not None and prev_cy is not None:
|
||||
cx = int(alpha * prev_cx + (1 - alpha) * cx)
|
||||
cy = int(alpha * prev_cy + (1 - alpha) * cy)
|
||||
|
||||
# 绘制画面中心
|
||||
center_x = 50 # 画面中心x坐标
|
||||
center_y = 120 # 画面中心y坐标
|
||||
cv2.circle(frame, (center_x, center_y), 5, (255, 0, 0), -1)
|
||||
|
||||
# 绘制目标矩形和中心
|
||||
cv2.rectangle(frame, (x, y), (x+w, y+h), (0, 255, 0), 2)
|
||||
cv2.circle(frame, (cx, cy), 5, (0, 0, 255), -1)
|
||||
|
||||
print("唯一目标:", cx, cy, "area:", max_area)
|
||||
|
||||
# 检测目标是否突然跳变
|
||||
target_jumped = False
|
||||
if prev_cx is not None and prev_cy is not None and prev_area is not None:
|
||||
# 计算位置变化
|
||||
dx = abs(cx - prev_cx)
|
||||
dy = abs(cy - prev_cy)
|
||||
# 计算面积变化比例
|
||||
area_ratio = max_area / prev_area if prev_area > 0 else 0
|
||||
|
||||
# 如果位置变化过大或面积变化过大,认为目标跳变
|
||||
if dx > 100 or dy > 100 or area_ratio < 0.3 or area_ratio > 3:
|
||||
target_jumped = True
|
||||
print("目标突然跳变,可能已离开视野范围")
|
||||
# 重置跟踪状态
|
||||
tracking_target = None
|
||||
|
||||
# 控制逻辑:根据x轴和y轴坐标控制垃圾桶移动
|
||||
center_x = 160 # 画面中心x坐标
|
||||
center_y = 120 # 画面中心y坐标
|
||||
threshold = 15 # 阈值范围
|
||||
|
||||
if target_jumped:
|
||||
# 目标跳变,停止移动
|
||||
print("目标跳变,停止移动")
|
||||
motor.stop()
|
||||
else:
|
||||
# 计算目标与中心的偏差
|
||||
dx = cx - center_x
|
||||
dy = cy - center_y
|
||||
|
||||
# 确定移动方向
|
||||
if abs(dx) < threshold and abs(dy) < threshold:
|
||||
# 目标在中心附近,停止移动
|
||||
print("目标在中心,停止移动")
|
||||
motor.stop()
|
||||
elif abs(dx) < threshold:
|
||||
# 只在y轴方向有偏差
|
||||
if dy > threshold:
|
||||
# 目标在下侧,垃圾桶向右移动
|
||||
print("目标在下侧,向右移动")
|
||||
motor.move_right(speed=1.0)
|
||||
else:
|
||||
# 目标在上侧,垃圾桶向左移动
|
||||
print("目标在上侧,向左移动")
|
||||
motor.move_left(speed=1.0)
|
||||
elif abs(dy) < threshold:
|
||||
# 只在x轴方向有偏差
|
||||
if dx > threshold:
|
||||
# 目标在右侧,垃圾桶向后移动
|
||||
print("目标在右侧,向后移动")
|
||||
motor.backward(speed=1.0)
|
||||
else:
|
||||
# 目标在左侧,垃圾桶向前移动
|
||||
print("目标在左侧,向前移动")
|
||||
motor.forward(speed=1.0)
|
||||
else:
|
||||
# 在x轴和y轴方向都有偏差,使用斜向移动
|
||||
if dx > threshold and dy > threshold:
|
||||
# 目标在右下侧,垃圾桶向右后移动
|
||||
print("目标在右下侧,向右后移动")
|
||||
motor.move_right_backward(speed=1.0)
|
||||
elif dx > threshold and dy < -threshold:
|
||||
# 目标在右上侧,垃圾桶向左后移动
|
||||
print("目标在右上侧,向左后移动")
|
||||
motor.move_left_backward(speed=1.0)
|
||||
elif dx < -threshold and dy > threshold:
|
||||
# 目标在左下侧,垃圾桶向右前移动
|
||||
print("目标在左下侧,向右前移动")
|
||||
motor.move_right_forward(speed=1.0)
|
||||
else:
|
||||
# 目标在左上侧,垃圾桶向左前移动
|
||||
print("目标在左上侧,向左前移动")
|
||||
motor.move_left_forward(speed=1.0)
|
||||
|
||||
# 更新上一帧的目标信息
|
||||
prev_cx = cx
|
||||
prev_cy = cy
|
||||
prev_area = max_area
|
||||
if lock_active:
|
||||
track_state = None
|
||||
selected = None
|
||||
else:
|
||||
# 没有检测到目标,停止移动
|
||||
print("未检测到目标,停止移动")
|
||||
motor.stop()
|
||||
# 重置上一帧的目标信息
|
||||
prev_cx = None
|
||||
prev_cy = None
|
||||
prev_area = None
|
||||
# 重置跟踪状态
|
||||
tracking_target = None
|
||||
selected = select_target(candidates, track_state)
|
||||
if track_state is not None and selected is None:
|
||||
motor.stop()
|
||||
track_state = None
|
||||
lock_until = now + TARGET_LOCK_DURATION
|
||||
lock_remaining = TARGET_LOCK_DURATION
|
||||
lock_active = True
|
||||
else:
|
||||
track_state = update_track_state(track_state, selected)
|
||||
|
||||
draw_debug(frame, motion_mask, track_state, motion_level, inlier_count, lock_remaining)
|
||||
drive_to_target(track_state, lock_active=lock_active)
|
||||
|
||||
if lock_active:
|
||||
print(f"track locked {lock_remaining:0.1f}s, cam={motion_level:4.1f}")
|
||||
elif track_state is not None:
|
||||
print(
|
||||
f"track=({track_state.cx:3d},{track_state.cy:3d}) "
|
||||
f"area={track_state.area:6.1f} cam={motion_level:4.1f}"
|
||||
)
|
||||
else:
|
||||
print(f"waiting target, cam={motion_level:4.1f}")
|
||||
|
||||
cv2.imshow("tracking", frame)
|
||||
|
||||
prev_gray = gray.copy()
|
||||
|
||||
if cv2.waitKey(1) & 0xFF == 27:
|
||||
|
||||
@@ -6,68 +6,144 @@
|
||||
<title>垃圾桶控制界面</title>
|
||||
<link rel="stylesheet" href="CSS/control.css">
|
||||
<link rel="stylesheet" href="CSS/record.css">
|
||||
<link rel="stylesheet" href="CSS/agent.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1 class="title">垃圾桶控制界面</h1>
|
||||
<div class="page-shell">
|
||||
<div class="container">
|
||||
<header class="page-intro">
|
||||
<p class="eyebrow">Robot Console</p>
|
||||
<h1 class="title">垃圾桶控制界面</h1>
|
||||
<p class="subtitle">把实时控制、轨迹管理和智能指令整理到同一个更清晰的工作台里。</p>
|
||||
</header>
|
||||
|
||||
<!-- 手柄风格控制器 -->
|
||||
<div class="controller">
|
||||
<div class="controller-layout">
|
||||
<!-- 左侧:八个方向控制 -->
|
||||
<div class="dpad">
|
||||
<div class="dpad-row">
|
||||
<button class="btn btn-forward-left" id="forwardLeftBtn"><span>↖</span></button>
|
||||
<button class="btn btn-forward" id="forwardBtn"><span>↑</span></button>
|
||||
<button class="btn btn-forward-right" id="forwardRightBtn"><span>↗</span></button>
|
||||
</div>
|
||||
<div class="dpad-row">
|
||||
<button class="btn btn-left" id="leftBtn"><span>←</span></button>
|
||||
<div class="btn btn-center"></div>
|
||||
<button class="btn btn-right" id="rightBtn"><span>→</span></button>
|
||||
</div>
|
||||
<div class="dpad-row">
|
||||
<button class="btn btn-backward-left" id="backwardLeftBtn"><span>↙</span></button>
|
||||
<button class="btn btn-backward" id="backwardBtn"><span>↓</span></button>
|
||||
<button class="btn btn-backward-right" id="backwardRightBtn"><span>↘</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<main class="dashboard">
|
||||
<section class="main-column">
|
||||
<section class="surface control-surface">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">实时控制</p>
|
||||
<h2>方向与动作</h2>
|
||||
</div>
|
||||
<p class="section-note">按住按钮持续运动,松开后自动停止。</p>
|
||||
</div>
|
||||
|
||||
<!-- 中间:录制回放控制 -->
|
||||
<div class="center-controls">
|
||||
<button class="btn btn-record" id="recordBtn"><span>录制</span></button>
|
||||
<button class="btn btn-playback" id="playbackBtn"><span>回放</span></button>
|
||||
</div>
|
||||
<div class="controller">
|
||||
<div class="controller-layout">
|
||||
<div class="control-cluster">
|
||||
<span class="cluster-label">移动</span>
|
||||
<div class="dpad">
|
||||
<div class="dpad-row">
|
||||
<button class="btn btn-forward-left" id="forwardLeftBtn" type="button"><span>↖</span></button>
|
||||
<button class="btn btn-forward" id="forwardBtn" type="button"><span>↑</span></button>
|
||||
<button class="btn btn-forward-right" id="forwardRightBtn" type="button"><span>↗</span></button>
|
||||
</div>
|
||||
<div class="dpad-row">
|
||||
<button class="btn btn-left" id="leftBtn" type="button"><span>←</span></button>
|
||||
<div class="btn btn-center" aria-hidden="true"></div>
|
||||
<button class="btn btn-right" id="rightBtn" type="button"><span>→</span></button>
|
||||
</div>
|
||||
<div class="dpad-row">
|
||||
<button class="btn btn-backward-left" id="backwardLeftBtn" type="button"><span>↙</span></button>
|
||||
<button class="btn btn-backward" id="backwardBtn" type="button"><span>↓</span></button>
|
||||
<button class="btn btn-backward-right" id="backwardRightBtn" type="button"><span>↘</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:旋转控制 -->
|
||||
<div class="rotate-controls">
|
||||
<button class="btn btn-rotate-left" id="rotateLeftBtn"><span>↶</span></button>
|
||||
<button class="btn btn-rotate-right" id="rotateRightBtn"><span>↷</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-cluster control-cluster-center">
|
||||
<span class="cluster-label">录制</span>
|
||||
<div class="center-controls">
|
||||
<button class="btn btn-record" id="recordBtn" type="button"><span>录制</span></button>
|
||||
<button class="btn btn-playback" id="playbackBtn" type="button"><span>回放</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 轨迹管理区域 -->
|
||||
<div class="path-management">
|
||||
<h3>轨迹管理</h3>
|
||||
<div class="path-input">
|
||||
<input type="text" id="pathName" placeholder="输入轨迹名称(如:test1)">
|
||||
<button class="btn btn-save" id="savePathBtn">保存轨迹</button>
|
||||
</div>
|
||||
<div class="path-list">
|
||||
<h4>已保存轨迹</h4>
|
||||
<ul id="pathList"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-cluster control-cluster-rotate">
|
||||
<span class="cluster-label">旋转</span>
|
||||
<div class="rotate-controls">
|
||||
<button class="btn btn-rotate-left" id="rotateLeftBtn" type="button"><span>⟲</span></button>
|
||||
<button class="btn btn-rotate-right" id="rotateRightBtn" type="button"><span>⟳</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 状态显示区域 -->
|
||||
<div class="status" id="status">
|
||||
<p>就绪</p>
|
||||
<section class="surface status-surface">
|
||||
<div class="section-heading compact">
|
||||
<div>
|
||||
<p class="section-kicker">运行状态</p>
|
||||
<h2>设备反馈</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status" id="status">
|
||||
<p>就绪</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="side-column">
|
||||
<section class="surface path-management">
|
||||
<div class="section-heading compact">
|
||||
<div>
|
||||
<p class="section-kicker">轨迹管理</p>
|
||||
<h3>保存与回放路径</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="path-input">
|
||||
<input type="text" id="pathName" placeholder="输入轨迹名称,例如 test1">
|
||||
<button class="btn btn-save" id="savePathBtn" type="button">保存轨迹</button>
|
||||
</div>
|
||||
<div class="path-list">
|
||||
<h4>已保存轨迹</h4>
|
||||
<ul id="pathList"></ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface agent-chat">
|
||||
<div class="section-heading compact">
|
||||
<div>
|
||||
<p class="section-kicker">智能助手</p>
|
||||
<h3>自然语言控制</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input">
|
||||
<input type="text" id="agentInput" placeholder="输入命令,例如:前进 3 秒、左转、执行路径 test1">
|
||||
<button class="btn btn-save" id="sendAgentBtn" type="button">发送</button>
|
||||
</div>
|
||||
|
||||
<div class="llm-config">
|
||||
<h4>LLM 模型配置</h4>
|
||||
<div class="config-row">
|
||||
<label for="apiUrlInput">API URL</label>
|
||||
<input type="text" id="apiUrlInput" placeholder="例如: https://api.openai.com/v1">
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label for="apiKeyInput">API Key</label>
|
||||
<input type="password" id="apiKeyInput" placeholder="输入 API 密钥">
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label for="modelInput">模型</label>
|
||||
<input type="text" id="modelInput" placeholder="例如: gpt-3.5-turbo">
|
||||
</div>
|
||||
<button class="btn btn-config" id="saveConfigBtn" type="button">保存配置</button>
|
||||
</div>
|
||||
|
||||
<div class="chat-history" id="chatHistory">
|
||||
<!-- 聊天历史记录 -->
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="Javascript/control.js"></script>
|
||||
<script src="Javascript/record.js"></script>
|
||||
<script src="Javascript/joycon.js"></script>
|
||||
<script src="Javascript/voice.js"></script>
|
||||
<script src="Javascript/agent.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -56,6 +56,17 @@ def motor_drive(name, speed):
|
||||
in1, in2 = MOTOR[name]
|
||||
|
||||
speed *= DIR[name]
|
||||
|
||||
speed_compensation = {
|
||||
"M1": 1.0, # 正常
|
||||
"M2": 1.0, # 正常
|
||||
"M3": 1.0, # 正常
|
||||
"M4": 1.15, # 假设M4转速慢,增加20%的转速
|
||||
}
|
||||
|
||||
# 应用速度补偿
|
||||
speed *= speed_compensation.get(name, 1.0)
|
||||
|
||||
pwm = int(abs(speed) * 4095)
|
||||
|
||||
if speed > 0:
|
||||
@@ -72,25 +83,25 @@ def motor_drive(name, speed):
|
||||
# ======================
|
||||
# 基础移动
|
||||
# ======================
|
||||
def forward(speed=0.8):
|
||||
def forward(speed=0.6):
|
||||
motor_drive("M1", -speed)
|
||||
motor_drive("M2", -speed)
|
||||
motor_drive("M3", speed)
|
||||
motor_drive("M4", speed)
|
||||
|
||||
def backward(speed=0.8):
|
||||
def backward(speed=0.6):
|
||||
motor_drive("M1", speed)
|
||||
motor_drive("M2", speed)
|
||||
motor_drive("M3", -speed)
|
||||
motor_drive("M4", -speed)
|
||||
|
||||
def move_left(speed=0.8):
|
||||
def move_left(speed=0.6):
|
||||
motor_drive("M1", -speed)
|
||||
motor_drive("M2", speed)
|
||||
motor_drive("M3", speed)
|
||||
motor_drive("M4", -speed)
|
||||
|
||||
def move_right(speed=0.8):
|
||||
def move_right(speed=0.6):
|
||||
motor_drive("M1", speed)
|
||||
motor_drive("M2", -speed)
|
||||
motor_drive("M3", -speed)
|
||||
@@ -99,13 +110,13 @@ def move_right(speed=0.8):
|
||||
# ======================
|
||||
# 旋转
|
||||
# ======================
|
||||
def rotate_left(speed=0.8):
|
||||
def rotate_left(speed=0.6):
|
||||
motor_drive("M1", -speed)
|
||||
motor_drive("M2", -speed)
|
||||
motor_drive("M3", -speed)
|
||||
motor_drive("M4", -speed)
|
||||
|
||||
def rotate_right(speed=0.8):
|
||||
def rotate_right(speed=0.6):
|
||||
motor_drive("M1", speed)
|
||||
motor_drive("M2", speed)
|
||||
motor_drive("M3", speed)
|
||||
@@ -114,25 +125,25 @@ def rotate_right(speed=0.8):
|
||||
# ======================
|
||||
# 斜向移动
|
||||
# ======================
|
||||
def move_left_forward(speed=0.8):
|
||||
def move_left_forward(speed=0.6):
|
||||
motor_drive("M1", 0)
|
||||
motor_drive("M2", speed)
|
||||
motor_drive("M3", 0)
|
||||
motor_drive("M4", -speed)
|
||||
|
||||
def move_right_forward(speed=0.8):
|
||||
def move_right_forward(speed=0.6):
|
||||
motor_drive("M1", speed)
|
||||
motor_drive("M2", 0)
|
||||
motor_drive("M3", -speed)
|
||||
motor_drive("M4", 0)
|
||||
|
||||
def move_left_backward(speed=0.8):
|
||||
def move_left_backward(speed=0.6):
|
||||
motor_drive("M1", -speed)
|
||||
motor_drive("M2", 0)
|
||||
motor_drive("M3", speed)
|
||||
motor_drive("M4", 0)
|
||||
|
||||
def move_right_backward(speed=0.8):
|
||||
def move_right_backward(speed=0.6):
|
||||
motor_drive("M1", 0)
|
||||
motor_drive("M2", -speed)
|
||||
motor_drive("M3", 0)
|
||||
|
||||
@@ -3,9 +3,16 @@ import motor
|
||||
import time
|
||||
import os
|
||||
import json
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import agent
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
HTTPS_HOST = os.getenv('HOST', '0.0.0.0')
|
||||
HTTPS_PORT = int(os.getenv('HTTPS_PORT', '5443'))
|
||||
DEBUG_MODE = os.getenv('FLASK_DEBUG', '1') == '1'
|
||||
|
||||
# 静态文件路由
|
||||
@app.route('/CSS/<path:filename>')
|
||||
def serve_css(filename):
|
||||
@@ -168,5 +175,8 @@ def delete_path():
|
||||
print(f"删除轨迹出错: {e}")
|
||||
return jsonify({'status': 'error', 'message': f'删除轨迹出错: {e}'})
|
||||
|
||||
agent.create_agent_routes(app, motor)
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
print(f"HTTPS server available at https://localhost:{HTTPS_PORT}")
|
||||
app.run(host=HTTPS_HOST, port=HTTPS_PORT, debug=DEBUG_MODE, ssl_context='adhoc')
|
||||