新增agent语音输入控制、优化web界面ui布局和样式、优化摄像头控制响应
This commit is contained in:
parent
7896e90602
commit
2f8c3bfdca
@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
288
main/Javascript/voice.js
Normal 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.
BIN
main/__pycache__/lan_https.cpython-38.pyc
Normal file
BIN
main/__pycache__/lan_https.cpython-38.pyc
Normal file
Binary file not shown.
Binary file not shown.
@ -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":
|
||||
|
||||
703
main/camera.py
703
main/camera.py
@ -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
|
||||
for _ in range(10):
|
||||
ret, _ = cap.read()
|
||||
if ret:
|
||||
success_count += 1
|
||||
print(f"第{i+1}帧读取成功")
|
||||
time.sleep(0.1)
|
||||
print(f"摄像头初始化完成,成功读取{success_count}帧")
|
||||
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
|
||||
if lock_active:
|
||||
track_state = None
|
||||
selected = None
|
||||
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("目标跳变,停止移动")
|
||||
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:
|
||||
# 计算目标与中心的偏差
|
||||
dx = cx - center_x
|
||||
dy = cy - center_y
|
||||
track_state = None
|
||||
|
||||
# 确定移动方向
|
||||
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)
|
||||
draw_debug(frame, motion_mask, track_state, motion_level, inlier_count, lock_remaining)
|
||||
drive_to_target(track_state, lock_active=lock_active)
|
||||
|
||||
# 更新上一帧的目标信息
|
||||
prev_cx = cx
|
||||
prev_cy = cy
|
||||
prev_area = max_area
|
||||
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("未检测到目标,停止移动")
|
||||
motor.stop()
|
||||
# 重置上一帧的目标信息
|
||||
prev_cx = None
|
||||
prev_cy = None
|
||||
prev_area = None
|
||||
# 重置跟踪状态
|
||||
tracking_target = None
|
||||
print(f"waiting target, cam={motion_level:4.1f}")
|
||||
|
||||
cv2.imshow("tracking", frame)
|
||||
|
||||
prev_gray = gray.copy()
|
||||
|
||||
if cv2.waitKey(1) & 0xFF == 27:
|
||||
|
||||
124
main/index.html
124
main/index.html
@ -9,99 +9,141 @@
|
||||
<link rel="stylesheet" href="CSS/agent.css">
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
|
||||
<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="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"><span>↖</span></button>
|
||||
<button class="btn btn-forward" id="forwardBtn"><span>↑</span></button>
|
||||
<button class="btn btn-forward-right" id="forwardRightBtn"><span>↗</span></button>
|
||||
<button class="btn btn-forward-left" id="forwardLeftBtn" type="button"><span>↖</span></button>
|
||||
<button class="btn btn-forward" id="forwardBtn" type="button"><span>↑</span></button>
|
||||
<button class="btn btn-forward-right" id="forwardRightBtn" type="button"><span>↗</span></button>
|
||||
</div>
|
||||
<div class="dpad-row">
|
||||
<button class="btn btn-left" id="leftBtn"><span>←</span></button>
|
||||
<div class="btn btn-center"></div>
|
||||
<button class="btn btn-right" id="rightBtn"><span>→</span></button>
|
||||
<button class="btn btn-left" id="leftBtn" type="button"><span>←</span></button>
|
||||
<div class="btn btn-center" aria-hidden="true"></div>
|
||||
<button class="btn btn-right" id="rightBtn" type="button"><span>→</span></button>
|
||||
</div>
|
||||
<div class="dpad-row">
|
||||
<button class="btn btn-backward-left" id="backwardLeftBtn"><span>↙</span></button>
|
||||
<button class="btn btn-backward" id="backwardBtn"><span>↓</span></button>
|
||||
<button class="btn btn-backward-right" id="backwardRightBtn"><span>↘</span></button>
|
||||
<button class="btn btn-backward-left" id="backwardLeftBtn" type="button"><span>↙</span></button>
|
||||
<button class="btn btn-backward" id="backwardBtn" type="button"><span>↓</span></button>
|
||||
<button class="btn btn-backward-right" id="backwardRightBtn" type="button"><span>↘</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间:录制回放控制 -->
|
||||
<div class="control-cluster control-cluster-center">
|
||||
<span class="cluster-label">录制</span>
|
||||
<div class="center-controls">
|
||||
<button class="btn btn-record" id="recordBtn"><span>录制</span></button>
|
||||
<button class="btn btn-playback" id="playbackBtn"><span>回放</span></button>
|
||||
<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="control-cluster control-cluster-rotate">
|
||||
<span class="cluster-label">旋转</span>
|
||||
<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>
|
||||
<button class="btn btn-rotate-left" id="rotateLeftBtn" type="button"><span>⟲</span></button>
|
||||
<button class="btn btn-rotate-right" id="rotateRightBtn" type="button"><span>⟳</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 轨迹管理区域 -->
|
||||
<div class="path-management">
|
||||
<h3>轨迹管理</h3>
|
||||
<section class="surface status-surface">
|
||||
<div class="section-heading compact">
|
||||
<div>
|
||||
<p class="section-kicker">运行状态</p>
|
||||
<h2>设备反馈</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status" id="status">
|
||||
<p>就绪</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="side-column">
|
||||
<section class="surface path-management">
|
||||
<div class="section-heading compact">
|
||||
<div>
|
||||
<p class="section-kicker">轨迹管理</p>
|
||||
<h3>保存与回放路径</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="path-input">
|
||||
<input type="text" id="pathName" placeholder="输入轨迹名称(如:test1)">
|
||||
<button class="btn btn-save" id="savePathBtn">保存轨迹</button>
|
||||
<input type="text" id="pathName" placeholder="输入轨迹名称,例如 test1">
|
||||
<button class="btn btn-save" id="savePathBtn" type="button">保存轨迹</button>
|
||||
</div>
|
||||
<div class="path-list">
|
||||
<h4>已保存轨迹</h4>
|
||||
<ul id="pathList"></ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface agent-chat">
|
||||
<div class="section-heading compact">
|
||||
<div>
|
||||
<p class="section-kicker">智能助手</p>
|
||||
<h3>自然语言控制</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态显示区域 -->
|
||||
<div class="status" id="status">
|
||||
<p>就绪</p>
|
||||
<div class="chat-input">
|
||||
<input type="text" id="agentInput" placeholder="输入命令,例如:前进 3 秒、左转、执行路径 test1">
|
||||
<button class="btn btn-save" id="sendAgentBtn" type="button">发送</button>
|
||||
</div>
|
||||
|
||||
<!-- Agent聊天区域 -->
|
||||
<div class="agent-chat">
|
||||
<h3>智能控制助手</h3>
|
||||
|
||||
<!-- LLM配置区域 -->
|
||||
<div class="llm-config">
|
||||
<h4>LLM模型配置</h4>
|
||||
<h4>LLM 模型配置</h4>
|
||||
<div class="config-row">
|
||||
<label for="apiUrlInput">API URL:</label>
|
||||
<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密钥">
|
||||
<label for="apiKeyInput">API Key</label>
|
||||
<input type="password" id="apiKeyInput" placeholder="输入 API 密钥">
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<label for="modelInput">模型:</label>
|
||||
<label for="modelInput">模型</label>
|
||||
<input type="text" id="modelInput" placeholder="例如: gpt-3.5-turbo">
|
||||
</div>
|
||||
<button class="btn btn-config" id="saveConfigBtn">保存配置</button>
|
||||
<button class="btn btn-config" id="saveConfigBtn" type="button">保存配置</button>
|
||||
</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>
|
||||
</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>
|
||||
@ -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')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user