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 543fd13..bee23b4 100644 Binary files a/main/__pycache__/agent.cpython-38.pyc and b/main/__pycache__/agent.cpython-38.pyc differ diff --git a/main/__pycache__/lan_https.cpython-38.pyc b/main/__pycache__/lan_https.cpython-38.pyc new file mode 100644 index 0000000..8d401f3 Binary files /dev/null and b/main/__pycache__/lan_https.cpython-38.pyc differ diff --git a/main/__pycache__/motor.cpython-38.pyc b/main/__pycache__/motor.cpython-38.pyc index 86c66f9..64e98f4 100644 Binary files a/main/__pycache__/motor.cpython-38.pyc and b/main/__pycache__/motor.cpython-38.pyc differ 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')