Compare commits

...
6 Commits
49 changed files with 1825 additions and 766 deletions

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 880 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+276 -2
View File
@@ -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
本项目用于课程设计、毕业设计或个人学习研究。若用于其他用途,请根据实际情况补充许可证说明。
+68 -121
View File
@@ -1,175 +1,122 @@
/* Agent聊天区域样式 */
.agent-chat {
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;
display: flex;
flex-direction: column;
gap: 18px;
}
.agent-chat h3 {
margin-top: 0;
color: #333;
text-align: center;
}
/* LLM配置区域 */
.llm-config {
margin-bottom: 20px;
padding: 15px;
background: #f5f5f5;
border-radius: 10px;
box-shadow: inset 3px 3px 6px rgba(0, 0, 0, 0.1), inset -3px -3px 6px rgba(255, 255, 255, 0.7);
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 10px 0;
color: #555;
font-size: 14px;
margin: 0 0 14px;
font-size: 15px;
font-weight: 700;
color: #516577;
}
.config-row {
display: flex;
gap: 10px;
margin-bottom: 10px;
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.config-row:last-child {
margin-bottom: 0;
.config-row:last-of-type {
margin-bottom: 16px;
}
.config-row label {
width: 70px;
color: #666;
color: #647789;
font-size: 14px;
flex-shrink: 0;
}
.config-row input {
flex: 1;
padding: 8px;
border: none;
border-radius: 8px;
background: #ffffff;
box-shadow: inset 2px 2px 4px rgba(0, 0, 0, 0.1), inset -2px -2px 4px rgba(255, 255, 255, 0.7);
font-size: 14px;
}
.config-row input:focus {
outline: none;
box-shadow: inset 3px 3px 6px rgba(0, 0, 0, 0.15), inset -3px -3px 6px rgba(255, 255, 255, 0.8);
font-weight: 600;
}
.btn-config {
background: linear-gradient(145deg, #2196F3, #1976D2);
color: white;
border: none;
border-radius: 8px;
padding: 8px 16px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 3px 3px 6px rgba(0, 0, 0, 0.1), -3px -3px 6px rgba(255, 255, 255, 0.7);
margin-left: 80px;
min-width: 112px;
padding: 0 18px;
border-radius: 16px;
background: linear-gradient(160deg, var(--btn-secondary-start), var(--btn-secondary-end));
}
.btn-config:hover {
transform: translateY(-2px);
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.15), -5px -5px 10px rgba(255, 255, 255, 0.8);
.llm-config > .btn-config + .btn-config {
margin-left: 10px;
}
/* 聊天输入区域 */
.chat-input {
display: flex;
gap: 10px;
margin-bottom: 20px;
justify-content: center;
}
.chat-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: 250px;
font-size: 16px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: stretch;
}
.chat-history {
max-height: 300px;
min-height: 280px;
max-height: 380px;
overflow-y: auto;
background: #f5f5f5;
border-radius: 10px;
padding: 15px;
box-shadow: inset 5px 5px 10px rgba(0, 0, 0, 0.1), inset -5px -5px 10px rgba(255, 255, 255, 0.7);
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 {
margin-bottom: 10px;
padding: 10px;
border-radius: 10px;
max-width: 80%;
max-width: min(82%, 420px);
padding: 12px 14px;
border-radius: 18px;
box-shadow: 0 10px 18px rgba(53, 74, 97, 0.08);
}
.message.user {
background: linear-gradient(145deg, #E3F2FD, #BBDEFB);
margin-left: auto;
box-shadow: 3px 3px 6px rgba(0, 0, 0, 0.1), -3px -3px 6px rgba(255, 255, 255, 0.7);
align-self: flex-end;
background: linear-gradient(160deg, #e1f1ff, #c5def6);
}
.message.agent {
background: linear-gradient(145deg, #E8F5E8, #C8E6C9);
margin-right: auto;
box-shadow: 3px 3px 6px rgba(0, 0, 0, 0.1), -3px -3px 6px rgba(255, 255, 255, 0.7);
align-self: flex-start;
background: linear-gradient(160deg, #ecf6ea, #d9ebd6);
}
.message p {
margin: 0;
color: #333;
color: #2f4253;
line-height: 1.6;
word-break: break-word;
}
/* 响应式设计 - Agent聊天 */
@media (max-width: 650px) {
.chat-input {
flex-direction: column;
align-items: center;
}
.chat-input input {
width: 100%;
max-width: 300px;
}
.agent-chat {
padding: 15px;
}
.message {
max-width: 90%;
}
@media (max-width: 760px) {
.config-row {
flex-direction: column;
align-items: flex-start;
grid-template-columns: 1fr;
gap: 8px;
}
.config-row label {
width: auto;
margin-bottom: 5px;
}
.config-row input {
.llm-config > .btn-config {
width: 100%;
}
.btn-config {
.llm-config > .btn-config + .btn-config {
margin-left: 0;
width: 100%;
margin-top: 10px;
}
}
@media (max-width: 640px) {
.chat-input {
grid-template-columns: 1fr;
}
.chat-input .btn-save {
width: 100%;
}
.message {
max-width: 100%;
}
}
+361 -194
View File
@@ -1,272 +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;
}
}
+71 -92
View File
@@ -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;
}
}
+25 -8
View File
@@ -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');
}
+24 -23
View File
@@ -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 {
+4
View File
@@ -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');
+288
View File
@@ -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();
}
});
});
+25
View File
@@ -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
}
]
}
@@ -1,21 +1,21 @@
{
"name": "轨迹",
"name": "test2",
"actions": [
{
"direction": "forward",
"timestamp": 858
"timestamp": 1734
},
{
"direction": "stop",
"timestamp": 1166
"timestamp": 2074
},
{
"direction": "backward",
"timestamp": 2327
"timestamp": 3287
},
{
"direction": "stop",
"timestamp": 2642
"timestamp": 3656
}
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+139 -4
View File
@@ -4,6 +4,7 @@ import json
import requests
import re
import threading
import urllib3
from queue import Queue
task_queue = Queue()
@@ -14,6 +15,17 @@ llm_config = {
"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:
@@ -207,22 +219,145 @@ def execute_skill(action, args, motor_module):
path_name = args.get("name")
if not path_name:
return {"status": "error", "message": "路径名称不能为空"}
return {"status": "success", "message": f"播放轨迹{path_name}"}
# 调用加载轨迹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":
return {"status": "success", "message": "获取轨迹列表"}
# 调用列出轨迹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": "路径名称不能为空"}
return {"status": "success", "message": f"删除轨迹{path_name}"}
# 调用删除轨迹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}"}
# 这里需要获取当前录制的轨迹,暂时返回成功消息
return {"status": "success", "message": f"保存轨迹{path_name}成功"}
else:
return {"status": "error", "message": "无效的动作"}
+401 -237
View File
@@ -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 > 5000: # 过滤大面积假目标(如整个画面),减小阈值
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 = 80 # 目标锁定区域半径
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 = 90 # 画面中心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 = 90 # 画面中心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:
+121 -79
View File
@@ -9,99 +9,141 @@
<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>&#8598;</span></button>
<button class="btn btn-forward" id="forwardBtn" type="button"><span>&#8593;</span></button>
<button class="btn btn-forward-right" id="forwardRightBtn" type="button"><span>&#8599;</span></button>
</div>
<div class="dpad-row">
<button class="btn btn-left" id="leftBtn" type="button"><span>&#8592;</span></button>
<div class="btn btn-center" aria-hidden="true"></div>
<button class="btn btn-right" id="rightBtn" type="button"><span>&#8594;</span></button>
</div>
<div class="dpad-row">
<button class="btn btn-backward-left" id="backwardLeftBtn" type="button"><span>&#8601;</span></button>
<button class="btn btn-backward" id="backwardBtn" type="button"><span>&#8595;</span></button>
<button class="btn btn-backward-right" id="backwardRightBtn" type="button"><span>&#8600;</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>&#10226;</span></button>
<button class="btn btn-rotate-right" id="rotateRightBtn" type="button"><span>&#10227;</span></button>
</div>
</div>
</div>
</div>
</section>
<!-- 状态显示区域 -->
<div class="status" id="status">
<p>就绪</p>
</div>
<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>
<!-- Agent聊天区域 -->
<div class="agent-chat">
<h3>智能控制助手</h3>
<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>
<!-- LLM配置区域 -->
<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">保存配置</button>
</div>
<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">发送</button>
</div>
<div class="chat-history" id="chatHistory">
<!-- 聊天历史记录 -->
</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>
+11
View File
@@ -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:
+6 -1
View File
@@ -9,6 +9,10 @@ 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):
@@ -174,4 +178,5 @@ def delete_path():
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')