From 2f8c3bfdcae4e20e8b1f4a1c6f129dbf62a149fb Mon Sep 17 00:00:00 2001 From: Cx330 <1487537121@qq.com> Date: Sat, 25 Apr 2026 15:49:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9Eagent=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=8E=A7=E5=88=B6=E3=80=81=E4=BC=98=E5=8C=96?= =?UTF-8?q?web=E7=95=8C=E9=9D=A2ui=E5=B8=83=E5=B1=80=E5=92=8C=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E3=80=81=E4=BC=98=E5=8C=96=E6=91=84=E5=83=8F=E5=A4=B4?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/CSS/agent.css | 191 +++--- main/CSS/control.css | 561 +++++++++++------ main/CSS/record.css | 165 +++-- main/Javascript/voice.js | 288 +++++++++ main/__pycache__/agent.cpython-38.pyc | Bin 6704 -> 10863 bytes main/__pycache__/lan_https.cpython-38.pyc | Bin 0 -> 6201 bytes main/__pycache__/motor.cpython-38.pyc | Bin 3564 -> 3654 bytes main/agent.py | 23 +- main/camera.py | 717 +++++++++++++++------- main/index.html | 202 +++--- main/server.py | 7 +- 11 files changed, 1416 insertions(+), 738 deletions(-) create mode 100644 main/Javascript/voice.js create mode 100644 main/__pycache__/lan_https.cpython-38.pyc diff --git a/main/CSS/agent.css b/main/CSS/agent.css index 3e6d5e9..0af3b38 100644 --- a/main/CSS/agent.css +++ b/main/CSS/agent.css @@ -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; +@media (max-width: 760px) { + .config-row { + grid-template-columns: 1fr; + gap: 8px; } - .chat-input input { + .llm-config > .btn-config { width: 100%; - max-width: 300px; } - .agent-chat { - padding: 15px; + .llm-config > .btn-config + .btn-config { + margin-left: 0; + margin-top: 10px; + } +} + +@media (max-width: 640px) { + .chat-input { + grid-template-columns: 1fr; + } + + .chat-input .btn-save { + width: 100%; } .message { - max-width: 90%; + max-width: 100%; } - - .config-row { - flex-direction: column; - align-items: flex-start; - } - - .config-row label { - width: auto; - margin-bottom: 5px; - } - - .config-row input { - width: 100%; - } - - .btn-config { - margin-left: 0; - width: 100%; - margin-top: 10px; - } -} \ No newline at end of file +} diff --git a/main/CSS/control.css b/main/CSS/control.css index 36561a9..0beb25d 100644 --- a/main/CSS/control.css +++ b/main/CSS/control.css @@ -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; - padding: 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; } } - diff --git a/main/CSS/record.css b/main/CSS/record.css index 53df625..de28e30 100644 --- a/main/CSS/record.css +++ b/main/CSS/record.css @@ -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; } -} \ No newline at end of file + + #pathList li > div { + width: 100%; + } + + #pathList li button { + flex: 1; + } +} diff --git a/main/Javascript/voice.js b/main/Javascript/voice.js new file mode 100644 index 0000000..8db9394 --- /dev/null +++ b/main/Javascript/voice.js @@ -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 = `

${message}

`; + } + } + + 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(); + } + }); +}); diff --git a/main/__pycache__/agent.cpython-38.pyc b/main/__pycache__/agent.cpython-38.pyc index 543fd137568c39baf8cacc20db6b264489879056..bee23b4a7f6b132dfb37bbf7029c00f026221c63 100644 GIT binary patch literal 10863 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 literal 0 HcmV?d00001 diff --git a/main/__pycache__/motor.cpython-38.pyc b/main/__pycache__/motor.cpython-38.pyc index 86c66f9d84467d9e01cc42eac3469d0121d28f84..64e98f40165986e3e60503adff9a547c197385c3 100644 GIT binary patch delta 1079 zcmbV~&u|LbOFnTZH~H(TgT+WBm~iO)KqE#71a`O?p{U zBZ((Z&c&!r@JQlCy%_%hqyK^7Z(g@b=SviM|EXaZOBA|nPMM`X=O^d zCH4f}cF)}rR>H#S>jjCKE!~om+DZvd2<^vGX-Bl5OY9`9SrkD4Nv*9euijo=Yb)2T z14>1wt&}T(!d8uGc>kovPpA<(T-z%a?al4#qrGCKR&dJI3QwqGiE|*4h$UN!Oho^s z=^;5J>%<@y5mXGqCiz2k))3pWY0$3q=_n8R4Z7uNF)QSq>E<=~hJSP*=E!f- z2VM+b$|7tQV$Sey{@j2*rCW60)1w;nS)t$6(*ycXdXOA&7Rd77`ea-b2!BjpmAus9;jJOP8!r~l@Glg{_@yihHE--w%Yc${9f zDL;a^XI@;liVNMijG61xr_C^Z?&-0DaNb3J&s@j z+#JOdoJZj6WaEeg0^hTEZY+bdX~Ya-7Li3;Sh_)5t8LTT*0h?Mq)km*P*4OBQ>?#Gl#pf)-xff4%Q*T&E=Uet^!BtqGW|$mr`d6?zKq8F-sMz>|DwP`V|zlJA6rVWqeTAL#6kqn0woD|js3W80W`$Q`4tjUz(Ce=Wp_>rXWG_fqj&xZ{ z(IPttNiYIPseyWN<;u!xK+m2B)Lk#2<+UcmLx59p%j?xZZ9c44otxmzAhLW@ojkf9 z6cVvyi}cAFF^EM3sX^E=@chV$_U#mZ6gk3wsp(-1U>Zy8@r|ezL*G^aW0hPJ}422-~Z3cSOao`=0#0 ztHr053SZK>z>% diff --git a/main/agent.py b/main/agent.py index 6aab9fb..d403759 100644 --- a/main/agent.py +++ b/main/agent.py @@ -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": @@ -431,4 +440,4 @@ def create_agent_routes(app, motor_module): return jsonify(response) except Exception as e: print(f"Agent聊天出错: {e}") - return jsonify({'status': 'error', 'message': f'Agent聊天出错: {e}'}) \ No newline at end of file + return jsonify({'status': 'error', 'message': f'Agent聊天出错: {e}'}) diff --git a/main/camera.py b/main/camera.py index d25f3c3..2bf9391 100644 --- a/main/camera.py +++ b/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 - 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: @@ -278,4 +519,4 @@ while True: cap.release() cv2.destroyAllWindows() -motor.stop() \ No newline at end of file +motor.stop() diff --git a/main/index.html b/main/index.html index 810c15e..346e8f5 100644 --- a/main/index.html +++ b/main/index.html @@ -9,99 +9,141 @@ -
-

垃圾桶控制界面

+
+
+
+

Robot Console

+

垃圾桶控制界面

+

把实时控制、轨迹管理和智能指令整理到同一个更清晰的工作台里。

+
- -
-
- -
-
- - - -
-
- -
- -
-
- - - -
-
+
+
+
+
+
+

实时控制

+

方向与动作

+
+

按住按钮持续运动,松开后自动停止。

+
- -
- - -
+
+
+
+ 移动 +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
- -
- - -
-
-
+
+ 录制 +
+ + +
+
- -
-

轨迹管理

-
- - -
-
-

已保存轨迹

-
    -
    -
    +
    + 旋转 +
    + + +
    +
    +
    +
    + - -
    -

    就绪

    -
    +
    +
    +
    +

    运行状态

    +

    设备反馈

    +
    +
    +
    +

    就绪

    +
    +
    + - -
    -

    智能控制助手

    + +
    + - \ No newline at end of file + diff --git a/main/server.py b/main/server.py index c2b0d88..b196024 100644 --- a/main/server.py +++ b/main/server.py @@ -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/') 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) \ No newline at end of file + print(f"HTTPS server available at https://localhost:{HTTPS_PORT}") + app.run(host=HTTPS_HOST, port=HTTPS_PORT, debug=DEBUG_MODE, ssl_context='adhoc')