新增agent语音输入控制、优化web界面ui布局和样式、优化摄像头控制响应

This commit is contained in:
张梦南 2026-04-25 15:49:23 +08:00
parent 7896e90602
commit 2f8c3bfdca
11 changed files with 1416 additions and 738 deletions

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%;
}
}

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;
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);
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;
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;
}
}

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;
}
}

288
main/Javascript/voice.js Normal file
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();
}
});
});

Binary file not shown.

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:
@ -209,8 +221,7 @@ def execute_skill(action, args, motor_module):
return {"status": "error", "message": "路径名称不能为空"}
# 调用加载轨迹API
try:
import requests
response = requests.get(f"http://localhost:5000/load_path", params={"name": path_name}, timeout=5)
response = local_api_request("GET", "/load_path", params={"name": path_name})
if response.status_code == 200:
data = response.json()
if data.get("status") == "success":
@ -305,8 +316,7 @@ def execute_skill(action, args, motor_module):
elif action == "list_paths":
# 调用列出轨迹API
try:
import requests
response = requests.get("http://localhost:5000/list_paths", timeout=5)
response = local_api_request("GET", "/list_paths")
if response.status_code == 200:
data = response.json()
if data.get("status") == "success":
@ -329,8 +339,7 @@ def execute_skill(action, args, motor_module):
return {"status": "error", "message": "路径名称不能为空"}
# 调用删除轨迹API
try:
import requests
response = requests.delete("http://localhost:5000/delete_path", json={"name": path_name}, timeout=5)
response = local_api_request("DELETE", "/delete_path", json={"name": path_name})
if response.status_code == 200:
data = response.json()
if data.get("status") == "success":

View File

@ -1,70 +1,468 @@
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
CONTROL_BASE_SPEED = 0.55
CONTROL_MAX_SPEED = 1.0
CONTROL_LOOKAHEAD_FRAMES = 1.4
CONTROL_LOOKAHEAD_GAIN = 0.06
MIN_CONTOUR_AREA = 90
MAX_CONTOUR_AREA = 6000
EDGE_IGNORE = 12
TARGET_LOCK_DURATION = 3.0
TRACK_MAX_MISSES = 4
TRACK_FAST_MOVE_PX = 24.0
TRACK_CENTER_WINDOW = 28
@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"],
)
raw_dx = candidate["cx"] - track_state.cx
raw_dy = candidate["cy"] - track_state.cy
motion_mag = math.hypot(raw_dx, raw_dy)
crossed_guide_x = (track_state.cx - GUIDE_CENTER_X) * (candidate["cx"] - GUIDE_CENTER_X) < 0
near_guide_x = (
abs(track_state.cx - GUIDE_CENTER_X) < TRACK_CENTER_WINDOW
or abs(candidate["cx"] - GUIDE_CENTER_X) < TRACK_CENTER_WINDOW
)
if track_state.misses > 0:
smooth_prev = 0.30
else:
smooth_prev = 0.58
if motion_mag > TRACK_FAST_MOVE_PX:
smooth_prev = min(smooth_prev, 0.34)
if crossed_guide_x and near_guide_x:
smooth_prev = min(smooth_prev, 0.18)
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
velocity_keep = 0.60
if motion_mag > TRACK_FAST_MOVE_PX:
velocity_keep = 0.42
if crossed_guide_x and near_guide_x:
velocity_keep = 0.30
new_vx = velocity_keep * track_state.vx + (1.0 - velocity_keep) * raw_vx
new_vy = velocity_keep * track_state.vy + (1.0 - velocity_keep) * 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 predict_track_state(track_state):
if track_state is None:
return None
x, y, w, h = track_state.bbox
next_cx = int(np.clip(track_state.cx + track_state.vx, 0, FRAME_WIDTH - 1))
next_cy = int(np.clip(track_state.cy + track_state.vy, 0, FRAME_HEIGHT - 1))
next_x = int(np.clip(x + track_state.vx, 0, max(0, FRAME_WIDTH - w)))
next_y = int(np.clip(y + track_state.vy, 0, max(0, FRAME_HEIGHT - h)))
track_state.cx = next_cx
track_state.cy = next_cy
track_state.bbox = (next_x, next_y, w, h)
track_state.vx *= 0.82
track_state.vy *= 0.82
track_state.misses += 1
track_state.visible = False
return track_state
def predicted_control_point(track_state):
if track_state is None:
return GUIDE_CENTER_X, GUIDE_CENTER_Y
speed = math.hypot(track_state.vx, track_state.vy)
lookahead = CONTROL_LOOKAHEAD_FRAMES + min(1.3, speed * CONTROL_LOOKAHEAD_GAIN)
px = int(np.clip(track_state.cx + track_state.vx * lookahead, 0, FRAME_WIDTH - 1))
py = int(np.clip(track_state.cy + track_state.vy * lookahead, 0, FRAME_HEIGHT - 1))
return px, py
def control_speed(error, velocity):
magnitude = abs(error) + abs(velocity) * 1.3
if magnitude <= CONTROL_DEADZONE:
return 0.0
normalized = min(1.0, (magnitude - CONTROL_DEADZONE) / 90.0)
return CONTROL_BASE_SPEED + normalized * (CONTROL_MAX_SPEED - CONTROL_BASE_SPEED)
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)
predicted_x, predicted_y = predicted_control_point(track_state)
cv2.circle(frame, (predicted_x, predicted_y), 4, (0, 255, 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:
motor.stop()
return
target_x, target_y = predicted_control_point(track_state)
dx = target_x - GUIDE_CENTER_X
dy = target_y - GUIDE_CENTER_Y
speed_x = control_speed(dx, track_state.vx)
speed_y = control_speed(dy, track_state.vy)
if speed_x == 0.0 and speed_y == 0.0:
motor.stop()
elif speed_x == 0.0:
if dy > 0:
motor.move_right(speed=speed_y)
else:
motor.move_left(speed=speed_y)
elif speed_y == 0.0:
if dx > 0:
motor.backward(speed=speed_x)
else:
motor.forward(speed=speed_x)
else:
speed = max(speed_x, speed_y)
if dx > 0 and dy > 0:
motor.move_right_backward(speed=speed)
elif dx > 0 and dy < 0:
motor.move_left_backward(speed=speed)
elif dx < 0 and dy > 0:
motor.move_right_forward(speed=speed)
else:
motor.move_left_forward(speed=speed)
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 +470,48 @@ 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 selected is not None:
track_state = update_track_state(track_state, selected)
elif track_state is not None:
track_state = predict_track_state(track_state)
if track_state.misses > TRACK_MAX_MISSES:
motor.stop()
track_state = None
lock_until = now + TARGET_LOCK_DURATION
lock_remaining = TARGET_LOCK_DURATION
lock_active = True
else:
track_state = None
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:

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>

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')