wenhongquan пре 4 дана
родитељ
комит
ad978022ef
100 измењених фајлова са 5558 додато и 10069 уклоњено
  1. 0 6
      .claude/settings.local.json
  2. 87 0
      .superpowers/brainstorm/65597-1781516879/content/3d-panorama-layout.html
  3. 58 0
      .superpowers/brainstorm/65597-1781516879/content/multi-camera-grid.html
  4. 3 0
      .superpowers/brainstorm/65597-1781516879/content/waiting.html
  5. 80 0
      .superpowers/brainstorm/65597-1781516879/content/web-layout.html
  6. 1 0
      .superpowers/brainstorm/65597-1781516879/state/server-stopped
  7. 6 0
      .superpowers/brainstorm/65597-1781516879/state/server.log
  8. 1 0
      .superpowers/brainstorm/65597-1781516879/state/server.pid
  9. 84 83
      AGENTS.md
  10. 42 48
      CLAUDE.md
  11. 1790 0
      docs/superpowers/plans/2026-06-15-ptz-360-scan-3d-panorama.md
  12. 316 0
      docs/superpowers/specs/2026-06-15-ptz-360-scan-3d-panorama-design.md
  13. BIN
      dual_camera_system/.coverage
  14. 82 87
      dual_camera_system/README.md
  15. BIN
      dual_camera_system/__pycache__/calibration.cpython-310.pyc
  16. BIN
      dual_camera_system/__pycache__/calibration.cpython-313.pyc
  17. BIN
      dual_camera_system/__pycache__/camera_group.cpython-310.pyc
  18. BIN
      dual_camera_system/__pycache__/camera_group.cpython-313.pyc
  19. BIN
      dual_camera_system/__pycache__/coordinator.cpython-310.pyc
  20. BIN
      dual_camera_system/__pycache__/coordinator.cpython-313.pyc
  21. BIN
      dual_camera_system/__pycache__/dahua_sdk.cpython-310.pyc
  22. BIN
      dual_camera_system/__pycache__/event_pusher.cpython-310.pyc
  23. BIN
      dual_camera_system/__pycache__/inference_backend.cpython-310.pyc
  24. BIN
      dual_camera_system/__pycache__/main.cpython-310.pyc
  25. BIN
      dual_camera_system/__pycache__/main.cpython-313.pyc
  26. BIN
      dual_camera_system/__pycache__/multi_group_system.cpython-310.pyc
  27. BIN
      dual_camera_system/__pycache__/ocr_recognizer.cpython-310.pyc
  28. BIN
      dual_camera_system/__pycache__/ocr_recognizer.cpython-313.pyc
  29. BIN
      dual_camera_system/__pycache__/oss_uploader.cpython-310.pyc
  30. BIN
      dual_camera_system/__pycache__/paired_image_saver.cpython-310.pyc
  31. BIN
      dual_camera_system/__pycache__/paired_image_saver.cpython-313.pyc
  32. BIN
      dual_camera_system/__pycache__/panorama_camera.cpython-310.pyc
  33. BIN
      dual_camera_system/__pycache__/panorama_camera.cpython-313.pyc
  34. BIN
      dual_camera_system/__pycache__/polling_tracker.cpython-310.pyc
  35. BIN
      dual_camera_system/__pycache__/ptz_camera.cpython-310.pyc
  36. BIN
      dual_camera_system/__pycache__/ptz_person_tracker.cpython-310.pyc
  37. BIN
      dual_camera_system/__pycache__/safety_coordinator.cpython-310.pyc
  38. BIN
      dual_camera_system/__pycache__/safety_coordinator.cpython-313.pyc
  39. BIN
      dual_camera_system/__pycache__/safety_detector.cpython-310.pyc
  40. BIN
      dual_camera_system/__pycache__/safety_main.cpython-310.pyc
  41. BIN
      dual_camera_system/__pycache__/third_party_pusher.cpython-310.pyc
  42. BIN
      dual_camera_system/__pycache__/tracker.cpython-310.pyc
  43. BIN
      dual_camera_system/__pycache__/video_lock.cpython-310.pyc
  44. 286 0
      dual_camera_system/app.py
  45. 0 1728
      dual_camera_system/calibration.py
  46. 0 464
      dual_camera_system/camera_group.py
  47. 0 79
      dual_camera_system/capture_and_detect.py
  48. 0 37
      dual_camera_system/config.py
  49. 4 20
      dual_camera_system/config/__init__.py
  50. 2 56
      dual_camera_system/config/coordinator.py
  51. 3 0
      dual_camera_system/config/device.py
  52. 0 7
      dual_camera_system/config/ptz.py
  53. 5 25
      dual_camera_system/config/system.py
  54. 13 6
      dual_camera_system/config/tracking.py
  55. 0 2154
      dual_camera_system/coordinator.py
  56. 0 0
      dual_camera_system/core/__init__.py
  57. 176 0
      dual_camera_system/core/capture_uploader.py
  58. 52 0
      dual_camera_system/core/coord_utils.py
  59. 18 0
      dual_camera_system/core/detector_service.py
  60. 77 0
      dual_camera_system/core/group_state.py
  61. 137 0
      dual_camera_system/core/polling_scheduler.py
  62. 219 0
      dual_camera_system/core/scan_point_store.py
  63. 143 0
      dual_camera_system/core/spatial_scanner.py
  64. 147 0
      dual_camera_system/core/stream_manager.py
  65. 12 0
      dual_camera_system/data/scan_models.json
  66. 264 423
      dual_camera_system/docs/技术实现架构.md
  67. 0 467
      dual_camera_system/dual_stream_manager.py
  68. 0 219
      dual_camera_system/event_pusher.py
  69. 27 269
      dual_camera_system/main.py
  70. 0 297
      dual_camera_system/multi_group_system.py
  71. 0 428
      dual_camera_system/oss_uploader.py
  72. 0 1150
      dual_camera_system/paired_image_saver.py
  73. 0 472
      dual_camera_system/polling_tracker.py
  74. 0 543
      dual_camera_system/ptz_person_tracker.py
  75. 2 0
      dual_camera_system/pytest.ini
  76. 6 5
      dual_camera_system/requirements.txt
  77. BIN
      dual_camera_system/scripts/__pycache__/calibration_scanner.cpython-310.pyc
  78. BIN
      dual_camera_system/scripts/__pycache__/local_test.cpython-310.pyc
  79. BIN
      dual_camera_system/scripts/__pycache__/re_match_orb.cpython-310.pyc
  80. 1 1
      dual_camera_system/scripts/manual_ptz_test.py
  81. BIN
      dual_camera_system/tests/__pycache__/test_event_pusher_upload.cpython-310-pytest-9.0.2.pyc
  82. BIN
      dual_camera_system/tests/__pycache__/test_event_pusher_upload.cpython-310.pyc
  83. BIN
      dual_camera_system/tests/__pycache__/test_integration_polling.cpython-310-pytest-9.0.2.pyc
  84. BIN
      dual_camera_system/tests/__pycache__/test_polling_tracker.cpython-310-pytest-9.0.2.pyc
  85. BIN
      dual_camera_system/tests/__pycache__/test_polling_tracker.cpython-310.pyc
  86. BIN
      dual_camera_system/tests/__pycache__/test_tracker.cpython-310-pytest-9.0.2.pyc
  87. 136 0
      dual_camera_system/tests/test_capture_uploader.py
  88. 71 0
      dual_camera_system/tests/test_coord_utils.py
  89. 0 93
      dual_camera_system/tests/test_event_pusher_upload.py
  90. 149 0
      dual_camera_system/tests/test_group_state.py
  91. 0 91
      dual_camera_system/tests/test_integration_polling.py
  92. 125 0
      dual_camera_system/tests/test_polling_scheduler.py
  93. 0 338
      dual_camera_system/tests/test_polling_tracker.py
  94. 192 0
      dual_camera_system/tests/test_routes.py
  95. 290 0
      dual_camera_system/tests/test_scan_point_store.py
  96. 108 0
      dual_camera_system/tests/test_spatial_scanner.py
  97. 124 0
      dual_camera_system/tests/test_stream_manager.py
  98. 0 179
      dual_camera_system/tests/test_tracker.py
  99. 219 0
      dual_camera_system/tests/test_web_routes.py
  100. 0 294
      dual_camera_system/tracker.py

+ 0 - 6
.claude/settings.local.json

@@ -12,9 +12,7 @@
       "Bash(curl -s -m 5 -X OPTIONS \"http://58.213.48.54:9999/api/v1/human-analysis/\" -H \"Content-Type: application/json\")",
       "Bash(curl *)",
       "Bash(sshpass *)",
-      "Bash(python -c \"import py_compile; py_compile.compile\\('calibration.py', doraise=True\\)\")",
       "Bash(python -c \"import py_compile; py_compile.compile\\('main.py', doraise=True\\)\")",
-      "Bash(python -c \"import py_compile; py_compile.compile\\('main.py', doraise=True\\); py_compile.compile\\('coordinator.py', doraise=True\\); py_compile.compile\\('calibration.py', doraise=True\\)\")",
       "Bash(scp *)",
       "Bash(python3 *)",
       "Bash(git log *)",
@@ -24,11 +22,7 @@
       "Bash(ruff check *)",
       "Bash(mypy *)",
       "Bash(black *)",
-      "Bash(awk '/^class / {class=$2} /def _get_clear_ptz_frame/ {print class\": \"$0}' coordinator.py)",
-      "Bash(awk '/^class / {class=$0} /def _update_tracking/ {print NR\": \"class\" -> \"$0}' coordinator.py)",
-      "Bash(awk '/^class / {class=$0} /with self._track_id_lock/ {print NR\": \"class\" -> \"$0}' coordinator.py)",
       "Bash(python -c \"import config\")",
-      "Bash(python -c \"from coordinator import Coordinator, AsyncCoordinator, SequentialCoordinator; print\\('OK'\\)\")",
       "Bash(python *)"
     ]
   }

+ 87 - 0
.superpowers/brainstorm/65597-1781516879/content/3d-panorama-layout.html

@@ -0,0 +1,87 @@
+<h2>3D 全景模型配置扫描点</h2>
+<p class="subtitle">基于 360° 扫描生成可交互的全景球,点击任意位置设为轮询点</p>
+
+<div class="mockup">
+  <div class="mockup-header">3D 全景扫描点配置台</div>
+  <div class="mockup-body" style="padding: 16px;">
+    <!-- 顶部控制栏 -->
+    <div style="display:flex; gap:12px; margin-bottom:16px; align-items:center;">
+      <button class="mock-button" style="background:#3b82f6;color:#fff;">执行 360° 扫描建模</button>
+      <button class="mock-button">重置视角</button>
+      <button class="mock-button">保存扫描点</button>
+      <div style="margin-left:auto;" class="label">操作:拖拽旋转视角 · 点击红点点设为扫描点</div>
+    </div>
+
+    <div style="display:grid; grid-template-columns: 1fr 280px; gap:16px;">
+      <!-- 3D 全景区 -->
+      <div>
+        <div style="background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); aspect-ratio:16/9; border-radius:8px; position:relative; overflow:hidden;">
+          <!-- 模拟全景网格 -->
+          <div style="position:absolute; inset:0; opacity:0.15; background-image: linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px); background-size: 60px 60px;"></div>
+          
+          <!-- 模拟场景物体 -->
+          <div style="position:absolute; left:25%; top:40%; width:120px; height:80px; background:#334155; border-radius:4px; transform: perspective(600px) rotateY(15deg);"></div>
+          <div style="position:absolute; left:60%; top:35%; width:80px; height:120px; background:#475569; border-radius:4px; transform: perspective(600px) rotateY(-10deg);"></div>
+          <div style="position:absolute; left:45%; top:60%; width:160px; height:40px; background:#1e293b; border-radius:4px; transform: perspective(600px) rotateX(10deg);"></div>
+          
+          <!-- 已选扫描点 -->
+          <div style="position:absolute; left:28%; top:42%; width:16px; height:16px; background:#22c55e; border:2px solid #fff; border-radius:50%; box-shadow:0 0 8px #22c55e; cursor:pointer;"></div>
+          <div style="position:absolute; left:62%; top:38%; width:16px; height:16px; background:#22c55e; border:2px solid #fff; border-radius:50%; box-shadow:0 0 8px #22c55e; cursor:pointer;"></div>
+          <div style="position:absolute; left:48%; top:45%; width:16px; height:16px; background:#3b82f6; border:2px solid #fff; border-radius:50%; box-shadow:0 0 10px #3b82f6; cursor:pointer;"></div>
+          
+          <!-- 视角指示器 -->
+          <div style="position:absolute; bottom:16px; right:16px; width:80px; height:80px; border:2px solid rgba(255,255,255,0.3); border-radius:50%; background:rgba(0,0,0,0.4);">
+            <div style="position:absolute; top:50%; left:50%; width:2px; height:36px; background:#ef4444; transform:translate(-50%,-100%) rotate(45deg); transform-origin:bottom;"></div>
+            <div style="position:absolute; top:50%; left:50%; width:8px; height:8px; background:#fff; border-radius:50%; transform:translate(-50%,-50%);"></div>
+          </div>
+          
+          <!-- 悬浮提示 -->
+          <div style="position:absolute; left:48%; top:38%; background:rgba(0,0,0,0.8); color:#fff; padding:6px 10px; border-radius:4px; font-size:12px;">
+            P: 95° T: -5° Z: 1x
+          </div>
+        </div>
+        <div style="display:flex; gap:12px; margin-top:8px; font-size:12px; color:#666;">
+          <span>🖱 左键拖拽:旋转</span>
+          <span>🖱 滚轮:缩放</span>
+          <span>🖱 点击:添加/删除扫描点</span>
+        </div>
+      </div>
+
+      <!-- 侧边栏 -->
+      <div style="display:flex; flex-direction:column; gap:12px;">
+        <div style="border:1px solid #e5e7eb; border-radius:8px; padding:12px;">
+          <h3>已选扫描点</h3>
+          <div style="font-size:13px; margin-bottom:6px;">✓ 扫描点 #1 · P:30° T:0°</div>
+          <div style="font-size:13px; margin-bottom:6px;">✓ 扫描点 #2 · P:95° T:-5°</div>
+          <div style="font-size:13px; margin-bottom:6px; color:#3b82f6; font-weight:600;">➤ 扫描点 #3 · P:120° T:0°</div>
+          <div style="font-size:13px; color:#999;">○ 扫描点 #4 · P:180° T:0°</div>
+        </div>
+
+        <div style="border:1px solid #e5e7eb; border-radius:8px; padding:12px;">
+          <h3>当前点参数</h3>
+          <div style="margin-bottom:8px;"><span class="label">Pan:</span> 95°</div>
+          <div style="margin-bottom:8px;"><span class="label">Tilt:</span> -5°</div>
+          <div style="margin-bottom:8px;"><span class="label">Zoom:</span> 1x</div>
+          <div style="margin-bottom:8px;"><span class="label">停留:</span> 3s</div>
+          <button class="mock-button" style="width:100%;">更新参数</button>
+        </div>
+
+        <div style="border:1px solid #e5e7eb; border-radius:8px; padding:12px; background:#f9fafb; font-family:monospace; font-size:11px; max-height:140px; overflow:auto;">
+          <div>建模完成:36 个采样点</div>
+          <div>已启用 3 个扫描点</div>
+          <div>当前视角:P:80° T:-3°</div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
+<div class="section">
+  <h3>实现思路</h3>
+  <p>扫描完成后,把球机在各角度抓取的图片拼接/映射成一个等距圆柱全景图(equirectangular),用 Three.js 在网页里渲染成球体贴图。用户在球面上点击时,把屏幕坐标反向换算成 pan/tilt,生成扫描点。</p>
+</div>
+
+<div class="section">
+  <h3>这种 3D 全景方式符合你的预期吗?</h3>
+  <p class="subtitle">或者你更希望是点云、3D 场景重建,还是简化版的 2D 展开全景图?</p>
+</div>

+ 58 - 0
.superpowers/brainstorm/65597-1781516879/content/multi-camera-grid.html

@@ -0,0 +1,58 @@
+<h2>多路视频平铺布局</h2>
+<p class="subtitle">所有摄像头画面全局平铺,配置扫描点时选择对应球机</p>
+
+<div class="mockup">
+  <div class="mockup-header">多路监控控制台</div>
+  <div class="mockup-body" style="padding: 16px;">
+    <!-- 顶部控制栏 -->
+    <div style="display:flex; gap:12px; margin-bottom:16px; align-items:center;">
+      <button class="mock-button" style="background:#3b82f6;color:#fff;">执行 360° 扫描建模</button>
+      <button class="mock-button">开始轮询</button>
+      <button class="mock-button">停止</button>
+      <select class="mock-input" style="width:180px;">
+        <option>选择球机配置扫描点...</option>
+        <option>球机-北侧</option>
+        <option>球机-南侧</option>
+        <option>球机-东侧</option>
+      </select>
+      <div style="margin-left:auto;" class="label">状态:轮询中 · 球机-北侧 → 扫描点 #3</div>
+    </div>
+
+    <!-- 视频网格 -->
+    <div style="display:grid; grid-template-columns: repeat(auto-fill, minmax(320px,1fr)); gap:16px; margin-bottom:16px;">
+      <div>
+        <div class="label" style="margin-bottom:6px;">枪机-北侧(人标记)</div>
+        <div style="background:#111; aspect-ratio:16/9; border-radius:8px; display:flex; align-items:center; justify-content:center; color:#666;">[ MJPEG ]</div>
+      </div>
+      <div>
+        <div class="label" style="margin-bottom:6px;">球机-北侧(人标记)</div>
+        <div style="background:#111; aspect-ratio:16/9; border-radius:8px; display:flex; align-items:center; justify-content:center; color:#666;">[ MJPEG ]</div>
+      </div>
+      <div>
+        <div class="label" style="margin-bottom:6px;">枪机-南侧(人标记)</div>
+        <div style="background:#111; aspect-ratio:16/9; border-radius:8px; display:flex; align-items:center; justify-content:center; color:#666;">[ MJPEG ]</div>
+      </div>
+      <div>
+        <div class="label" style="margin-bottom:6px;">球机-南侧(人标记)</div>
+        <div style="background:#111; aspect-ratio:16/9; border-radius:8px; display:flex; align-items:center; justify-content:center; color:#666;">[ MJPEG ]</div>
+      </div>
+    </div>
+
+    <!-- 3D 配置区 -->
+    <div style="display:grid; grid-template-columns: 1fr 280px; gap:16px;">
+      <div>
+        <div class="label" style="margin-bottom:6px;">球机-北侧 3D 全景模型(点击设置扫描点)</div>
+        <div style="background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%); aspect-ratio:16/9; border-radius:8px; position:relative;">
+          <div style="position:absolute; left:30%; top:40%; width:16px; height:16px; background:#22c55e; border:2px solid #fff; border-radius:50%; box-shadow:0 0 8px #22c55e;"></div>
+          <div style="position:absolute; left:55%; top:35%; width:16px; height:16px; background:#3b82f6; border:2px solid #fff; border-radius:50%; box-shadow:0 0 10px #3b82f6;"></div>
+        </div>
+      </div>
+      <div style="border:1px solid #e5e7eb; border-radius:8px; padding:12px;">
+        <h3>当前配置:球机-北侧</h3>
+        <div style="font-size:13px; margin-bottom:6px;">✓ P:30° T:0°</div>
+        <div style="font-size:13px; margin-bottom:6px; color:#3b82f6;">➤ P:95° T:-5°</div>
+        <button class="mock-button" style="width:100%; margin-top:8px;">预览并保存该点</button>
+      </div>
+    </div>
+  </div>
+</div>

+ 3 - 0
.superpowers/brainstorm/65597-1781516879/content/waiting.html

@@ -0,0 +1,3 @@
+<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
+  <p class="subtitle">Continuing design discussion in terminal...</p>
+</div>

+ 80 - 0
.superpowers/brainstorm/65597-1781516879/content/web-layout.html

@@ -0,0 +1,80 @@
+<h2>Web 控制台布局方案</h2>
+<p class="subtitle">扫描点配置 + 双路实时视频 + 状态日志</p>
+
+<div class="mockup">
+  <div class="mockup-header">PTZ 扫描轮询控制台</div>
+  <div class="mockup-body" style="padding: 16px;">
+    <!-- 顶部控制栏 -->
+    <div style="display:flex; gap:12px; margin-bottom:16px; align-items:center;">
+      <button class="mock-button" style="background:#3b82f6;color:#fff;">开始 360° 扫描</button>
+      <button class="mock-button">开始轮询</button>
+      <button class="mock-button">停止</button>
+      <div style="margin-left:auto;" class="label">当前状态:轮询中 → 扫描点 #3</div>
+    </div>
+
+    <!-- 主视频区 -->
+    <div style="display:grid; grid-template-columns: 1fr 1fr; gap:16px; margin-bottom:16px;">
+      <div>
+        <div class="label" style="margin-bottom:6px;">枪机实时流(带人标记)</div>
+        <div style="background:#111; aspect-ratio:16/9; border-radius:8px; display:flex; align-items:center; justify-content:center; color:#666;">
+          [ MJPEG 实时流 ]
+        </div>
+      </div>
+      <div>
+        <div class="label" style="margin-bottom:6px;">球机实时流(带人标记)</div>
+        <div style="background:#111; aspect-ratio:16/9; border-radius:8px; display:flex; align-items:center; justify-content:center; color:#666;">
+          [ MJPEG 实时流 ]
+        </div>
+      </div>
+    </div>
+
+    <!-- 扫描点缩略图网格 -->
+    <div class="section">
+      <h3>扫描点(点击勾选/取消加入轮询)</h3>
+      <div style="display:grid; grid-template-columns: repeat(auto-fill, minmax(140px,1fr)); gap:12px;">
+        <div style="border:2px solid #3b82f6; border-radius:8px; padding:8px; text-align:center;">
+          <div style="background:#222; aspect-ratio:16/9; border-radius:4px; margin-bottom:6px;"></div>
+          <div style="font-size:12px;">P:30° T:0°</div>
+          <div style="font-size:11px; color:#22c55e;">✓ 已启用</div>
+        </div>
+        <div style="border:2px solid #ddd; border-radius:8px; padding:8px; text-align:center;">
+          <div style="background:#222; aspect-ratio:16/9; border-radius:4px; margin-bottom:6px;"></div>
+          <div style="font-size:12px;">P:60° T:0°</div>
+          <div style="font-size:11px; color:#999;">未启用</div>
+        </div>
+        <div style="border:2px solid #3b82f6; border-radius:8px; padding:8px; text-align:center;">
+          <div style="background:#222; aspect-ratio:16/9; border-radius:4px; margin-bottom:6px;"></div>
+          <div style="font-size:12px;">P:90° T:0°</div>
+          <div style="font-size:11px; color:#22c55e;">✓ 已启用</div>
+        </div>
+        <div style="border:2px solid #ddd; border-radius:8px; padding:8px; text-align:center;">
+          <div style="background:#222; aspect-ratio:16/9; border-radius:4px; margin-bottom:6px;"></div>
+          <div style="font-size:12px;">P:120° T:0°</div>
+          <div style="font-size:11px; color:#999;">未启用</div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 底部:参数 + 日志 -->
+    <div style="display:grid; grid-template-columns: 320px 1fr; gap:16px; margin-top:16px;">
+      <div style="border:1px solid #e5e7eb; border-radius:8px; padding:12px;">
+        <h3>扫描参数</h3>
+        <div style="margin-bottom:8px;"><span class="label">Tilt 层:</span>-20° / 0° / +20°</div>
+        <div style="margin-bottom:8px;"><span class="label">Pan 步长:</span>30°</div>
+        <div style="margin-bottom:8px;"><span class="label">单点停留:</span>3 秒</div>
+        <div style="margin-bottom:8px;"><span class="label">Zoom:</span>1x</div>
+      </div>
+      <div style="border:1px solid #e5e7eb; border-radius:8px; padding:12px; background:#f9fafb; font-family:monospace; font-size:12px; max-height:160px; overflow:auto;">
+        <div>09:41:02 扫描完成,共 36 个点</div>
+        <div>09:41:05 开始轮询,启用 12 个点</div>
+        <div>09:41:08 移动到扫描点 #3 (P:90° T:0°)</div>
+        <div>09:41:11 球机检测到目标,保存并上传</div>
+      </div>
+    </div>
+  </div>
+</div>
+
+<div class="section">
+  <h3>这个布局是否满足需求?</h3>
+  <p class="subtitle">如果有调整(比如布局比例、功能位置、操作方式),请告诉我。</p>
+</div>

+ 1 - 0
.superpowers/brainstorm/65597-1781516879/state/server-stopped

@@ -0,0 +1 @@
+{"reason":"idle timeout","timestamp":1781519639274}

+ 6 - 0
.superpowers/brainstorm/65597-1781516879/state/server.log

@@ -0,0 +1,6 @@
+{"type":"server-started","port":51155,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:51155","screen_dir":"/Users/wenhongquan/Desktop/阿里云同步/项目/dnn/德胜河 AI/dsh/.superpowers/brainstorm/65597-1781516879/content","state_dir":"/Users/wenhongquan/Desktop/阿里云同步/项目/dnn/德胜河 AI/dsh/.superpowers/brainstorm/65597-1781516879/state"}
+{"type":"screen-added","file":"/Users/wenhongquan/Desktop/阿里云同步/项目/dnn/德胜河 AI/dsh/.superpowers/brainstorm/65597-1781516879/content/web-layout.html"}
+{"type":"screen-added","file":"/Users/wenhongquan/Desktop/阿里云同步/项目/dnn/德胜河 AI/dsh/.superpowers/brainstorm/65597-1781516879/content/3d-panorama-layout.html"}
+{"type":"screen-added","file":"/Users/wenhongquan/Desktop/阿里云同步/项目/dnn/德胜河 AI/dsh/.superpowers/brainstorm/65597-1781516879/content/waiting.html"}
+{"type":"screen-added","file":"/Users/wenhongquan/Desktop/阿里云同步/项目/dnn/德胜河 AI/dsh/.superpowers/brainstorm/65597-1781516879/content/multi-camera-grid.html"}
+{"type":"server-stopped","reason":"idle timeout"}

+ 1 - 0
.superpowers/brainstorm/65597-1781516879/state/server.pid

@@ -0,0 +1 @@
+65606

+ 84 - 83
AGENTS.md

@@ -12,7 +12,7 @@
 - 多组全景摄像头实时监控,YOLO11 检测人体
 - 球机 PTZ 联动跟踪,变焦定位目标
 - 配对图片保存与上传
-- 事件推送至业务平台 + 语音播报
+- 事件推送至业务平台
 
 > 注:OCR 编号识别、LLM 判断、安全帽/反光衣检测已在本版本中移除。
 
@@ -25,26 +25,40 @@ dual_camera_system/
 ├── config/                      # 模块化配置(已重构)
 │   ├── __init__.py              # 配置汇总导出
 │   ├── camera.py                # 摄像头 + 日志配置
+│   ├── coordinator.py           # 扫描轮询参数
 │   ├── detection.py             # 人体检测配置
-│   ├── ptz.py                   # PTZ 控制参数
-│   ├── coordinator.py            # 联动 + 校准配置
-│   ├── tracking.py              # 跟踪 + 轮询抓拍配置(库代码)
+│   ├── device.py                # 设备元数据配置
 │   ├── event.py                 # 事件推送配置
-│   ├── voice.py                 # 语音播报配置
-│   └── system.py                # 系统开关 + 工作模式
-├── main.py                      # 主入口(仅多组模式)
-├── multi_group_system.py        # 多组系统管理器
-├── camera_group.py              # 单组摄像头封装
+│   ├── oss.py                   # OSS 上传配置
+│   ├── ptz.py                   # PTZ 控制参数
+│   ├── system.py                # 系统功能开关
+│   └── tracking.py              # 跟踪 + 轮询抓拍配置(库代码)
+├── core/                        # 新架构核心模块
+│   ├── capture_uploader.py      # 抓拍与上传核心逻辑
+│   ├── coord_utils.py           # 坐标/角度换算工具
+│   ├── detector_service.py      # 检测器服务封装
+│   ├── group_state.py           # 摄像头组状态管理
+│   ├── polling_scheduler.py     # 轮询调度器
+│   ├── scan_point_store.py      # 扫描点位存储
+│   ├── spatial_scanner.py       # 空间扫描建模
+│   └── stream_manager.py        # 视频流管理
+├── scripts/                     # 脚本工具
+│   ├── local_test.py            # 本地 RTSP/检测/PTZ 角度测试
+│   └── manual_ptz_test.py       # 手动 PTZ 控制测试脚本(原 test_ptz.py)
+├── pytest.ini                   # pytest 仅收集 tests/ 目录
+├── web/                         # Web API 服务
+│   ├── routes.py                # HTTP 路由
+│   └── state.py                 # Web 应用状态
+├── web_static/                  # 前端静态页面
+│   ├── app.js                   # 前端交互脚本
+│   ├── index.html               # 前端入口页面
+│   └── style.css                # 前端样式
+├── app.py                       # FastAPI 应用工厂与全局服务初始化
+├── main.py                      # 服务入口(启动 uvicorn)
 ├── dahua_sdk.py                 # 大华 SDK ctypes 封装
 ├── panorama_camera.py           # 全景摄像头 + 人体检测
 ├── ptz_camera.py                # 球机 PTZ 控制
-├── calibration.py               # 视觉校准(运动检测 + 特征匹配)
-├── coordinator.py               # 联动控制器(SequentialCoordinator)
-├── tracker.py                   # Ultralytics 跟踪器封装(库代码)
-├── polling_tracker.py           # 多目标轮询跟踪协调器(库代码)
 ├── inference_backend.py         # RKNN/ONNX 通用推理后端
-├── event_pusher.py              # 事件推送至业务平台
-├── voice_announcer.py           # TTS 语音播报
 └── README.md                    # 项目说明
 
 dh/                              # 大华 SDK(仅参考)
@@ -57,16 +71,16 @@ dh/                              # 大华 SDK(仅参考)
 
 ## 运行命令
 
-### 多组模式(当前唯一入口)
 ```bash
+cd dual_camera_system
 python main.py
-python main.py --skip-calibration                     # 跳过校准
-python main.py --demo                                 # 演示模式
-python main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101  # 覆盖第一组 IP
+python main.py --demo
 ```
 
 ### 通用参数
 ```bash
+--host HOST                  # 服务监听地址(默认 0.0.0.0,也可通过 HOST 环境变量设置)
+--port PORT                  # 服务监听端口(默认 8000,也可通过 PORT 环境变量设置)
 --model-size {n,s,m,l,x}    # YOLO11 模型尺寸
 --no-gpu                     # 禁用 GPU
 --model /path/to/model.pt    # 显式指定检测模型
@@ -81,20 +95,30 @@ python main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101  # 覆盖第
 | 人体检测 | YOLO11 (ultralytics) |
 | 摄像头 SDK | 大华 NetSDK (ctypes) |
 | 图像处理 | OpenCV |
-| 特征匹配 | SIFT / ORB |
 | PTZ 控制 | DH_EXTPTZ_EXACTGOTO |
-| 语音播报 | Edge-TTS (zh-CN-XiaoxiaoNeural) |
 | 事件推送 | HTTP API → jtjai.device.wenhq.top:8583 |
+| Web 服务 | FastAPI + uvicorn |
 
 ---
 
 ## 配置说明
 
-### config/system.py - 功能开关
-- `enable_panorama_camera`, `enable_ptz_camera`
-- `enable_detection`: 启用人人体检测
-- `enable_calibration`, `enable_ptz_tracking`
-- `enable_event_push`, `enable_voice_announce`
+### config/system.py - 系统功能开关
+- `enable_panorama_camera`: 启用全景摄像头;为 False 时不注册枪机 RTSP 流
+- `enable_ptz_camera`: 启用 PTZ 球机;为 False 时不连接球机、不注册球机流、不创建扫描器/调度器
+- `enable_detection`: 启用人体检测;为 False 时不启动检测线程
+- `enable_event_push`: 启用事件推送;为 False 时不启动第三方推送器
+
+`app.py` 在启动时会读取这些开关并跳过对应服务。
+
+### config/device.py - 设备与接口鉴权
+- `device_id`: 设备编号
+- `project_id`: 项目编号
+- `api_key`: Web 控制接口 API Key;留空时不鉴权(向后兼容本地开发),配置后需在请求头中携带 `X-API-Key`
+
+受保护的写端点:`/api/scan/*`、`/api/poll/*`、`/api/points/*` 的 POST/DELETE。
+读端点:`/api/status`、`/api/live/*`、`/api/panorama/*`、`/api/scan/*/progress`、`/api/points/*` 保持公开。
+鉴权实现位于 `web/auth.py`。
 
 ### config/camera.py - 摄像头配置
 - `PANORAMA_CAMERA`: 全景摄像头 IP/端口/凭证/分辨率/品牌
@@ -113,10 +137,6 @@ python main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101  # 覆盖第
 - `/api/resource/oss/upload` - 图片上传
 - `/api/system/event` - 事件创建
 
-### config/voice.py - 语音播报
-- `TTS_CONFIG`: Edge-TTS, zh-CN-XiaoxiaoNeural
-- `VOICE_ANNOUNCER_CONFIG`: 违规播报重复 3 次
-
 ### config/tracking.py - 跟踪 + 轮询抓拍配置
 - `TRACKING_CONFIG.model_path`: 默认跟踪模型路径,按平台自动选择
   - Linux aarch64 → `/home/admin/dsh/yolo/yolo11.rknn`(RK3588)
@@ -128,8 +148,9 @@ python main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101  # 覆盖第
 - `TRACKING_CONFIG.tracking_timeout`: 目标丢失后保留 ID 的超时时间
 - 轮询抓拍参数:`ptz_stabilize_time`、`ptz_command_cooldown`、`capture_dir`、`enable_upload`
 
-### config/coordinator.py - 校准配置
-- `CALIBRATION_CONFIG.interval`: 24 小时(不是 5 分钟)
+### config/coordinator.py - 扫描轮询参数
+- `ptz_stabilize_time`: PTZ 到位后稳定等待时间(秒)
+- `ptz_command_cooldown`: PTZ 命令最小间隔(秒)
 
 ---
 
@@ -148,77 +169,57 @@ python main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101  # 覆盖第
    - 其他 → 项目相对路径 `../dh/Bin`(开发环境参考)
 2. **ARM64 SDK 兼容性**: ARM64 版 SDK 缺少 `CLIENT_SetVideoProcCallBack` 等函数,`dahua_sdk.py` 已做可选绑定处理,缺失函数运行时降级而非崩溃
 3. **SDK 类型映射**: 大华 SDK 在 Linux 上 `DWORD=unsigned int(4B)`, `LONG=int(4B)`, `LLONG=long(8B)`,ctypes 绑定必须严格匹配,否则结构体对齐错误导致登录失败
-4. **初始化顺序**: `main.py` 中先加载 YOLO/PyTorch,再初始化大华 SDK。大华 SDK 的 `CLIENT_Init` 会修改进程内存映射,如果先于 PyTorch 加载会导致 segfault
-5. **校准间隔**: 实际是 24 小时,不是 README 中的 5 分钟
-6. **模型路径**: 人体检测模型在 `config/detection.py` 中配置,默认 `/home/wen/dsh/yolo/yolo11n.pt`;macOS 本地测试建议将 `config/tracking.py` 中模型路径指向可用 `.pt` 文件
-7. **YOLO11 自动下载**: 首次运行自动下载预训练权重
-8. **工作模式**: `main.py` 当前仅支持 **多组摄像头联动模式**(`multi_group_system.py`),所有摄像头组在 `config/camera.py` 的 `CAMERA_GROUPS` 中配置
-9. **摄像头端口**: SDK 登录用 37777,RTSP 流用 554,config 中 `port=37777`, `rtsp_port=554`
-10. **全景分辨率**: 在 `config/camera.py` 中配置 `resolution`,支持 `3840x1080`、`2560x1440`、`1920x1080` 等;系统仅在 RTSP 不可用时按配置分辨率生成模拟帧,实际流帧不再拉伸缩放;模型推理时通过 letterbox(灰度填充)保持宽高比,避免丢精度
-11. **混合品牌**: 枪机支持 Hikvision RTSP-only 模式(配置 `brand='hikvision'` 或 `use_sdk=False`),球机仍使用 Dahua SDK 控制 PTZ
-12. **本地测试**: 在无法加载 Dahua SDK 的环境(如 macOS),可运行 `python scripts/local_test.py` 验证 RTSP、检测与 PTZ 角度计算
-13. **库代码**: `tracker.py`、`polling_tracker.py` 为保留的 Ultralytics 跟踪库代码,当前 `main.py` 未使用,但测试用例仍覆盖
-
----
+4. **初始化顺序**: `app.py` 的生命周期中先初始化检测器服务,再初始化大华 SDK。大华 SDK 的 `CLIENT_Init` 会修改进程内存映射,如果先于 PyTorch 加载会导致 segfault
+5. **模型路径**: 人体检测模型在 `config/detection.py` 中配置,默认 `/home/wen/dsh/yolo/yolo11n.pt`;macOS 本地测试建议将 `config/tracking.py` 中模型路径指向可用 `.pt` 文件
+6. **YOLO11 自动下载**: 首次运行自动下载预训练权重
+7. **工作模式**: 当前通过 `app.py` 的 `create_app()` 启动 **多组摄像头扫描轮询模式**,所有摄像头组在 `config/camera.py` 的 `CAMERA_GROUPS` 中配置,`main.py` 仅作为 uvicorn 服务入口
+8. **摄像头端口**: SDK 登录用 37777,RTSP 流用 554,config 中 `port=37777`, `rtsp_port=554`
+9. **全景分辨率**: 在 `config/camera.py` 中配置 `resolution`,支持 `3840x1080`、`2560*1440`、`1920x1080` 等;系统仅在 RTSP 不可用时按配置分辨率生成模拟帧,实际流帧不再拉伸缩放;模型推理时通过 letterbox(灰度填充)保持宽高比,避免丢精度
+10. **混合品牌**: 枪机支持 Hikvision RTSP-only 模式(配置 `brand='hikvision'` 或 `use_sdk=False`),球机仍使用 Dahua SDK 控制 PTZ
+11. **本地测试**: 在无法加载 Dahua SDK 的环境(如 macOS),可运行 `python scripts/local_test.py` 验证 RTSP、检测与 PTZ 角度计算
+12. **PTZ 连接失败处理**: `app.py` 会检查 `ptz.connect()` 返回值;连接失败时记录错误,跳过该组的球机流/扫描器/调度器,并在 group_state 中设置 `ptz_connected: false`,枪机流与检测线程仍尽可能保留
+13. **线程安全检测**: `core/detector_service.py` 使用 `threading.Lock()` 保护 `detect()`,避免多摄像头检测线程并发推理导致崩溃
 
 ---
 
-## 校准机制
-
-**方法**:
-1. 运动检测法:帧差定位球机移动区域
-2. 特征匹配法:SIFT/ORB 匹配球机图像与全景画面
-3. 加权融合 + 降级(角度估算)
-
-**流程**:
-```
-移动前全景帧 ────┐
-                 ├──> 运动检测 ──> 运动区域中心
-移动后全景帧 ────┘
-
-球机抓拍 ────────┐
-                 ├──> 特征匹配 ──> 匹配点中心
-全景画面 ─────────┘
-
-运动区域 + 匹配点 ──> 加权融合 ──> 坐标映射
-```
-
----
-
-## 多组摄像头联动模式
+## 多组摄像头扫描轮询模式
 
 **核心组件**:
-- `multi_group_system.py`:`MultiGroupSystem` 管理多组摄像头的并行运行
-- `camera_group.py`:`CameraGroup` 封装单组(全景 + 球机)的初始化、校准、联动与抓拍
-- `coordinator.py`:`SequentialCoordinator` 负责单组内的人体检测 → PTZ 定位 → 配对图片保存
+- `app.py`:`create_app()` 负责全局服务初始化与 FastAPI 生命周期管理
+- `core/stream_manager.py`:统一管理枪机/球机 RTSP 视频流
+- `core/spatial_scanner.py`:执行 360° 空间扫描建模(可选)
+- `core/polling_scheduler.py`:按扫描点位轮询控制球机 PTZ
+- `core/capture_uploader.py`:抓拍保存与可选上传
+- `web/routes.py`:暴露状态查看、扫描控制等 HTTP 接口
 
 **工作流程**:
 ```
 config/camera.py 中 CAMERA_GROUPS
-      MultiGroupSystem
-              │
-              ▼
-    为每组创建 CameraGroup
+      create_app() / app.py
-    共享 SDK + 共享 ObjectDetector
+    初始化共享 SDK、检测器、视频流管理器
-      每组独立 SequentialCoordinator
+    为每组创建:
+    ├── StreamManager:枪机/球机 RTSP 流
+    ├── SpatialScanner:360° 空间扫描建模(可选)
+    ├── PollingScheduler:扫描点位轮询
+    └── CaptureUploader:抓拍 + 上传
-    人体检测 ──> PTZ 定位 ──> 抓拍 ──> 配对图片保存/上传
+    枪机实时检测 + 球机轮询检测 ──> 抓拍 ──> 上传/事件推送
 ```
 
 **关键行为**:
-1. `MultiGroupSystem` 初始化一次大华 SDK 和一个人体检测器(YOLO11
-2. 为 `CAMERA_GROUPS` 中每个启用的组创建 `CameraGroup`
-3. 每组独立完成:视频流连接 → 自动校准 → 启动 `SequentialCoordinator`
-4. `SequentialCoordinator` 检测到人体后控制球机 PTZ 定位并保存配对图片
-5. 配对图片保存器 `PairedImageSaver` 负责本地存储与可选 OSS/第三方平台上传
-6. 系统运行期间按配置执行每日定时校准
+1. `app.py` 初始化一次大华 SDK、一个检测器服务(`DetectorService`)和一个视频流管理器(`StreamManager`
+2. 为 `CAMERA_GROUPS` 中每个启用的组注册枪机/球机 RTSP 流
+3. 每组独立完成:视频流连接 → (可选)360° 扫描建模 → 启动轮询调度器
+4. `PollingScheduler` 按扫描点位轮询球机 PTZ,到位后由枪机/球机检测线程抓拍
+5. `CaptureUploader` 负责本地存储与可选 OSS/第三方平台上传
+6. Web 服务通过 `web/routes.py` 暴露状态/控制接口
 
 ---
 

+ 42 - 48
CLAUDE.md

@@ -7,70 +7,73 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
 **施工现场安全行为智能识别系统 v2.0.0** - Construction site safety behavior intelligent recognition system.
 
 A dual-camera coordinated capture system that:
-- Uses panoramic camera for real-time monitoring with YOLO11 detection (person/hard hat/reflective vest)
-- Controls PTZ dome camera to zoom and track detected targets
-- Performs OCR number recognition via llama-server API
-- Detects safety violations (missing hard hat/reflective vest)
-- Pushes events to business platform and announces via TTS
+- Uses panoramic cameras for real-time monitoring with YOLO11 person detection
+- Controls PTZ dome cameras to scan preset positions and capture targets
+- Saves paired panorama + PTZ images
+- Pushes events to business platform
 
 ## Running Commands
 
-### OCR Mode (Number Recognition)
+### Scan-Polling Mode
 ```bash
 cd dual_camera_system
-python main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101
-python main.py --interactive      # Interactive mode
-python main.py --skip-calibration # Skip auto-calibration
-```
-
-### Safety Mode (Safety Detection)
-```bash
-cd dual_camera_system
-python safety_main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101
+python main.py
+python main.py --demo
 ```
 
 ### Common Parameters
 ```bash
 --model-size {n,s,m,l,x}    # YOLO11 model size
 --no-gpu                    # Disable GPU
---ocr-host localhost --ocr-port 8111  # OCR API endpoint
+--model /path/to/model.pt   # Explicit detection model
 ```
 
 ### Dependencies
 ```bash
 pip install opencv-python opencv-contrib-python ultralytics
-# OCR requires llama-server running on localhost:8111
 ```
 
 ## Architecture
 
 ```
 dual_camera_system/
-├── main.py              # OCR mode entry point
-├── safety_main.py       # Safety mode entry point
-├── config.py            # Config aggregation (imports from config/)
+├── main.py              # uvicorn service entry point
+├── app.py               # FastAPI app factory and global service initialization
 ├── config/              # Modular configuration
-│   ├── system.py        # Feature toggles and working mode
+│   ├── __init__.py      # Config aggregation
+│   ├── system.py        # Feature toggles
 │   ├── camera.py        # Camera IPs, credentials, SDK paths
 │   ├── detection.py     # YOLO detection config
+│   ├── coordinator.py   # Scan polling parameters
+│   ├── ptz.py           # PTZ control parameters
+│   ├── event.py         # Event push config
 │   └── ...              # Other module configs
+├── core/                # New architecture core modules
+│   ├── capture_uploader.py   # Capture and upload logic
+│   ├── coord_utils.py        # Coordinate/angle conversion
+│   ├── detector_service.py   # Detector service wrapper
+│   ├── group_state.py        # Per-group state management
+│   ├── polling_scheduler.py  # PTZ scan polling scheduler
+│   ├── scan_point_store.py   # Scan point storage
+│   ├── spatial_scanner.py    # Spatial 360° scan modeling
+│   └── stream_manager.py     # RTSP stream manager
+├── web/                 # Web API service
+│   ├── routes.py        # HTTP routes
+│   └── state.py         # Web app state
+├── web_static/          # Frontend static files
+│   ├── index.html
+│   ├── app.js
+│   └── style.css
 ├── dahua_sdk.py         # Dahua SDK ctypes wrapper
-├── panorama_camera.py   # Panoramic camera + YOLO detection
+├── panorama_camera.py   # Panoramic camera + person detection
 ├── ptz_camera.py        # PTZ dome camera control
-├── calibration.py       # Visual calibration (motion detection + feature matching)
-├── coordinator.py       # Camera coordination logic (AsyncCoordinator, SequentialCoordinator)
-├── safety_detector.py   # Safety detection (hard hat/reflective vest)
-├── safety_coordinator.py # Safety mode coordinator
-├── ocr_recognizer.py    # OCR number recognition
-├── event_pusher.py      # Event push to platform
-├── voice_announcer.py   # TTS voice announcement
-└── llm_service.py       # LLM service wrapper (Qwen2.5-VL)
+└── inference_backend.py # RKNN/ONNX inference backend
 ```
 
 ## Critical Technical Details
 
 ### SDK Initialization Order (CRITICAL)
-In `main.py`, YOLO/PyTorch MUST load before Dahua SDK. The SDK's `CLIENT_Init` modifies process memory mapping; if loaded before PyTorch, it causes segfault. The current code handles this correctly - do not change the order.
+In `app.py`, YOLO/PyTorch MUST load before Dahua SDK. The SDK's `CLIENT_Init` modifies process memory mapping; if loaded before PyTorch, it causes segfault. The current code handles this correctly - do not change the order.
 
 ### SDK Path Auto-Selection
 `config/camera.py` auto-selects SDK path by CPU architecture:
@@ -82,22 +85,14 @@ In `main.py`, YOLO/PyTorch MUST load before Dahua SDK. The SDK's `CLIENT_Init` m
 - SDK login: port 37777
 - RTSP stream: port 554 (rtsp_port in config)
 
-### Calibration
-- Interval: 24 hours (not 5 minutes as noted in some docs)
-- Daily calibration time: configurable via `CALIBRATION_CONFIG.daily_calibration_time`
-- Methods: motion detection + SIFT/ORB feature matching
-- The system can fallback to angle estimation if visual calibration fails
-
-### Working Modes
-Controlled by `config/system.py`:
-- `mode: 'safety'` - Safety detection mode (safety_main.py)
-- `mode: 'ocr'` - Number recognition mode (main.py)
+### Working Mode
+Current working mode is **multi-group scan-polling mode** via `app.py:create_app()`. All camera groups are configured in `config/camera.py` under `CAMERA_GROUPS`. `main.py` is only the uvicorn entry point.
 
-Feature toggles control enabled modules:
-- `enable_detection`, `enable_safety_detection`
-- `enable_calibration`, `enable_ptz_tracking`
-- `enable_ocr`, `enable_llm`
-- `enable_event_push`, `enable_voice_announce`
+Feature toggles in `config/system.py`:
+- `enable_panorama_camera`
+- `enable_ptz_camera`
+- `enable_detection`
+- `enable_event_push`
 
 ## RKNN Test Environment
 
@@ -109,8 +104,7 @@ cd /home/admin/dsh/dual_camera_system
 
 ## Model Paths
 
-- Safety detection model: `/home/wen/dsh/yolo/yolo11m_safety.pt`
-- Class mapping: 0=helmet, 3=person, 4=reflective vest
+- Person detection model: configured in `config/detection.py`, default `/home/wen/dsh/yolo/yolo11n.pt`
 - YOLO11 weights auto-download on first run
 
 ## RTSP URL Pattern

+ 1790 - 0
docs/superpowers/plans/2026-06-15-ptz-360-scan-3d-panorama.md

@@ -0,0 +1,1790 @@
+# PTZ 360° 扫描 + 3D 全景轮询抓拍系统实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 用单进程 FastAPI 应用替换现有枪球联动逻辑,实现球机 360° 扫描建模、3D 全景交互选点、多路实时人识别、轮询扫描与自动上传。
+
+**架构:** FastAPI 提供 Web UI 和 REST API;后台线程管理多路 RTSP 取流、360° 扫描、扫描点轮询、双路人体检测;状态与配置通过共享内存和 JSON 文件共享;检测到人后保存图片并调用现有 `third_party_pusher` 上传。
+
+**Tech Stack:** Python 3.10, FastAPI, Uvicorn, OpenCV, NumPy, Three.js, Ultralytics/RKNN/ONNX(复用现有检测器), 大华 SDK ctypes。
+
+---
+
+## 文件结构
+
+新增/修改文件:
+
+```
+dual_camera_system/
+├── main.py                         # 修改:FastAPI 入口
+├── app.py                          # 新增:FastAPI 应用工厂
+├── requirements.txt                # 修改:添加 fastapi, uvicorn
+├── core/
+│   ├── __init__.py
+│   ├── coord_utils.py              # 新增:球面/角度坐标换算
+│   ├── stream_manager.py           # 新增:多路取流 + MJPEG 输出
+│   ├── spatial_scanner.py          # 新增:360° 扫描 + 全景图生成
+│   ├── scan_point_store.py         # 新增:扫描点配置持久化
+│   ├── polling_scheduler.py        # 新增:扫描点轮询
+│   ├── capture_uploader.py         # 新增:保存 + 上传
+│   └── group_state.py              # 新增:各组运行时共享状态
+├── web/
+│   ├── __init__.py
+│   ├── routes.py                   # 新增:REST API 路由
+│   └── state.py                    # 新增:Web 层访问共享状态的代理
+├── web_static/                     # 新增:前端静态文件
+│   ├── index.html
+│   ├── app.js
+│   └── style.css
+├── tests/
+│   ├── test_coord_utils.py
+│   ├── test_scan_point_store.py
+│   ├── test_spatial_scanner.py
+│   └── test_routes.py
+└── config/                         # 修改:清理校准配置,新增扫描配置
+
+计划删除的旧文件(最后阶段执行):
+- multi_group_system.py
+- camera_group.py
+- coordinator.py
+- calibration.py
+- tracker.py
+- polling_tracker.py
+- event_pusher.py(当前未使用)
+```
+
+---
+
+## Task 1: 更新依赖
+
+**Files:**
+- Modify: `dual_camera_system/requirements.txt`
+
+- [ ] **Step 1: 添加 FastAPI 与 Uvicorn**
+
+在 `requirements.txt` 末尾追加:
+
+```text
+fastapi>=0.110.0
+uvicorn[standard]>=0.27.0
+python-multipart>=0.0.9
+```
+
+- [ ] **Step 2: 安装依赖**
+
+Run:
+```bash
+cd dual_camera_system
+source ../.venv/bin/activate  # 或项目实际虚拟环境
+pip install -r requirements.txt
+```
+
+Expected: 安装成功,无报错。
+
+---
+
+## Task 2: 坐标换算工具
+
+**Files:**
+- Create: `dual_camera_system/core/coord_utils.py`
+- Test: `dual_camera_system/tests/test_coord_utils.py`
+
+- [ ] **Step 1: 写失败测试**
+
+Create `dual_camera_system/tests/test_coord_utils.py`:
+
+```python
+import sys
+import os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import math
+from core.coord_utils import spherical_to_pan_tilt, pan_tilt_to_vector
+
+
+def test_spherical_front():
+    # 正前方单位向量 -> pan=0, tilt=0
+    pan, tilt = spherical_to_pan_tilt(0.0, 0.0, 1.0)
+    assert abs(pan - 0.0) < 1e-6
+    assert abs(tilt - 0.0) < 1e-6
+
+
+def test_spherical_right():
+    # 右侧 -> pan=90
+    pan, tilt = spherical_to_pan_tilt(1.0, 0.0, 0.0)
+    assert abs(pan - 90.0) < 1e-6
+    assert abs(tilt - 0.0) < 1e-6
+
+
+def test_spherical_up():
+    # 上方 -> tilt=90
+    pan, tilt = spherical_to_pan_tilt(0.0, 1.0, 0.0)
+    assert abs(tilt - 90.0) < 1e-6
+
+
+def test_pan_tilt_to_vector_roundtrip():
+    for pan in [0, 45, 90, 180, 270]:
+        for tilt in [-20, 0, 20]:
+            x, y, z = pan_tilt_to_vector(pan, tilt)
+            pan2, tilt2 = spherical_to_pan_tilt(x, y, z)
+            assert abs(pan - pan2) < 1e-6 or abs(abs(pan - pan2) - 360) < 1e-6
+            assert abs(tilt - tilt2) < 1e-6
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run:
+```bash
+cd dual_camera_system
+pytest tests/test_coord_utils.py -v
+```
+
+Expected: `ModuleNotFoundError: No module named 'core.coord_utils'`
+
+- [ ] **Step 3: 实现坐标工具**
+
+Create `dual_camera_system/core/coord_utils.py`:
+
+```python
+"""球面坐标与 PTZ pan/tilt 角度换算工具."""
+import math
+from typing import Tuple
+
+
+def pan_tilt_to_vector(pan: float, tilt: float) -> Tuple[float, float, float]:
+    """把 pan/tilt(度)转成单位向量 (x, y, z)。
+
+    坐标系:x 向右,y 向上,z 向前;pan 从正前方 z 轴开始顺时针;
+    tilt 0 为水平,+90 为天顶。
+    """
+    pan_rad = math.radians(pan)
+    tilt_rad = math.radians(tilt)
+    x = math.cos(tilt_rad) * math.sin(pan_rad)
+    y = math.sin(tilt_rad)
+    z = math.cos(tilt_rad) * math.cos(pan_rad)
+    return x, y, z
+
+
+def spherical_to_pan_tilt(x: float, y: float, z: float) -> Tuple[float, float]:
+    """把单位向量 (x, y, z) 转成 pan/tilt(度)。"""
+    norm = math.sqrt(x * x + y * y + z * z)
+    if norm < 1e-9:
+        return 0.0, 0.0
+    x, y, z = x / norm, y / norm, z / norm
+    tilt = math.degrees(math.asin(max(-1.0, min(1.0, y))))
+    pan = math.degrees(math.atan2(x, z))
+    if pan < 0:
+        pan += 360.0
+    return pan, tilt
+
+
+def compute_sample_grid(
+    pan_range: Tuple[float, float] = (0.0, 360.0),
+    tilt_layers: Tuple[float, ...] = (-20.0, 0.0, 20.0),
+    pan_step: float = 30.0,
+) -> list:
+    """生成扫描采样网格 [(pan, tilt), ...]。"""
+    points = []
+    pan_start, pan_end = pan_range
+    pan = pan_start
+    while pan < pan_end - 1e-6:
+        for tilt in tilt_layers:
+            points.append((round(pan, 2), round(tilt, 2)))
+        pan += pan_step
+    return points
+```
+
+- [ ] **Step 4: 运行测试确认通过**
+
+Run:
+```bash
+cd dual_camera_system
+pytest tests/test_coord_utils.py -v
+```
+
+Expected: 4 tests PASS。
+
+---
+
+## Task 3: 扫描点存储
+
+**Files:**
+- Create: `dual_camera_system/core/scan_point_store.py`
+- Test: `dual_camera_system/tests/test_scan_point_store.py`
+
+- [ ] **Step 1: 写失败测试**
+
+Create `dual_camera_system/tests/test_scan_point_store.py`:
+
+```python
+import sys
+import os
+import tempfile
+import json
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from core.scan_point_store import ScanPointStore
+
+
+def test_add_and_list_points(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("group_1", {"ptz_name": "PTZ1", "panorama_name": "PAN1"})
+    store.add_enabled_point("group_1", pan=30.0, tilt=0.0, zoom=1, dwell_time=3.0)
+    points = store.list_enabled_points("group_1")
+    assert len(points) == 1
+    assert points[0]["pan"] == 30.0
+
+
+def test_persistence(tmp_path):
+    path = str(tmp_path / "scan_models.json")
+    store = ScanPointStore(path)
+    store.ensure_group("group_1", {"ptz_name": "PTZ1", "panorama_name": "PAN1"})
+    store.add_enabled_point("group_1", pan=60.0, tilt=-5.0, zoom=2, dwell_time=2.0)
+    del store
+
+    store2 = ScanPointStore(path)
+    points = store2.list_enabled_points("group_1")
+    assert len(points) == 1
+    assert points[0]["zoom"] == 2
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run:
+```bash
+cd dual_camera_system
+pytest tests/test_scan_point_store.py -v
+```
+
+Expected: ImportError 或 AttributeError。
+
+- [ ] **Step 3: 实现扫描点存储**
+
+Create `dual_camera_system/core/scan_point_store.py`:
+
+```python
+"""扫描点配置持久化."""
+import json
+import os
+import threading
+import time
+from typing import Any, Dict, List, Optional
+
+
+class ScanPointStore:
+    def __init__(self, filepath: str = "data/scan_models.json"):
+        self.filepath = filepath
+        self._lock = threading.Lock()
+        self._ensure_dir()
+        self._data = self._load()
+
+    def _ensure_dir(self):
+        dir_name = os.path.dirname(self.filepath)
+        if dir_name:
+            os.makedirs(dir_name, exist_ok=True)
+
+    def _load(self) -> Dict[str, Any]:
+        if os.path.exists(self.filepath):
+            with open(self.filepath, "r", encoding="utf-8") as f:
+                return json.load(f)
+        return {"camera_groups": {}}
+
+    def _save(self):
+        with open(self.filepath, "w", encoding="utf-8") as f:
+            json.dump(self._data, f, ensure_ascii=False, indent=2)
+
+    def ensure_group(self, group_id: str, meta: Dict[str, str]):
+        with self._lock:
+            groups = self._data.setdefault("camera_groups", {})
+            if group_id not in groups:
+                groups[group_id] = {
+                    "ptz_name": meta.get("ptz_name", group_id),
+                    "panorama_name": meta.get("panorama_name", group_id),
+                    "scan_config": {},
+                    "samples": [],
+                    "enabled_points": [],
+                    "panorama": {},
+                }
+                self._save()
+
+    def get_group(self, group_id: str) -> Optional[Dict[str, Any]]:
+        with self._lock:
+            return self._data.get("camera_groups", {}).get(group_id)
+
+    def set_scan_config(self, group_id: str, config: Dict[str, Any]):
+        with self._lock:
+            self.ensure_group(group_id, {})
+            self._data["camera_groups"][group_id]["scan_config"] = config
+            self._save()
+
+    def set_samples(self, group_id: str, samples: List[Dict[str, Any]]):
+        with self._lock:
+            self.ensure_group(group_id, {})
+            self._data["camera_groups"][group_id]["samples"] = samples
+            self._save()
+
+    def set_panorama(self, group_id: str, panorama: Dict[str, Any]):
+        with self._lock:
+            self.ensure_group(group_id, {})
+            self._data["camera_groups"][group_id]["panorama"] = panorama
+            self._save()
+
+    def add_enabled_point(
+        self,
+        group_id: str,
+        pan: float,
+        tilt: float,
+        zoom: int = 1,
+        dwell_time: float = 3.0,
+    ) -> Dict[str, Any]:
+        with self._lock:
+            self.ensure_group(group_id, {})
+            points = self._data["camera_groups"][group_id]["enabled_points"]
+            point_id = int(time.time() * 1000) + len(points)
+            point = {
+                "id": point_id,
+                "pan": round(float(pan), 2),
+                "tilt": round(float(tilt), 2),
+                "zoom": int(zoom),
+                "dwell_time": float(dwell_time),
+                "order": len(points),
+            }
+            points.append(point)
+            self._save()
+            return point
+
+    def update_enabled_point(self, group_id: str, point_id: int, updates: Dict[str, Any]) -> bool:
+        with self._lock:
+            points = self._data.get("camera_groups", {}).get(group_id, {}).get("enabled_points", [])
+            for p in points:
+                if p["id"] == point_id:
+                    for key in ("pan", "tilt", "zoom", "dwell_time", "order"):
+                        if key in updates:
+                            p[key] = updates[key]
+                    self._save()
+                    return True
+            return False
+
+    def delete_enabled_point(self, group_id: str, point_id: int) -> bool:
+        with self._lock:
+            group = self._data.get("camera_groups", {}).get(group_id)
+            if not group:
+                return False
+            points = group["enabled_points"]
+            new_points = [p for p in points if p["id"] != point_id]
+            if len(new_points) == len(points):
+                return False
+            for idx, p in enumerate(new_points):
+                p["order"] = idx
+            group["enabled_points"] = new_points
+            self._save()
+            return True
+
+    def list_enabled_points(self, group_id: str) -> List[Dict[str, Any]]:
+        with self._lock:
+            group = self._data.get("camera_groups", {}).get(group_id)
+            if not group:
+                return []
+            return sorted(group.get("enabled_points", []), key=lambda x: x.get("order", 0))
+
+    def reorder_points(self, group_id: str, point_ids: List[int]):
+        with self._lock:
+            points = self._data.get("camera_groups", {}).get(group_id, {}).get("enabled_points", [])
+            id_map = {p["id"]: p for p in points}
+            new_points = [id_map[i] for i in point_ids if i in id_map]
+            for idx, p in enumerate(new_points):
+                p["order"] = idx
+            self._data["camera_groups"][group_id]["enabled_points"] = new_points
+            self._save()
+```
+
+- [ ] **Step 4: 运行测试确认通过**
+
+Run:
+```bash
+cd dual_camera_system
+pytest tests/test_scan_point_store.py -v
+```
+
+Expected: 2 tests PASS。
+
+---
+
+## Task 4: 多路取流管理器
+
+**Files:**
+- Create: `dual_camera_system/core/stream_manager.py`
+- Modify: `dual_camera_system/panorama_camera.py`(复用连接/取流逻辑)
+
+- [ ] **Step 1: 实现 StreamManager**
+
+Create `dual_camera_system/core/stream_manager.py`:
+
+```python
+"""多路 RTSP 取流管理 + MJPEG 输出."""
+import threading
+import time
+from typing import Dict, Optional, Callable
+import cv2
+import numpy as np
+
+
+class CameraStream:
+    """单个摄像头的 RTSP 流封装."""
+
+    def __init__(self, name: str, rtsp_url: str):
+        self.name = name
+        self.rtsp_url = rtsp_url
+        self.cap: Optional[cv2.VideoCapture] = None
+        self.latest_frame: Optional[np.ndarray] = None
+        self.marked_frame: Optional[np.ndarray] = None
+        self.running = False
+        self.thread: Optional[threading.Thread] = None
+        self.lock = threading.Lock()
+        self._last_error: Optional[str] = None
+
+    def start(self):
+        if self.running:
+            return
+        self.running = True
+        self.thread = threading.Thread(target=self._worker, daemon=True)
+        self.thread.start()
+
+    def stop(self):
+        self.running = False
+        if self.thread:
+            self.thread.join(timeout=2.0)
+        if self.cap:
+            self.cap.release()
+            self.cap = None
+
+    def _worker(self):
+        reconnect_delay = 1.0
+        while self.running:
+            try:
+                self.cap = cv2.VideoCapture(self.rtsp_url)
+                self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
+                if not self.cap.isOpened():
+                    raise RuntimeError(f"Cannot open {self.rtsp_url}")
+                while self.running:
+                    ret, frame = self.cap.read()
+                    if not ret:
+                        raise RuntimeError("Frame read failed")
+                    with self.lock:
+                        self.latest_frame = frame
+                    reconnect_delay = 1.0
+            except Exception as e:
+                self._last_error = str(e)
+                time.sleep(reconnect_delay)
+                reconnect_delay = min(reconnect_delay * 2, 30.0)
+
+    def get_frame(self) -> Optional[np.ndarray]:
+        with self.lock:
+            return self.latest_frame.copy() if self.latest_frame is not None else None
+
+    def set_marked_frame(self, frame: np.ndarray):
+        with self.lock:
+            self.marked_frame = frame.copy()
+
+    def get_marked_frame(self) -> Optional[np.ndarray]:
+        with self.lock:
+            return self.marked_frame.copy() if self.marked_frame is not None else None
+
+
+class StreamManager:
+    def __init__(self):
+        self._streams: Dict[str, CameraStream] = {}
+        self._lock = threading.Lock()
+
+    def register(self, stream_id: str, rtsp_url: str) -> CameraStream:
+        with self._lock:
+            if stream_id not in self._streams:
+                stream = CameraStream(stream_id, rtsp_url)
+                self._streams[stream_id] = stream
+                stream.start()
+            return self._streams[stream_id]
+
+    def unregister(self, stream_id: str):
+        with self._lock:
+            stream = self._streams.pop(stream_id, None)
+            if stream:
+                stream.stop()
+
+    def get(self, stream_id: str) -> Optional[CameraStream]:
+        with self._lock:
+            return self._streams.get(stream_id)
+
+    def stop_all(self):
+        with self._lock:
+            streams = list(self._streams.values())
+            self._streams.clear()
+        for s in streams:
+            s.stop()
+
+
+def encode_mjpeg_frame(frame: np.ndarray, quality: int = 80) -> bytes:
+    """把 numpy 帧编码为 JPEG bytes。"""
+    encode_params = [int(cv2.IMWRITE_JPEG_QUALITY), quality]
+    ret, buf = cv2.imencode(".jpg", frame, encode_params)
+    if not ret:
+        raise RuntimeError("JPEG encode failed")
+    return buf.tobytes()
+
+
+def generate_mjpeg_stream(get_frame: Callable[[], Optional[np.ndarray]], fps: int = 15):
+    """MJPEG 流生成器,用于 FastAPI StreamingResponse。"""
+    period = 1.0 / fps
+    while True:
+        frame = get_frame()
+        if frame is None:
+            time.sleep(period)
+            continue
+        jpeg = encode_mjpeg_frame(frame)
+        yield (
+            b"--frame\r\n"
+            b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n"
+        )
+        time.sleep(period)
+```
+
+- [ ] **Step 2: 写 StreamManager 基础测试**
+
+Create `dual_camera_system/tests/test_stream_manager.py`:
+
+```python
+import sys
+import os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import numpy as np
+from core.stream_manager import encode_mjpeg_frame
+
+
+def test_encode_mjpeg_frame():
+    frame = np.zeros((100, 100, 3), dtype=np.uint8)
+    data = encode_mjpeg_frame(frame)
+    assert data[:2] == b"\xff\xd8"  # JPEG SOI marker
+```
+
+Run:
+```bash
+cd dual_camera_system
+pytest tests/test_stream_manager.py -v
+```
+
+Expected: PASS。
+
+---
+
+## Task 5: 360° 空间扫描器
+
+**Files:**
+- Create: `dual_camera_system/core/spatial_scanner.py`
+- Test: `dual_camera_system/tests/test_spatial_scanner.py`
+
+- [ ] **Step 1: 实现 SpatialScanner**
+
+Create `dual_camera_system/core/spatial_scanner.py`:
+
+```python
+"""球机 360° 扫描与全景图生成."""
+import os
+import time
+from typing import Callable, Dict, List, Optional, Tuple
+import cv2
+import numpy as np
+
+from core.coord_utils import compute_sample_grid
+
+
+class SpatialScanner:
+    def __init__(
+        self,
+        group_id: str,
+        ptz_camera,
+        ptz_frame_source: Callable[[], Optional[np.ndarray]],
+        data_dir: str = "data",
+        stabilize_time: float = 1.5,
+    ):
+        self.group_id = group_id
+        self.ptz = ptz_camera
+        self.get_ptz_frame = ptz_frame_source
+        self.data_dir = os.path.join(data_dir, group_id)
+        os.makedirs(os.path.join(self.data_dir, "samples"), exist_ok=True)
+        os.makedirs(os.path.join(self.data_dir, "panorama"), exist_ok=True)
+        self.stabilize_time = stabilize_time
+        self.progress = {"total": 0, "current": 0, "state": "idle"}
+        self.cancelled = False
+
+    def cancel(self):
+        self.cancelled = True
+
+    def run(
+        self,
+        pan_range: Tuple[float, float] = (0.0, 360.0),
+        tilt_layers: Tuple[float, ...] = (-20.0, 0.0, 20.0),
+        pan_step: float = 30.0,
+        zoom: int = 1,
+        progress_callback: Optional[Callable[[Dict], None]] = None,
+    ) -> Dict:
+        self.cancelled = False
+        grid = compute_sample_grid(pan_range, tilt_layers, pan_step)
+        self.progress = {"total": len(grid), "current": 0, "state": "scanning"}
+        samples: List[Dict] = []
+
+        for idx, (pan, tilt) in enumerate(grid):
+            if self.cancelled:
+                break
+            self.ptz.goto_exact_position(pan, tilt, zoom)
+            time.sleep(self.stabilize_time)
+            frame = self._wait_frame(timeout=5.0)
+            if frame is None:
+                continue
+            filename = f"p{int(pan)}_t{int(tilt)}.jpg"
+            path = os.path.join(self.data_dir, "samples", filename)
+            cv2.imwrite(path, frame)
+            samples.append({
+                "id": idx + 1,
+                "pan": pan,
+                "tilt": tilt,
+                "zoom": zoom,
+                "thumbnail": path,
+            })
+            self.progress["current"] = idx + 1
+            if progress_callback:
+                progress_callback(dict(self.progress))
+
+        panorama_path = self._build_equirectangular(samples, pan_range, tilt_layers)
+        self.progress["state"] = "done"
+        return {
+            "group_id": self.group_id,
+            "samples": samples,
+            "panorama_path": panorama_path,
+            "config": {
+                "pan_range": pan_range,
+                "tilt_layers": list(tilt_layers),
+                "pan_step": pan_step,
+                "zoom": zoom,
+            },
+        }
+
+    def _wait_frame(self, timeout: float = 5.0) -> Optional[np.ndarray]:
+        deadline = time.time() + timeout
+        while time.time() < deadline:
+            frame = self.get_ptz_frame()
+            if frame is not None:
+                return frame
+            time.sleep(0.1)
+        return None
+
+    def _build_equirectangular(
+        self,
+        samples: List[Dict],
+        pan_range: Tuple[float, float],
+        tilt_layers: Tuple[float, ...],
+        width: int = 4096,
+        height: int = 2048,
+    ) -> Optional[str]:
+        if not samples:
+            return None
+        # 简单映射:每个采样图直接投影到等距圆柱全景图的对应列条带
+        panorama = np.zeros((height, width, 3), dtype=np.uint8)
+        pan_start, pan_end = pan_range
+        for s in samples:
+            frame = cv2.imread(s["thumbnail"])
+            if frame is None:
+                continue
+            h, w = frame.shape[:2]
+            pan = s["pan"]
+            tilt = s["tilt"]
+            # 计算该采样图在全景图上的中心位置
+            u_center = int(((pan - pan_start) / (pan_end - pan_start)) * width)
+            v_center = int(((90 - tilt) / 180) * height)
+            # 取垂直条带,高度与画面高度一致
+            y_start = max(0, v_center - h // 2)
+            y_end = min(height, v_center + h // 2)
+            x_start = max(0, u_center - w // 2)
+            x_end = min(width, u_center + w // 2)
+            # 简单覆盖
+            paste_h = y_end - y_start
+            paste_w = x_end - x_start
+            if paste_h <= 0 or paste_w <= 0:
+                continue
+            roi = frame[(h - paste_h) // 2:(h - paste_h) // 2 + paste_h,
+                        (w - paste_w) // 2:(w - paste_w) // 2 + paste_w]
+            panorama[y_start:y_end, x_start:x_end] = roi
+
+        path = os.path.join(self.data_dir, "panorama", f"scan_{int(time.time())}.jpg")
+        cv2.imwrite(path, panorama)
+        return path
+```
+
+- [ ] **Step 2: 写 SpatialScanner 测试**
+
+Create `dual_camera_system/tests/test_spatial_scanner.py`:
+
+```python
+import sys
+import os
+import tempfile
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import numpy as np
+from core.spatial_scanner import SpatialScanner
+
+
+class FakePTZ:
+    def __init__(self):
+        self.positions = []
+    def goto_exact_position(self, pan, tilt, zoom):
+        self.positions.append((pan, tilt, zoom))
+
+
+def test_spatial_scanner_grid(tmp_path):
+    ptz = FakePTZ()
+    counter = {"n": 0}
+
+    def frame_source():
+        counter["n"] += 1
+        return np.zeros((120, 160, 3), dtype=np.uint8)
+
+    scanner = SpatialScanner("g1", ptz, frame_source, str(tmp_path), stabilize_time=0.0)
+    result = scanner.run(pan_range=(0, 90), tilt_layers=(-10, 0), pan_step=30, zoom=1)
+    assert len(result["samples"]) == 6  # 0,30,60 x 2 layers
+    assert result["panorama_path"] is not None
+```
+
+Run:
+```bash
+cd dual_camera_system
+pytest tests/test_spatial_scanner.py -v
+```
+
+Expected: PASS。
+
+---
+
+## Task 6: 轮询调度器
+
+**Files:**
+- Create: `dual_camera_system/core/polling_scheduler.py`
+
+- [ ] **Step 1: 实现 PollingScheduler**
+
+Create `dual_camera_system/core/polling_scheduler.py`:
+
+```python
+"""扫描点轮询调度器."""
+import threading
+import time
+from typing import Callable, Dict, List, Optional
+
+
+class PollingScheduler:
+    def __init__(
+        self,
+        group_id: str,
+        ptz_camera,
+        get_points: Callable[[], List[Dict]],
+        on_arrived: Optional[Callable[[Dict], None]] = None,
+        on_finished: Optional[Callable[[], None]] = None,
+        default_dwell: float = 3.0,
+        stabilize_time: float = 1.5,
+    ):
+        self.group_id = group_id
+        self.ptz = ptz_camera
+        self.get_points = get_points
+        self.on_arrived = on_arrived
+        self.on_finished = on_finished
+        self.default_dwell = default_dwell
+        self.stabilize_time = stabilize_time
+        self.running = False
+        self.paused = False
+        self.thread: Optional[threading.Thread] = None
+        self.current_point: Optional[Dict] = None
+
+    def start(self):
+        if self.running:
+            return
+        self.running = True
+        self.paused = False
+        self.thread = threading.Thread(target=self._loop, daemon=True)
+        self.thread.start()
+
+    def stop(self):
+        self.running = False
+        if self.thread:
+            self.thread.join(timeout=5.0)
+
+    def pause(self):
+        self.paused = True
+
+    def resume(self):
+        self.paused = False
+
+    def _loop(self):
+        while self.running:
+            points = self.get_points()
+            if not points:
+                time.sleep(1.0)
+                continue
+            for point in points:
+                if not self.running:
+                    break
+                while self.paused and self.running:
+                    time.sleep(0.5)
+                if not self.running:
+                    break
+                self.current_point = point
+                self.ptz.goto_exact_position(
+                    point["pan"], point["tilt"], point.get("zoom", 1)
+                )
+                time.sleep(self.stabilize_time)
+                if self.on_arrived:
+                    self.on_arrived(point)
+                dwell = point.get("dwell_time", self.default_dwell)
+                deadline = time.time() + dwell
+                while time.time() < deadline and self.running and not self.paused:
+                    time.sleep(0.1)
+            if self.on_finished:
+                self.on_finished()
+        self.current_point = None
+```
+
+---
+
+## Task 7: 检测与上传集成
+
+**Files:**
+- Create: `dual_camera_system/core/capture_uploader.py`
+- Modify: `dual_camera_system/panorama_camera.py`(复用 ObjectDetector)
+
+- [ ] **Step 1: 实现 CaptureUploader**
+
+Create `dual_camera_system/core/capture_uploader.py`:
+
+```python
+"""检测到人后的保存 + 上传."""
+import os
+import time
+from typing import Any, Callable, Dict, List, Optional
+import cv2
+import numpy as np
+
+
+class CaptureUploader:
+    def __init__(
+        self,
+        group_id: str,
+        save_dir: str = "data/captures",
+        upload_callback: Optional[Callable[[Dict], None]] = None,
+        dedup_seconds: float = 5.0,
+    ):
+        self.group_id = group_id
+        self.save_dir = os.path.join(save_dir, group_id)
+        os.makedirs(self.save_dir, exist_ok=True)
+        self.upload_callback = upload_callback
+        self.dedup_seconds = dedup_seconds
+        self._last_uploads: List[Dict[str, Any]] = []
+
+    def _should_upload(self, camera_type: str, bbox: List[float]) -> bool:
+        now = time.time()
+        cx = (bbox[0] + bbox[2]) / 2
+        cy = (bbox[1] + bbox[3]) / 2
+        self._last_uploads = [
+            u for u in self._last_uploads
+            if now - u["time"] < self.dedup_seconds
+        ]
+        for u in self._last_uploads:
+            if u["camera_type"] != camera_type:
+                continue
+            dx = abs(u["cx"] - cx)
+            dy = abs(u["cy"] - cy)
+            if dx < 50 and dy < 50:
+                return False
+        return True
+
+    def handle_detection(
+        self,
+        camera_type: str,  # 'panorama' or 'ptz'
+        frame: np.ndarray,
+        detections: List[Dict],
+        ptz_position: Optional[Dict] = None,
+    ) -> List[Dict]:
+        results = []
+        if not detections:
+            return results
+        ts = int(time.time() * 1000)
+        original_path = os.path.join(self.save_dir, f"{camera_type}_{ts}_original.jpg")
+        cv2.imwrite(original_path, frame)
+
+        marked = frame.copy()
+        for det in detections:
+            x1, y1, x2, y2 = det["bbox"]
+            cv2.rectangle(marked, (x1, y1), (x2, y2), (0, 255, 0), 2)
+            cv2.putText(marked, f"{det['confidence']:.2f}", (x1, y1 - 5),
+                        cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
+        marked_path = os.path.join(self.save_dir, f"{camera_type}_{ts}_marked.jpg")
+        cv2.imwrite(marked_path, marked)
+
+        for det in detections:
+            if not self._should_upload(camera_type, det["bbox"]):
+                continue
+            payload = {
+                "group_id": self.group_id,
+                "camera_type": camera_type,
+                "timestamp": ts,
+                "original": original_path,
+                "marked": marked_path,
+                "bbox": det["bbox"],
+                "confidence": det["confidence"],
+                "ptz_position": ptz_position,
+            }
+            if self.upload_callback:
+                try:
+                    self.upload_callback(payload)
+                except Exception:
+                    pass
+            self._last_uploads.append({
+                "camera_type": camera_type,
+                "cx": (det["bbox"][0] + det["bbox"][2]) / 2,
+                "cy": (det["bbox"][1] + det["bbox"][3]) / 2,
+                "time": time.time(),
+            })
+            results.append(payload)
+        return results
+```
+
+- [ ] **Step 2: 创建检测服务封装**
+
+Create `dual_camera_system/core/detector_service.py`:
+
+```python
+"""复用现有 ObjectDetector 的薄封装."""
+import sys
+import os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from panorama_camera import ObjectDetector
+
+
+class DetectorService:
+    def __init__(self, model_path: str = None, use_gpu: bool = True):
+        self.detector = ObjectDetector(model_path=model_path, use_gpu=use_gpu)
+
+    def detect(self, frame):
+        return self.detector.detect(frame)
+```
+
+---
+
+## Task 8: 运行时状态
+
+**Files:**
+- Create: `dual_camera_system/core/group_state.py`
+
+- [ ] **Step 1: 实现 GroupState**
+
+Create `dual_camera_system/core/group_state.py`:
+
+```python
+"""各摄像头组运行时共享状态."""
+import threading
+from typing import Any, Dict
+
+
+class GroupState:
+    def __init__(self):
+        self._data: Dict[str, Dict[str, Any]] = {}
+        self._lock = threading.Lock()
+
+    def init_group(self, group_id: str, panorama_rtsp: str, ptz_rtsp: str, ptz_config: Dict):
+        with self._lock:
+            self._data[group_id] = {
+                "panorama_rtsp": panorama_rtsp,
+                "ptz_rtsp": ptz_rtsp,
+                "ptz_position": {"pan": 0.0, "tilt": 0.0, "zoom": 1},
+                "polling_state": "idle",  # idle|scanning|polling|paused
+                "scan_progress": {"total": 0, "current": 0, "state": "idle"},
+                "last_detection": None,
+                "logs": [],
+            }
+
+    def get(self, group_id: str) -> Dict[str, Any]:
+        with self._lock:
+            return self._data.get(group_id, {})
+
+    def update(self, group_id: str, key: str, value: Any):
+        with self._lock:
+            if group_id in self._data:
+                self._data[group_id][key] = value
+
+    def append_log(self, group_id: str, message: str):
+        with self._lock:
+            if group_id in self._data:
+                self._data[group_id]["logs"].append(message)
+                self._data[group_id]["logs"] = self._data[group_id]["logs"][-200:]
+
+
+# 全局单例
+group_state = GroupState()
+```
+
+---
+
+## Task 9: Web API 路由
+
+**Files:**
+- Create: `dual_camera_system/web/routes.py`
+- Create: `dual_camera_system/web/state.py`
+
+- [ ] **Step 1: 实现 Web 状态代理和路由**
+
+Create `dual_camera_system/web/state.py`:
+
+```python
+"""Web 层访问共享状态与全局服务的代理."""
+from typing import Any, Dict
+
+
+class WebState:
+    def __init__(self, group_state, stream_manager, scan_store, scanners: Dict, schedulers: Dict):
+        self.group_state = group_state
+        self.stream_manager = stream_manager
+        self.scan_store = scan_store
+        self.scanners = scanners
+        self.schedulers = schedulers
+
+
+web_state: WebState = None
+```
+
+Create `dual_camera_system/web/routes.py`:
+
+```python
+"""FastAPI REST 路由."""
+import os
+
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import StreamingResponse, FileResponse
+
+from web.state import web_state
+from core.stream_manager import generate_mjpeg_stream
+
+router = APIRouter()
+
+
+@router.get("/api/status")
+def api_status():
+    groups = {}
+    for gid in web_state.group_state._data:
+        groups[gid] = web_state.group_state.get(gid)
+    return {"groups": groups}
+
+
+@router.post("/api/scan/{group_id}")
+def api_start_scan(group_id: str):
+    scanner = web_state.scanners.get(group_id)
+    if not scanner:
+        raise HTTPException(status_code=404, detail="Scanner not found")
+    # 启动后台线程执行扫描
+    import threading
+    def run():
+        result = scanner.run(
+            pan_range=(0.0, 360.0),
+            tilt_layers=(-20.0, 0.0, 20.0),
+            pan_step=30.0,
+            zoom=1,
+            progress_callback=lambda p: web_state.group_state.update(group_id, "scan_progress", p),
+        )
+        web_state.scan_store.set_samples(group_id, result["samples"])
+        web_state.scan_store.set_panorama(group_id, {
+            "equirectangular": result["panorama_path"],
+            "width": 4096,
+            "height": 2048,
+        })
+        web_state.scan_store.set_scan_config(group_id, result["config"])
+        web_state.group_state.update(group_id, "polling_state", "idle")
+
+    web_state.group_state.update(group_id, "polling_state", "scanning")
+    threading.Thread(target=run, daemon=True).start()
+    return {"message": "Scan started", "group_id": group_id}
+
+
+@router.get("/api/scan/{group_id}/progress")
+def api_scan_progress(group_id: str):
+    return web_state.group_state.get(group_id).get("scan_progress", {})
+
+
+@router.get("/api/points/{group_id}")
+def api_list_points(group_id: str):
+    return {"points": web_state.scan_store.list_enabled_points(group_id)}
+
+
+@router.post("/api/points/{group_id}")
+def api_add_point(group_id: str, payload: dict):
+    point = web_state.scan_store.add_enabled_point(
+        group_id,
+        pan=payload["pan"],
+        tilt=payload["tilt"],
+        zoom=payload.get("zoom", 1),
+        dwell_time=payload.get("dwell_time", 3.0),
+    )
+    return point
+
+
+@router.delete("/api/points/{group_id}/{point_id}")
+def api_delete_point(group_id: str, point_id: int):
+    ok = web_state.scan_store.delete_enabled_point(group_id, point_id)
+    if not ok:
+        raise HTTPException(status_code=404, detail="Point not found")
+    return {"ok": True}
+
+
+@router.post("/api/poll/{group_id}/start")
+def api_poll_start(group_id: str):
+    scheduler = web_state.schedulers.get(group_id)
+    if not scheduler:
+        raise HTTPException(status_code=404, detail="Scheduler not found")
+    scheduler.start()
+    web_state.group_state.update(group_id, "polling_state", "polling")
+    return {"message": "Polling started"}
+
+
+@router.post("/api/poll/{group_id}/stop")
+def api_poll_stop(group_id: str):
+    scheduler = web_state.schedulers.get(group_id)
+    if scheduler:
+        scheduler.stop()
+    web_state.group_state.update(group_id, "polling_state", "idle")
+    return {"message": "Polling stopped"}
+
+
+@router.get("/api/live/{camera}/{group_id}")
+def api_live(camera: str, group_id: str, marked: bool = False):
+    stream_id = f"{group_id}_{camera}"
+    stream = web_state.stream_manager.get(stream_id)
+    if not stream:
+        raise HTTPException(status_code=404, detail="Stream not found")
+    getter = stream.get_marked_frame if marked else stream.get_frame
+    return StreamingResponse(
+        generate_mjpeg_stream(getter),
+        media_type="multipart/x-mixed-replace; boundary=frame",
+    )
+
+
+@router.get("/api/panorama/{group_id}")
+def api_panorama(group_id: str):
+    group = web_state.scan_store.get_group(group_id)
+    if not group:
+        raise HTTPException(status_code=404, detail="Group not found")
+    path = group.get("panorama", {}).get("equirectangular")
+    if not path or not os.path.exists(path):
+        raise HTTPException(status_code=404, detail="Panorama not found")
+    return FileResponse(path)
+```
+
+---
+
+## Task 10: FastAPI 应用工厂与入口
+
+**Files:**
+- Create: `dual_camera_system/app.py`
+- Modify: `dual_camera_system/main.py`
+
+- [ ] **Step 1: 实现 app.py**
+
+Create `dual_camera_system/app.py`:
+
+```python
+"""FastAPI 应用工厂与全局服务初始化."""
+import os
+import time
+from contextlib import asynccontextmanager
+
+import cv2
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+
+from config import CAMERA_GROUPS, SDK_PATH
+from dahua_sdk import DahuaSDK
+from panorama_camera import ObjectDetector
+from ptz_camera import PTZCamera
+from third_party_pusher import get_third_party_pusher
+
+from core.stream_manager import StreamManager
+from core.scan_point_store import ScanPointStore
+from core.spatial_scanner import SpatialScanner
+from core.polling_scheduler import PollingScheduler
+from core.capture_uploader import CaptureUploader
+from core.detector_service import DetectorService
+from core.group_state import group_state
+from web.routes import router
+from web.state import WebState, web_state
+
+
+def build_rtsp_url(camera_config: dict) -> str:
+    ip = camera_config["ip"]
+    port = camera_config.get("rtsp_port", 554)
+    username = camera_config["username"]
+    password = camera_config["password"]
+    channel = camera_config.get("channel", 1)
+    subtype = camera_config.get("subtype", 0)
+    return f"rtsp://{username}:{password}@{ip}:{port}/cam/realmonitor?channel={channel}&subtype={subtype}"
+
+
+def create_app(test_mode: bool = False) -> FastAPI:
+    @asynccontextmanager
+    async def lifespan(app: FastAPI):
+        stream_manager = StreamManager()
+        scan_store = ScanPointStore("data/scan_models.json")
+        scanners: dict = {}
+        schedulers: dict = {}
+
+        if not test_mode:
+            detector = ObjectDetector()
+            sdk = DahuaSDK(SDK_PATH["lib_path"])
+            sdk.init()
+
+            pusher = get_third_party_pusher()
+            if pusher and not pusher.running:
+                pusher.start()
+
+            for group in CAMERA_GROUPS:
+                if not group.get("enabled", True):
+                    continue
+                gid = group["id"]
+                ptz_cfg = group["ptz"]
+                pano_cfg = group["panorama"]
+
+                # 连接球机
+                ptz = PTZCamera(ptz_cfg, sdk=sdk)
+                ptz.connect()
+                ptz.start_stream_rtsp()
+
+                # 连接枪机
+                pano_url = build_rtsp_url(pano_cfg)
+                stream_manager.register(f"{gid}_panorama", pano_url)
+
+                ptz_url = build_rtsp_url(ptz_cfg)
+                stream_manager.register(f"{gid}_ptz", ptz_url)
+
+                group_state.init_group(gid, pano_url, ptz_url, ptz_cfg)
+                scan_store.ensure_group(gid, {
+                    "ptz_name": ptz_cfg.get("name", gid),
+                    "panorama_name": pano_cfg.get("name", gid),
+                })
+
+                scanners[gid] = SpatialScanner(
+                    gid, ptz, lambda g=gid: stream_manager.get(f"{g}_ptz").get_frame()
+                )
+
+                uploader = CaptureUploader(gid, upload_callback=pusher.report_batch if pusher else None)
+                detector_service = DetectorService()
+
+                def on_arrived(point, g=gid, ptz=ptz):
+                    group_state.update(g, "ptz_position", {
+                        "pan": point["pan"], "tilt": point["tilt"], "zoom": point.get("zoom", 1)
+                    })
+
+                schedulers[gid] = PollingScheduler(
+                    gid, ptz,
+                    get_points=lambda g=gid: scan_store.list_enabled_points(g),
+                    on_arrived=on_arrived,
+                    default_dwell=3.0,
+                )
+
+                import threading
+                def panorama_detect_loop(g=gid, uploader=uploader, detector=detector_service):
+                    interval = 0.5
+                    while True:
+                        stream = stream_manager.get(f"{g}_panorama")
+                        frame = stream.get_frame() if stream else None
+                        if frame is not None:
+                            dets = detector.detect(frame)
+                            marked = frame.copy()
+                            if dets:
+                                det_dicts = [{
+                                    "bbox": [d.bbox[0], d.bbox[1], d.bbox[0]+d.bbox[2], d.bbox[1]+d.bbox[3]],
+                                    "confidence": d.confidence,
+                                } for d in dets]
+                                uploader.handle_detection("panorama", frame, det_dicts)
+                                for d in det_dicts:
+                                    x1, y1, x2, y2 = d["bbox"]
+                                    cv2.rectangle(marked, (x1, y1), (x2, y2), (0, 255, 0), 2)
+                            stream.set_marked_frame(marked)
+                        time.sleep(interval)
+
+                threading.Thread(target=panorama_detect_loop, daemon=True).start()
+
+                def ptz_detect_loop(g=gid, uploader=uploader, detector=detector_service):
+                    interval = 0.2
+                    while True:
+                        stream = stream_manager.get(f"{g}_ptz")
+                        frame = stream.get_frame() if stream else None
+                        if frame is not None:
+                            dets = detector.detect(frame)
+                            marked = frame.copy()
+                            if dets:
+                                det_dicts = [{
+                                    "bbox": [d.bbox[0], d.bbox[1], d.bbox[0]+d.bbox[2], d.bbox[1]+d.bbox[3]],
+                                    "confidence": d.confidence,
+                                } for d in dets]
+                                pos = group_state.get(g).get("ptz_position")
+                                uploader.handle_detection("ptz", frame, det_dicts, pos)
+                                for d in det_dicts:
+                                    x1, y1, x2, y2 = d["bbox"]
+                                    cv2.rectangle(marked, (x1, y1), (x2, y2), (0, 255, 0), 2)
+                            stream.set_marked_frame(marked)
+                        time.sleep(interval)
+
+                threading.Thread(target=ptz_detect_loop, daemon=True).start()
+
+        global web_state
+        web_state = WebState(group_state, stream_manager, scan_store, scanners, schedulers)
+
+        yield
+
+        # 清理
+        for s in schedulers.values():
+            s.stop()
+        stream_manager.stop_all()
+        if not test_mode:
+            sdk.cleanup()
+
+    app = FastAPI(lifespan=lifespan)
+    static_dir = os.path.join(os.path.dirname(__file__), "web_static")
+    app.mount("/static", StaticFiles(directory=static_dir), name="static")
+    app.include_router(router)
+
+    @app.get("/")
+    async def root():
+        return FileResponse(os.path.join(static_dir, "index.html"))
+
+    return app
+```
+
+- [ ] **Step 2: 修改 main.py**
+
+Replace `dual_camera_system/main.py` with:
+
+```python
+"""FastAPI 入口."""
+import uvicorn
+from app import create_app
+
+if __name__ == "__main__":
+    app = create_app()
+    uvicorn.run(app, host="0.0.0.0", port=8000)
+```
+
+---
+
+## Task 11: 前端 3D 全景页面
+
+**Files:**
+- Create: `dual_camera_system/web_static/index.html`
+- Create: `dual_camera_system/web_static/app.js`
+- Create: `dual_camera_system/web_static/style.css`
+
+- [ ] **Step 1: 创建 index.html**
+
+Create `dual_camera_system/web_static/index.html`:
+
+```html
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>PTZ 扫描轮询控制台</title>
+    <link rel="stylesheet" href="/static/style.css">
+    <script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/three@0.160.0/examples/js/controls/OrbitControls.js"></script>
+</head>
+<body>
+    <div id="toolbar">
+        <select id="group-select"></select>
+        <button id="btn-scan">执行 360° 扫描建模</button>
+        <button id="btn-poll-start">开始轮询</button>
+        <button id="btn-poll-stop">停止轮询</button>
+        <span id="status">状态:空闲</span>
+    </div>
+    <div id="video-grid"></div>
+    <div id="panorama-panel">
+        <div id="panorama-container"></div>
+        <div id="point-list">
+            <h3>已选扫描点</h3>
+            <ul id="points"></ul>
+            <div id="point-form">
+                <label>Pan: <input id="inp-pan" type="number" step="0.1"></label>
+                <label>Tilt: <input id="inp-tilt" type="number" step="0.1"></label>
+                <label>Zoom: <input id="inp-zoom" type="number" value="1"></label>
+                <label>停留(s): <input id="inp-dwell" type="number" value="3" step="0.1"></label>
+                <button id="btn-preview">球机预览</button>
+                <button id="btn-add">保存扫描点</button>
+            </div>
+        </div>
+    </div>
+    <div id="log-panel"></div>
+    <script src="/static/app.js"></script>
+</body>
+</html>
+```
+
+- [ ] **Step 2: 创建 style.css**
+
+Create `dual_camera_system/web_static/style.css`:
+
+```css
+body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #e2e8f0; }
+#toolbar { padding: 10px 16px; background: #1e293b; display: flex; gap: 12px; align-items: center; border-bottom: 1px solid #334155; }
+#toolbar button { padding: 6px 12px; border: none; border-radius: 4px; background: #3b82f6; color: #fff; cursor: pointer; }
+#toolbar button:hover { background: #2563eb; }
+#status { margin-left: auto; color: #94a3b8; }
+#video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 12px; padding: 12px; }
+.video-box { background: #1e293b; border-radius: 8px; overflow: hidden; }
+.video-box .title { padding: 8px 12px; font-size: 13px; color: #cbd5e1; }
+.video-box img { width: 100%; display: block; background: #000; }
+#panorama-panel { display: grid; grid-template-columns: 1fr 280px; gap: 12px; padding: 12px; }
+#panorama-container { height: 500px; background: #111; border-radius: 8px; position: relative; }
+#point-list { background: #1e293b; border-radius: 8px; padding: 12px; }
+#point-list h3 { margin-top: 0; }
+#point-list label { display: block; margin-bottom: 8px; font-size: 13px; }
+#point-list input { width: 80px; padding: 4px; border-radius: 4px; border: 1px solid #475569; background: #0f172a; color: #fff; }
+#point-list button { margin-top: 8px; margin-right: 8px; padding: 6px 10px; border: none; border-radius: 4px; background: #22c55e; color: #fff; cursor: pointer; }
+#points { list-style: none; padding: 0; max-height: 200px; overflow: auto; }
+#points li { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #334155; font-size: 13px; }
+#log-panel { padding: 12px; background: #1e293b; font-family: monospace; font-size: 12px; max-height: 160px; overflow: auto; border-top: 1px solid #334155; }
+```
+
+- [ ] **Step 3: 创建 app.js**
+
+Create `dual_camera_system/web_static/app.js`:
+
+```javascript
+const API = {
+  get: (url) => fetch(url).then(r => r.json()),
+  post: (url, body={}) => fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}).then(r => r.json()),
+  del: (url) => fetch(url, {method:'DELETE'}).then(r => r.json()),
+};
+
+let currentGroup = null;
+let scene, camera, renderer, sphere, controls;
+let raycaster = new THREE.Raycaster();
+let mouse = new THREE.Vector2();
+let pendingPoint = null;
+
+function initThree() {
+  const container = document.getElementById('panorama-container');
+  scene = new THREE.Scene();
+  camera = new THREE.PerspectiveCamera(75, container.clientWidth/container.clientHeight, 0.1, 1000);
+  camera.position.set(0, 0, 0.1);
+  renderer = new THREE.WebGLRenderer();
+  renderer.setSize(container.clientWidth, container.clientHeight);
+  container.appendChild(renderer.domElement);
+  controls = new THREE.OrbitControls(camera, renderer.domElement);
+  controls.enableZoom = true;
+  controls.enablePan = false;
+  controls.rotateSpeed = -0.3;
+  animate();
+
+  renderer.domElement.addEventListener('click', onPanoramaClick);
+}
+
+function loadPanorama(groupId) {
+  const loader = new THREE.TextureLoader();
+  loader.load(`/api/panorama/${groupId}`, (texture) => {
+    if (sphere) scene.remove(sphere);
+    const geo = new THREE.SphereGeometry(50, 60, 40);
+    geo.scale(-1, 1, 1);
+    const mat = new THREE.MeshBasicMaterial({ map: texture });
+    sphere = new THREE.Mesh(geo, mat);
+    scene.add(sphere);
+  }, undefined, () => {
+    console.warn('Panorama not ready');
+  });
+}
+
+function onPanoramaClick(event) {
+  if (!sphere) return;
+  const rect = renderer.domElement.getBoundingClientRect();
+  mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
+  mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
+  raycaster.setFromCamera(mouse, camera);
+  const intersects = raycaster.intersectObject(sphere);
+  if (intersects.length === 0) return;
+  const p = intersects[0].point.clone().normalize();
+  // 换算 pan/tilt
+  const pan = ((Math.atan2(p.x, p.z) * 180 / Math.PI) + 360) % 360;
+  const tilt = Math.asin(Math.max(-1, Math.min(1, p.y))) * 180 / Math.PI;
+  pendingPoint = {pan, tilt};
+  document.getElementById('inp-pan').value = pan.toFixed(2);
+  document.getElementById('inp-tilt').value = tilt.toFixed(2);
+}
+
+function animate() {
+  requestAnimationFrame(animate);
+  if (controls) controls.update();
+  renderer.render(scene, camera);
+}
+
+async function loadGroups() {
+  const status = await API.get('/api/status');
+  const select = document.getElementById('group-select');
+  select.innerHTML = '';
+  Object.keys(status.groups).forEach(gid => {
+    const opt = document.createElement('option');
+    opt.value = gid;
+    opt.textContent = gid;
+    select.appendChild(opt);
+  });
+  if (select.options.length && !currentGroup) {
+    currentGroup = select.value;
+    onGroupChange();
+  }
+}
+
+function onGroupChange() {
+  currentGroup = document.getElementById('group-select').value;
+  loadPanorama(currentGroup);
+  renderVideos(currentGroup);
+  loadPoints(currentGroup);
+}
+
+function renderVideos(groupId) {
+  const grid = document.getElementById('video-grid');
+  grid.innerHTML = '';
+  ['panorama', 'ptz'].forEach(cam => {
+    const box = document.createElement('div');
+    box.className = 'video-box';
+    box.innerHTML = `<div class="title">${cam} - ${groupId}</div>
+                     <img src="/api/live/${cam}/${groupId}?marked=1" alt="${cam}">`;
+    grid.appendChild(box);
+  });
+}
+
+async function loadPoints(groupId) {
+  const data = await API.get(`/api/points/${groupId}`);
+  const ul = document.getElementById('points');
+  ul.innerHTML = '';
+  data.points.forEach(p => {
+    const li = document.createElement('li');
+    li.innerHTML = `<span>P:${p.pan.toFixed(0)} T:${p.tilt.toFixed(0)}</span>
+                    <button data-id="${p.id}">删除</button>`;
+    li.querySelector('button').onclick = async () => {
+      await API.del(`/api/points/${groupId}/${p.id}`);
+      loadPoints(groupId);
+    };
+    ul.appendChild(li);
+  });
+}
+
+document.getElementById('group-select').addEventListener('change', onGroupChange);
+document.getElementById('btn-scan').addEventListener('click', async () => {
+  await API.post(`/api/scan/${currentGroup}`);
+  document.getElementById('status').textContent = '状态:扫描中...';
+});
+document.getElementById('btn-poll-start').addEventListener('click', async () => {
+  await API.post(`/api/poll/${currentGroup}/start`);
+  document.getElementById('status').textContent = '状态:轮询中';
+});
+document.getElementById('btn-poll-stop').addEventListener('click', async () => {
+  await API.post(`/api/poll/${currentGroup}/stop`);
+  document.getElementById('status').textContent = '状态:已停止';
+});
+document.getElementById('btn-preview').addEventListener('click', async () => {
+  // 预览:可扩展为临时移动球机到该点
+  alert('预览功能:球机将移动到 P:' + document.getElementById('inp-pan').value + ' T:' + document.getElementById('inp-tilt').value);
+});
+document.getElementById('btn-add').addEventListener('click', async () => {
+  const payload = {
+    pan: parseFloat(document.getElementById('inp-pan').value),
+    tilt: parseFloat(document.getElementById('inp-tilt').value),
+    zoom: parseInt(document.getElementById('inp-zoom').value),
+    dwell_time: parseFloat(document.getElementById('inp-dwell').value),
+  };
+  await API.post(`/api/points/${currentGroup}`, payload);
+  loadPoints(currentGroup);
+});
+
+setInterval(async () => {
+  const status = await API.get('/api/status');
+  const g = status.groups[currentGroup];
+  if (g) {
+    document.getElementById('status').textContent = `状态:${g.polling_state}`;
+  }
+}, 2000);
+
+initThree();
+loadGroups();
+```
+
+---
+
+## Task 12: 配置清理
+
+**Files:**
+- Modify: `dual_camera_system/config/system.py`
+- Modify: `dual_camera_system/config/coordinator.py`
+
+- [ ] **Step 1: 清理 system.py**
+
+保留基础开关,移除校准相关开关:
+
+```python
+SYSTEM_CONFIG = {
+    'enable_panorama_camera': True,
+    'enable_ptz_camera': True,
+    'enable_detection': True,
+    'enable_event_push': False,
+}
+```
+
+- [ ] **Step 2: 清理 coordinator.py**
+
+删除 `COORDINATOR_CONFIG` 中的 `sequential_mode` 相关子配置,仅保留基础参数:
+
+```python
+COORDINATOR_CONFIG = {
+    'ptz_stabilize_time': 1.5,
+    'ptz_command_cooldown': 0.5,
+}
+```
+
+---
+
+## Task 13: 清理旧代码
+
+**Files:**
+- Delete: `dual_camera_system/multi_group_system.py`
+- Delete: `dual_camera_system/camera_group.py`
+- Delete: `dual_camera_system/coordinator.py`
+- Delete: `dual_camera_system/calibration.py`
+- Delete: `dual_camera_system/tracker.py`
+- Delete: `dual_camera_system/polling_tracker.py`
+- Delete: `dual_camera_system/event_pusher.py`
+
+- [ ] **Step 1: 删除旧文件**
+
+Run:
+```bash
+cd dual_camera_system
+rm -f multi_group_system.py camera_group.py coordinator.py calibration.py tracker.py polling_tracker.py event_pusher.py
+```
+
+- [ ] **Step 2: 删除依赖旧逻辑的测试**
+
+Run:
+```bash
+cd dual_camera_system
+rm -f tests/test_tracker.py tests/test_polling_tracker.py tests/test_integration_polling.py tests/test_event_pusher_upload.py
+```
+
+- [ ] **Step 3: 检查残留引用**
+
+Run:
+```bash
+cd dual_camera_system
+grep -R "multi_group_system\|camera_group\|CameraGroup\|SequentialCoordinator\|CameraCalibrator\|tracker.py\|polling_tracker\|EventPusher" --include="*.py" .
+```
+
+Expected: 无相关引用。
+
+---
+
+## Task 14: 集成测试
+
+**Files:**
+- Create: `dual_camera_system/tests/test_routes.py`
+
+- [ ] **Step 1: 创建路由测试**
+
+Create `dual_camera_system/tests/test_routes.py`:
+
+```python
+import sys
+import os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from fastapi.testclient import TestClient
+from app import create_app
+
+
+def test_status_endpoint():
+    app = create_app(test_mode=True)
+    client = TestClient(app)
+    response = client.get("/api/status")
+    assert response.status_code == 200
+    assert "groups" in response.json()
+
+
+def test_add_and_list_points():
+    app = create_app(test_mode=True)
+    client = TestClient(app)
+    client.post("/api/points/group_1", json={"pan": 30.0, "tilt": 0.0, "zoom": 1, "dwell_time": 3.0})
+    response = client.get("/api/points/group_1")
+    assert response.status_code == 200
+    assert len(response.json()["points"]) == 1
+```
+
+---
+
+## Task 15: 运行验证
+
+- [ ] **Step 1: 运行所有单元测试**
+
+```bash
+cd dual_camera_system
+pytest tests/test_coord_utils.py tests/test_scan_point_store.py tests/test_stream_manager.py tests/test_spatial_scanner.py tests/test_routes.py -v
+```
+
+Expected: 全部 PASS。
+
+- [ ] **Step 2: 启动 FastAPI 服务**
+
+```bash
+cd dual_camera_system
+python main.py
+```
+
+Expected: 服务启动在 `0.0.0.0:8000`,无异常堆栈。
+
+- [ ] **Step 3: 浏览器验证**
+
+打开 `http://<device-ip>:8000`,检查:
+- 视频网格显示枪机/球机实时流
+- 可执行 360° 扫描
+- 扫描完成后 3D 全景模型可加载
+- 可在全景图上点击添加扫描点
+- 可开始/停止轮询
+- 检测到人后图片保存并上传
+
+---
+
+## 自我审查
+
+**Spec 覆盖检查:**
+
+| Spec 需求 | 对应 Task |
+|-----------|-----------|
+| 球机 360° 上下扫描 | Task 5 |
+| 生成空间全景模型 | Task 5 |
+| Web 页面 3D 交互选点 | Task 11 |
+| 多扫描点配置 | Task 3, Task 9, Task 11 |
+| 球机轮询扫描点 | Task 6 |
+| 枪机/球机实时人识别 | Task 7, Task 10 |
+| 识别到人保存+上传 | Task 7 |
+| 多路摄像头支持 | Task 10 |
+| 实时带标记视频流 | Task 4, Task 10 |
+| 删除旧枪球联动/校准代码 | Task 13 |
+
+**Placeholder 检查:**
+- 无 TBD/TODO/"implement later"。
+- 所有代码步骤均给出可运行代码。
+- 路由测试给出了 mock 策略。
+
+**类型一致性检查:**
+- `SpatialScanner.run` 返回 dict,与 `routes.py` 消费一致。
+- `ScanPointStore` 的 point id 为 int,与 API 路径一致。
+- `GroupState` 使用 dict,与 Web 路由读取一致。

+ 316 - 0
docs/superpowers/specs/2026-06-15-ptz-360-scan-3d-panorama-design.md

@@ -0,0 +1,316 @@
+# PTZ 360° 扫描 + 3D 全景轮询抓拍系统设计
+
+## 1. 设计目标
+
+替换现有基于枪球位置联动的 PTZ 跟踪逻辑,改为:
+
+1. 球机先执行 360° 上下扫描,建立空间全景模型。
+2. 通过 Web 页面以 3D 全景交互方式配置多个扫描点。
+3. 球机按配置点轮询扫描,每个点停留做人识别。
+4. 枪机和球机视频均实时做人识别;识别到人后实时保存图片并上传到第三方平台。
+5. 支持多路摄像头(多组枪机+球机)。
+
+## 2. 关键决策
+
+| 问题 | 决策 |
+|------|------|
+| 扫描点配置方式 | 360° 扫描生成 3D 全景球,用户点击球面任意位置设置扫描点 |
+| 扫描范围与精度 | 水平 0-360°,Tilt 层 [-20°, 0°, +20°],Pan 步长 30°,共 36 个采样点 |
+| 扫描点位置 | 支持球面任意 pan/tilt,不限于采样网格;保存前球机先移动到该点预览确认 |
+| 轮询行为 | 停-看-走:移动到每个点后停留 3 秒做人识别,再移向下一点 |
+| 检测上传 | 哪路摄像头检测到人,就保存该路原图+标记图并上传 |
+| Web 视频布局 | 所有摄像头画面平铺显示;配置扫描点时通过下拉框选择对应球机 |
+| 旧代码处理 | 完全替换旧逻辑,删除枪球联动与校准相关代码 |
+| 架构方案 | 单进程 FastAPI + 后台多线程 |
+
+## 3. 总体架构
+
+用一个 FastAPI 进程替代现有的 `main.py` 与 `multi_group_system.py`,内部启动多个后台线程:
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│  FastAPI Web 进程                                                 │
+│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────────┐  │
+│  │ 枪机取流线程 │  │ 球机取流线程 │  │ 扫描/轮询线程(每组一个)│  │
+│  │ + 人检测    │  │ + 人检测    │  │                         │  │
+│  └──────┬──────┘  └──────┬──────┘  └───────────┬─────────────┘  │
+│         │                │                      │                │
+│         ▼                ▼                      ▼                │
+│  ┌─────────────────────────────────────────────────────────────┐│
+│  │  共享状态                                                     ││
+│  │  (scan_models.json, current_position, detection_state,      ││
+│  │   live_frames, enabled_points per group)                    ││
+│  └─────────────────────────────────────────────────────────────┘│
+│         │                                                       │
+│         ▼                                                       │
+│  ┌─────────────────────────────────────────────────────────────┐│
+│  │  FastAPI 路由 + 静态页面                                     ││
+│  │  /api/scan, /api/points, /api/status, /api/live/*, /         ││
+│  └─────────────────────────────────────────────────────────────┘│
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 核心模块
+
+| 模块 | 文件建议 | 职责 |
+|------|----------|------|
+| `StreamManager` | `stream_manager.py` | 管理所有摄像头 RTSP 取流,维护最新帧,输出 MJPEG 流 |
+| `PersonDetector` | 复用 `panorama_camera.py` 的 `ObjectDetector` | 对任意帧做人识别 |
+| `SpatialScanner` | `spatial_scanner.py` | 执行 360° 球形扫描,生成采样缩略图和等距圆柱全景图 |
+| `ScanPointStore` | `scan_point_store.py` | 保存每组扫描点配置、缩略图、全景图 |
+| `PollingScheduler` | `polling_scheduler.py` | 按配置顺序移动球机到各扫描点,停留检测 |
+| `CaptureUploader` | `capture_uploader.py` | 保存原图+标记图,调用 `third_party_pusher` 上传 |
+| `WebServer` | `web_server.py` + `web/` | FastAPI 路由、静态页面、API、WebSocket/轮询状态 |
+| `CoordinateUtils` | `coord_utils.py` | 3D 球面坐标 ↔ pan/tilt 换算 |
+
+## 4. 数据模型
+
+### 4.1 扫描模型文件 `scan_models.json`
+
+```json
+{
+  "camera_groups": {
+    "group_1": {
+      "ptz_name": "球机-北侧",
+      "panorama_name": "枪机-北侧",
+      "scan_config": {
+        "pan_range": [0, 360],
+        "tilt_layers": [-20, 0, 20],
+        "pan_step": 30,
+        "zoom": 1,
+        "dwell_time": 3.0,
+        "created_at": "2026-06-15T09:45:00"
+      },
+      "samples": [
+        {"id": 1, "pan": 0, "tilt": 0, "zoom": 1, "thumbnail": "data/group_1/samples/p0_t0.jpg"}
+      ],
+      "enabled_points": [
+        {"id": 1, "pan": 95.0, "tilt": -5.0, "zoom": 1, "dwell_time": 3.0, "order": 0}
+      ],
+      "panorama": {
+        "equirectangular": "data/group_1/panorama/scan_20260615.jpg",
+        "width": 4096,
+        "height": 2048
+      }
+    }
+  }
+}
+```
+
+### 4.2 实时状态(内存)
+
+```python
+{
+  "group_1": {
+    "ptz_position": {"pan": 95.0, "tilt": -5.0, "zoom": 1},
+    "polling_state": "scanning|idle|polling|paused",
+    "current_point_index": 2,
+    "last_detection": {"camera": "ptz", "timestamp": 1781517600.0},
+    "frame_marked": { "panorama": ndarray, "ptz": ndarray }
+  }
+}
+```
+
+## 5. 360° 扫描流程
+
+1. 用户点击 Web 上的「执行 360° 扫描建模」并选择目标球机。
+2. 后端为该组创建 `SpatialScanner` 任务:
+   - Pan 从 0° 到 360°,步长 30°;
+   - 每层 Tilt [-20°, 0°, +20°];
+   - 共 36 个采样点。
+3. 对每个采样点:
+   - `PTZCamera.goto_exact_position(pan, tilt, zoom=1)`;
+   - 等待 `ptz_stabilize_time`(默认 1.5s);
+   - 抓取 PTZ 帧,保存缩略图;
+   - 记录采样元数据。
+4. 扫描完成后,把 36 张缩略图映射成等距圆柱全景图(equirectangular)。
+5. 更新 `scan_models.json` 并通知 Web 加载全景模型。
+
+### 全景图映射规则
+
+- 将每张采样图视为球面上 `(pan, tilt)` 方向的透视投影。
+- 使用球面投影公式把透视图像素映射到等距圆柱坐标 `(u, v)`:
+  - `u = pan / 360 * panorama_width`
+  - `v = (tilt + 90) / 180 * panorama_height`
+- 简单拼接,不融合重叠区域;精度足够用于交互选点。
+
+## 6. 3D 全景交互配置
+
+### 6.1 前端技术
+
+- **Three.js**:将球面全景图作为纹理贴到球体上,用户可拖拽旋转、滚轮缩放。
+- **OrbitControls**:限制垂直视角范围,避免穿帮。
+- **Raycaster**:用户点击时计算与球面交点,反向求出 `pan`/`tilt`。
+
+### 6.2 点击换算公式
+
+设球面交点单位向量为 `(x, y, z)`):
+
+```
+pan  = atan2(x, z)  转换为 [0, 360)
+tilt = asin(y)      转换为 [-90, 90]
+```
+
+### 6.3 扫描点保存流程
+
+1. 用户在全景球上点击,前端显示临时标记和 pan/tilt。
+2. 用户点击「预览」→ 后端把球机移动到该 `(pan, tilt, zoom)`。
+3. 球机画面实时显示在对应视频窗口中,用户确认视角正确。
+4. 用户点击「保存扫描点」→ 写入 `enabled_points`。
+5. 可设置每个点的 `zoom` 和 `dwell_time`(默认继承全局)。
+
+## 7. 轮询扫描流程
+
+1. 用户点击「开始轮询」。
+2. `PollingScheduler` 读取该组 `enabled_points`,按 `order` 排序。
+3. 对每个扫描点:
+   - `goto_exact_position(pan, tilt, zoom)`;
+   - 等待稳定 + `dwell_time`(默认 3s);
+   - 期间球机检测线程持续做人识别;
+   - 完成后移向下一点。
+4. 到达最后一个点后回到第一个点,循环执行。
+5. 用户可随时点击「停止」或「暂停」。
+
+## 8. 实时检测与上传
+
+### 8.1 枪机检测
+
+- 独立线程持续取流。
+- 按 `detection_interval` 对最新帧做人检测。
+- 绘制检测框和置信度,输出到 `marked` MJPEG 流。
+- 识别到人时触发 `CaptureUploader`。
+
+### 8.2 球机检测
+
+- 独立线程持续取流。
+- 在轮询稳定期间对最新帧做人检测。
+- 绘制检测框,输出到 `marked` MJPEG 流。
+- 识别到人时触发 `CaptureUploader`。
+
+### 8.3 上传流程
+
+1. 保存原图:`capture_<camera>_<timestamp>_original.jpg`。
+2. 保存标记图:`capture_<camera>_<timestamp>_marked.jpg`。
+3. 构造 batch_info:
+   - 时间戳、摄像头组、摄像头类型;
+   - 球机当前 pan/tilt/zoom(如果是球机);
+   - 检测框、置信度。
+4. 调用现有 `third_party_pusher.report_batch(batch_info)` 异步上传。
+5. Web 状态面板显示最近检测事件。
+
+### 8.4 去重机制
+
+- 同一摄像头、同一区域 5 秒内只上传一次,避免同一目标重复上报。
+- "同一区域" 定义为:两个检测框的 IoU ≥ 0.5,或中心点距离小于较短框对角线的 30%。
+- 去重按摄像头组 + 摄像头类型(panorama/ptz)独立计算。
+- 保存动作不受去重限制,每次检测都保存本地图片;仅上传操作做去重。
+
+## 9. Web 页面与 API
+
+### 9.1 页面布局
+
+- 顶部工具栏:扫描、轮询、停止、球机选择下拉框、当前状态。
+- 中部:所有摄像头画面平铺成网格,每个画面可切换原始/带标记流。
+- 下部:当前选中球机的 3D 全景模型 + 扫描点列表 + 参数面板 + 日志。
+
+### 9.2 REST API
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/status` | 全局状态:各组连接、轮询状态、最后检测 |
+| POST | `/api/scan/{group_id}` | 开始 360° 扫描建模 |
+| GET | `/api/scan/{group_id}/progress` | 扫描进度 |
+| GET | `/api/points/{group_id}` | 获取扫描点列表 |
+| POST | `/api/points/{group_id}` | 添加/更新扫描点 |
+| DELETE | `/api/points/{group_id}/{point_id}` | 删除扫描点 |
+| POST | `/api/poll/{group_id}/start` | 开始轮询 |
+| POST | `/api/poll/{group_id}/stop` | 停止轮询 |
+| GET | `/api/live/panorama/{group_id}` | 枪机原始 MJPEG |
+| GET | `/api/live/panorama/{group_id}/marked` | 枪机带标记 MJPEG |
+| GET | `/api/live/ptz/{group_id}` | 球机原始 MJPEG |
+| GET | `/api/live/ptz/{group_id}/marked` | 球机带标记 MJPEG |
+| GET | `/api/panorama/{group_id}` | 获取等距圆柱全景图 |
+| GET | `/` | Web 控制台首页 |
+
+## 10. 异常处理
+
+| 场景 | 处理 |
+|------|------|
+| 球机移动超时 | 连续 3 次未到达目标点,停止轮询/扫描并报警 |
+| RTSP 断流 | 自动重连,重连期间显示占位图;超过阈值上报离线 |
+| 扫描中断 | 保存已完成的采样,支持断点续扫 |
+| 点击位置超出范围 | 校验 pan/tilt 范围,超出时前端提示 |
+| 同一目标重复检测 | 5 秒内同摄像头同区域不重复上传 |
+| 多组同时扫描 | 每组独立队列和线程,避免命令串扰 |
+| Web 客户端断连 | 不影响后台运行;重连后拉取最新状态 |
+
+## 11. 文件结构变更
+
+```
+dual_camera_system/
+├── main.py                      # FastAPI 入口,替代旧 main.py
+├── web/
+│   ├── __init__.py
+│   ├── server.py                # FastAPI 应用创建
+│   ├── routes.py                # API 路由
+│   ├── static/
+│   │   ├── index.html
+│   │   ├── app.js
+│   │   └── style.css
+│   └── templates/
+├── core/
+│   ├── __init__.py
+│   ├── stream_manager.py
+│   ├── spatial_scanner.py
+│   ├── scan_point_store.py
+│   ├── polling_scheduler.py
+│   ├── capture_uploader.py
+│   └── coord_utils.py
+├── config/                      # 更新现有配置
+├── ...existing files to keep or remove...
+```
+
+### 计划删除的文件
+
+- `calibration.py` 及校准相关代码
+- `camera_group.py` 旧枪球联动逻辑
+- `coordinator.py` 中 SequentialCoordinator/AsyncCoordinator 相关旧逻辑
+- `multi_group_system.py` 旧入口
+- 旧的校准脚本
+
+### 保留并复用的文件
+
+- `panorama_camera.py` 中的 `ObjectDetector`
+- `ptz_camera.py` 中的 `PTZCamera`
+- `dahua_sdk.py`
+- `third_party_pusher.py`
+- `paired_image_saver.py`(可选简化)
+- `config/` 模块(删除校准相关配置,新增扫描轮询配置)
+
+## 12. 测试方案
+
+| 类型 | 内容 |
+|------|------|
+| 单元测试 | `coord_utils` 球面换算、`ScanPointStore` 读写、`SpatialScanner` 网格生成 |
+| 集成测试 | FastAPI 启动、扫描/轮询 API、实时流端点 |
+| 模拟测试 | 使用模拟 PTZ 和模拟视频流验证主流程 |
+| 端对端测试 | RK3588 连接真实设备,验证扫描 → 配置 → 轮询 → 检测 → 上传全链路 |
+
+## 13. 风险与注意事项
+
+1. **大改风险**:完全替换旧代码,建议先在新分支开发,保留旧分支可回退。
+2. **RK3588 性能**:多路视频流 + 多组检测 + Web 服务同时运行,需关注 CPU/NPU 占用。
+3. **全景图精度**:简化拼接可能产生接缝,但用于选点足够;如需高精度需引入图像拼接库。
+4. **SDK 限制**:`dahua_sdk.py` 只绑定了 `CLIENT_DHPTZControlEx`,`goto_exact_position` 已够用;如需高级巡航命令需扩展。
+5. **多组并发扫描**:多组同时 360° 扫描会大量移动球机,建议一次只扫描一组,其余组保持轮询或静止。
+
+## 14. 后续实施步骤
+
+1. 搭建 FastAPI 骨架和静态页面。
+2. 实现 `StreamManager` 双路(多路)取流和 MJPEG 输出。
+3. 实现 `SpatialScanner` 360° 扫描和全景图生成。
+4. 实现 3D 全景前端和点击选点。
+5. 实现 `PollingScheduler` 轮询和球机检测。
+6. 实现 `CaptureUploader` 保存和上传。
+7. 清理旧代码,整合配置。
+8. 测试与部署。

BIN
dual_camera_system/.coverage


+ 82 - 87
dual_camera_system/README.md

@@ -4,10 +4,10 @@
 
 本系统实现多组全景摄像头和可变焦球机的联动抓拍功能:
 - 多组全景摄像头实时监控,检测画面中的人体
-- 检测到人体后,对应球机自动变焦定位到目标
+- 对应球机按扫描点位轮询定位并抓拍
 - 保存全景+球机配对图片,支持上传至业务平台
 
-> 注意:本版本已移除 OCR 编号识别、LLM 判断、安全帽/反光衣检测,仅保留人体检测与联动抓拍。
+> 注意:本版本已移除 OCR 编号识别、LLM 判断、安全帽/反光衣检测仅保留人体检测与联动抓拍。
 
 ## 系统架构
 
@@ -15,50 +15,63 @@
 多组摄像头配置 (config/camera.py)
-      MultiGroupSystem
+      app.py / create_app()
-    ┌─────────┴─────────┐
-    │                   │
-CameraGroup 1      CameraGroup 2  ...
-    │                   │
-    ▼                   ▼
-全景+球机          全景+球机
-    │                   │
-    ▼                   ▼
-SequentialCoordinator  ...
-    │
-    ▼
-人体检测 -> PTZ定位 -> 抓拍 -> 配对保存/上传
+    初始化共享 SDK、检测器、视频流管理器
+              │
+              ▼
+    为每组创建:
+    ├── StreamManager:枪机/球机 RTSP 流
+    ├── SpatialScanner:360° 空间扫描建模(可选)
+    ├── PollingScheduler:扫描点位轮询
+    └── CaptureUploader:抓拍 + 上传
+              │
+              ▼
+    枪机实时检测 + 球机轮询检测 ──> 抓拍 ──> 上传/事件推送
 ```
 
 ## 目录结构
 
 ```
 dual_camera_system/
-├── config/            # 模块化配置
-│   ├── __init__.py    # 配置导出
-│   ├── camera.py      # 摄像头 + 日志配置
-│   ├── detection.py   # 人体检测配置
-│   ├── ptz.py         # PTZ 控制参数
-│   ├── coordinator.py # 联动 + 校准配置
-│   ├── tracking.py    # 跟踪配置(库代码)
-│   ├── event.py       # 事件推送配置
-│   ├── system.py      # 系统开关
-│   └── ...            # 其他配置
-├── main.py            # 主程序(仅多组模式)
-├── multi_group_system.py  # 多组系统管理器
-├── camera_group.py    # 单组摄像头封装
-├── dahua_sdk.py       # 大华SDK Python封装
-├── panorama_camera.py # 全景摄像头模块(视频流、人体检测)
-├── ptz_camera.py      # 球机控制模块(PTZ控制、精确定位)
-├── coordinator.py     # 联动控制器(SequentialCoordinator)
-├── tracker.py         # Ultralytics 跟踪器封装(库代码)
-├── polling_tracker.py # 多目标轮询跟踪协调器(库代码)
+├── config/               # 模块化配置
+│   ├── __init__.py       # 配置导出
+│   ├── camera.py         # 摄像头 + 日志配置
+│   ├── detection.py      # 人体检测配置
+│   ├── ptz.py            # PTZ 控制参数
+│   ├── coordinator.py    # 扫描轮询参数
+│   ├── tracking.py       # 跟踪 + 轮询抓拍配置(库代码)
+│   ├── event.py          # 事件推送配置
+│   ├── system.py         # 系统开关
+│   └── ...               # 其他配置
+├── core/                 # 新架构核心模块
+│   ├── capture_uploader.py   # 抓拍与上传核心逻辑
+│   ├── coord_utils.py        # 坐标/角度换算工具
+│   ├── detector_service.py   # 检测器服务封装
+│   ├── group_state.py        # 摄像头组状态管理
+│   ├── polling_scheduler.py  # 轮询调度器
+│   ├── scan_point_store.py   # 扫描点位存储
+│   ├── spatial_scanner.py    # 空间扫描建模
+│   └── stream_manager.py     # 视频流管理
+├── web/                  # Web API 服务
+│   ├── routes.py         # HTTP 路由
+│   └── state.py          # Web 应用状态
+├── web_static/           # 前端静态页面
+│   ├── app.js            # 前端交互脚本
+│   ├── index.html        # 前端入口页面
+│   └── style.css         # 前端样式
+├── app.py                # FastAPI 应用工厂与全局服务初始化
+├── main.py               # 服务入口(启动 uvicorn)
+├── dahua_sdk.py          # 大华SDK Python封装
+├── panorama_camera.py    # 全景摄像头模块(视频流、人体检测)
+├── ptz_camera.py         # 球机控制模块(PTZ控制、精确定位)
 ├── inference_backend.py  # RKNN/ONNX 通用推理后端
-├── event_pusher.py    # 事件推送至业务平台
-├── voice_announcer.py # TTS 语音播报
-└── README.md          # 说明文档
+├── scripts/              # 脚本工具
+│   ├── local_test.py     # 本地 RTSP/检测/PTZ 角度测试
+│   └── manual_ptz_test.py # 手动 PTZ 控制测试脚本
+├── pytest.ini            # pytest 仅收集 tests/ 目录
+└── README.md             # 说明文档
 ```
 
 ## 依赖安装
@@ -120,20 +133,20 @@ CAMERA_GROUPS = [
 ### 2. 运行系统
 
 ```bash
-# 启动多组联动系统
+# 启动扫描轮询模式
 python main.py
 
-# 跳过自动校准
-python main.py --skip-calibration
-
-# 覆盖第一组摄像头 IP
-python main.py --panorama-ip 192.168.8.2 --ptz-ip 192.168.8.5
+# 演示模式 (不连接实际摄像头)
+python main.py --demo
 
 # 指定模型
 python main.py --model /home/wen/dsh/yolo/yolo11n.pt --model-size n
 
-# 演示模式 (不连接实际摄像头)
-python main.py --demo
+# 禁用 GPU
+python main.py --no-gpu
+
+# 指定监听地址和端口
+python main.py --host 0.0.0.0 --port 8080
 ```
 
 ### 本地冒烟测试(无需 Dahua SDK)
@@ -156,29 +169,6 @@ python scripts/local_test.py --frames 10 --show
 
 ## 关键功能说明
 
-### 自动校准
-系统启动时会自动进行全景相机与球机的校准:
-
-**校准方法 (视觉检测):**
-1. **运动检测法**: 记录球机移动前后全景画面差异,定位运动区域中心
-2. **特征匹配法**: 将球机抓拍图像与全景画面进行SIFT特征匹配
-3. **融合定位**: 综合两种方法结果加权融合
-4. **降级方案**: 视觉检测失败时使用角度估算
-
-**定时校准:**
-- 系统运行时每日指定时间自动校准一次(默认 08:00,可在 `config/coordinator.py` 中配置)
-- 确保坐标映射的准确性
-
-**跳过校准:**
-```bash
-python main.py --skip-calibration
-```
-
-**强制重新校准:**
-```bash
-python main.py --force-calibration
-```
-
 ### 人体检测
 使用 YOLO11 模型检测画面中的人体,支持 GPU 加速。
 
@@ -189,10 +179,14 @@ python main.py --force-calibration
 - `l` (large) - 高精度
 - `x` (extra-large) - 最高精度,速度最慢
 
-### 多组联动
-- `MultiGroupSystem` 共享一个大华 SDK 实例和一个 YOLO 检测器
-- 每组摄像头独立运行 `SequentialCoordinator`
-- 检测到人体后控制球机 PTZ 定位并保存配对图片
+### 扫描轮询模式
+当前系统通过 `app.py` 的 `create_app()` 启动**多组摄像头扫描轮询模式**:
+- 共享一次大华 SDK 初始化、一个检测器服务(`DetectorService`)和一个视频流管理器(`StreamManager`)
+- 每组摄像头独立注册枪机/球机 RTSP 流
+- `SpatialScanner` 可选执行 360° 空间扫描建模
+- `PollingScheduler` 按扫描点位轮询球机 PTZ,到位后由枪机/球机检测线程触发抓拍
+- `CaptureUploader` 负责本地存储与可选 OSS/第三方平台上传
+- Web 服务通过 `web/routes.py` 暴露状态查看、扫描控制等 HTTP 接口
 
 ### PTZ控制
 支持大华球机的 PTZ 控制:
@@ -200,19 +194,8 @@ python main.py --force-calibration
 - 预置点设置/调用
 - 平滑移动跟踪
 
-### 独立扫描与手动校准
-当自动特征匹配不可靠时,可使用独立扫描脚本生成可人工复核的图片集,再基于确认的关键点生成校准文件。
-
-```bash
-# 1. 球机水平 360°、步长 20°,每个水平位置由下朝上扫描 tilt
-#    每个位置保存球机图(文件名带 pan/tilt)和全景图
-python scripts/calibration_scanner.py
-
-# 2. 用 ORB 重新匹配已扫描图片,生成 lookup_table_orb.json 与复核 CSV
-python scripts/re_match_orb.py
-```
-
-扫描结果保存在 `calibration_scan/`(可在脚本中修改路径)。人工确认图片后,编辑生成的 JSON 或手动编写 `calibration_group1.json`:
+### 坐标映射与校准
+系统使用基于标定文件的坐标映射:
 
 ```json
 {
@@ -226,6 +209,19 @@ python scripts/re_match_orb.py
 
 `pan_lookup` 按 `[[x_ratio, pan_angle], ...]` 分段线性插值;`tilt_lookup` 同理。运行时 `tilt_linear_enabled=True` 会优先使用 `config/ptz.py` 中的 `tilt_y0/tilt_y1` 线性映射。校准文件路径在 `config/camera.py` 的 `calibration_file` 中配置。
 
+当自动特征匹配不可靠时,可使用独立扫描脚本生成可人工复核的图片集,再基于确认的关键点生成校准文件。
+
+```bash
+# 1. 球机水平 360°、步长 20°,每个水平位置由下朝上扫描 tilt
+#    每个位置保存球机图(文件名带 pan/tilt)和全景图
+python scripts/calibration_scanner.py
+
+# 2. 用 ORB 重新匹配已扫描图片,生成 lookup_table_orb.json 与复核 CSV
+python scripts/re_match_orb.py
+```
+
+扫描结果保存在 `calibration_scan/`(可在脚本中修改路径)。人工确认图片后,编辑生成的 JSON 或手动编写 `calibration_group1.json`。
+
 ### 配对图片保存
 - 全景图与球机抓拍图按时间窗口归入同一目录
 - 支持 OSS 上传与第三方平台推送
@@ -252,5 +248,4 @@ python scripts/re_match_orb.py
 1. 确保大华 SDK 库文件路径正确
 2. 球机需要支持 PTZ 控制功能
 3. 坐标映射需要根据实际场景校准
-4. 所有摄像头组在 `config/camera.py` 中配置,`main.py` 不再支持单组独立运行
-5. `tracker.py` 和 `polling_tracker.py` 为保留的 Ultralytics 跟踪库代码,当前 `main.py` 未使用
+4. 所有摄像头组在 `config/camera.py` 中配置,`main.py` 仅作为 uvicorn 服务入口

BIN
dual_camera_system/__pycache__/calibration.cpython-310.pyc


BIN
dual_camera_system/__pycache__/calibration.cpython-313.pyc


BIN
dual_camera_system/__pycache__/camera_group.cpython-310.pyc


BIN
dual_camera_system/__pycache__/camera_group.cpython-313.pyc


BIN
dual_camera_system/__pycache__/coordinator.cpython-310.pyc


BIN
dual_camera_system/__pycache__/coordinator.cpython-313.pyc


BIN
dual_camera_system/__pycache__/dahua_sdk.cpython-310.pyc


BIN
dual_camera_system/__pycache__/event_pusher.cpython-310.pyc


BIN
dual_camera_system/__pycache__/inference_backend.cpython-310.pyc


BIN
dual_camera_system/__pycache__/main.cpython-310.pyc


BIN
dual_camera_system/__pycache__/main.cpython-313.pyc


BIN
dual_camera_system/__pycache__/multi_group_system.cpython-310.pyc


BIN
dual_camera_system/__pycache__/ocr_recognizer.cpython-310.pyc


BIN
dual_camera_system/__pycache__/ocr_recognizer.cpython-313.pyc


BIN
dual_camera_system/__pycache__/oss_uploader.cpython-310.pyc


BIN
dual_camera_system/__pycache__/paired_image_saver.cpython-310.pyc


BIN
dual_camera_system/__pycache__/paired_image_saver.cpython-313.pyc


BIN
dual_camera_system/__pycache__/panorama_camera.cpython-310.pyc


BIN
dual_camera_system/__pycache__/panorama_camera.cpython-313.pyc


BIN
dual_camera_system/__pycache__/polling_tracker.cpython-310.pyc


BIN
dual_camera_system/__pycache__/ptz_camera.cpython-310.pyc


BIN
dual_camera_system/__pycache__/ptz_person_tracker.cpython-310.pyc


BIN
dual_camera_system/__pycache__/safety_coordinator.cpython-310.pyc


BIN
dual_camera_system/__pycache__/safety_coordinator.cpython-313.pyc


BIN
dual_camera_system/__pycache__/safety_detector.cpython-310.pyc


BIN
dual_camera_system/__pycache__/safety_main.cpython-310.pyc


BIN
dual_camera_system/__pycache__/third_party_pusher.cpython-310.pyc


BIN
dual_camera_system/__pycache__/tracker.cpython-310.pyc


BIN
dual_camera_system/__pycache__/video_lock.cpython-310.pyc


+ 286 - 0
dual_camera_system/app.py

@@ -0,0 +1,286 @@
+"""FastAPI 应用工厂与全局服务初始化."""
+import os
+import tempfile
+import threading
+import time
+from contextlib import asynccontextmanager
+
+import cv2
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import FileResponse
+
+from config import CAMERA_GROUPS, SDK_PATH, SYSTEM_CONFIG
+from dahua_sdk import DahuaSDK
+from ptz_camera import PTZCamera
+from third_party_pusher import get_third_party_pusher
+
+from core.stream_manager import StreamManager
+from core.scan_point_store import ScanPointStore
+from core.spatial_scanner import SpatialScanner
+from core.polling_scheduler import PollingScheduler
+from core.capture_uploader import CaptureUploader
+from core.detector_service import DetectorService
+from core.group_state import group_state
+from web.routes import router
+from web.state import WebState
+import web.state as _web_state_module
+
+
+def build_rtsp_url(camera_config: dict) -> str:
+    ip = camera_config["ip"]
+    port = camera_config.get("rtsp_port", 554)
+    username = camera_config["username"]
+    password = camera_config["password"]
+    channel = camera_config.get("rtsp_channel") or camera_config.get("channel", 1)
+    subtype = camera_config.get("subtype", 0)
+    return f"rtsp://{username}:{password}@{ip}:{port}/cam/realmonitor?channel={channel}&subtype={subtype}"
+
+
+def create_app(test_mode: bool = False) -> FastAPI:
+    @asynccontextmanager
+    async def lifespan(app: FastAPI):
+        # 重置全局状态,允许同一进程内反复创建应用(测试场景)
+        group_state.reset()
+
+        stream_manager = StreamManager()
+        store_path = os.path.join(tempfile.mkdtemp(), "scan_models.json") if test_mode else "data/scan_models.json"
+        scan_store = ScanPointStore(store_path)
+        scanners: dict = {}
+        schedulers: dict = {}
+        ptz_cameras: dict = {}
+        threads: list = []
+        stop_event = threading.Event()
+
+        pusher = None
+        sdk = None
+        detector_service = None
+
+        if not test_mode:
+            if SYSTEM_CONFIG.get("enable_detection", True):
+                detector_service = DetectorService()
+
+            if SYSTEM_CONFIG.get("enable_ptz_camera", True):
+                try:
+                    sdk = DahuaSDK(SDK_PATH["lib_path"])
+                    sdk.init()
+                except Exception as exc:
+                    print(f"[lifespan] SDK init failed: {exc}")
+                    sdk = None
+
+            if SYSTEM_CONFIG.get("enable_event_push", False):
+                try:
+                    pusher = get_third_party_pusher()
+                    if pusher and not pusher.running:
+                        pusher.start()
+                except Exception as exc:
+                    print(f"[lifespan] Pusher start failed: {exc}")
+                    pusher = None
+
+        for group in CAMERA_GROUPS:
+            if not group.get("enabled", True):
+                continue
+            gid = group.get("group_id", group.get("id"))
+
+            try:
+                ptz_cfg = group["ptz"]
+                pano_cfg = group["panorama"]
+
+                pano_url = build_rtsp_url(pano_cfg)
+                ptz_url = build_rtsp_url(ptz_cfg)
+
+                group_state.init_group(gid, pano_url, ptz_url, ptz_cfg)
+                group_state.update(gid, "ptz_connected", False)
+                scan_store.ensure_group(gid, {
+                    "ptz_name": ptz_cfg.get("name", gid),
+                    "panorama_name": pano_cfg.get("name", gid),
+                })
+
+                if test_mode:
+                    continue
+
+                # 注册枪机 RTSP 流
+                if SYSTEM_CONFIG.get("enable_panorama_camera", True):
+                    stream_manager.register(f"{gid}_panorama", pano_url)
+
+                ptz = None
+                if SYSTEM_CONFIG.get("enable_ptz_camera", True) and sdk is not None:
+                    # 连接球机
+                    ptz = PTZCamera(sdk, ptz_cfg)
+                    try:
+                        ptz_connected = ptz.connect()
+                    except Exception as exc:
+                        print(f"[lifespan] PTZ connect raised for group {gid}: {exc}")
+                        ptz_connected = False
+
+                    if ptz_connected:
+                        ptz_cameras[gid] = ptz
+                        group_state.update(gid, "ptz_connected", True)
+
+                        # 注册球机 RTSP 流
+                        stream_manager.register(f"{gid}_ptz", ptz_url)
+
+                        scanners[gid] = SpatialScanner(
+                            gid, ptz, lambda g=gid: stream_manager.get(f"{g}_ptz").get_frame()
+                        )
+
+                        def on_arrived(point, g=gid, ptz=ptz):
+                            group_state.update(g, "ptz_position", {
+                                "pan": point["pan"], "tilt": point["tilt"], "zoom": point.get("zoom", 1)
+                            })
+
+                        schedulers[gid] = PollingScheduler(
+                            gid, ptz,
+                            get_points=lambda g=gid: scan_store.list_enabled_points(g),
+                            on_arrived=on_arrived,
+                            default_dwell=3.0,
+                        )
+                    else:
+                        print(f"[lifespan] PTZ connect failed for group {gid}; skipping PTZ features")
+
+                # 创建上传器(即使球机关闭,枪机检测仍可能使用)
+                uploader = CaptureUploader(gid, upload_callback=pusher.report_batch if pusher else None)
+
+                if SYSTEM_CONFIG.get("enable_detection", True) and detector_service is not None:
+                    def panorama_detect_loop(g=gid, uploader=uploader, detector=detector_service):
+                        interval = 0.5
+                        while not stop_event.is_set():
+                            try:
+                                stream = stream_manager.get(f"{g}_panorama")
+                                frame = stream.get_frame() if stream else None
+                                if frame is not None:
+                                    dets = detector.detect(frame)
+                                    marked = frame.copy()
+                                    if dets:
+                                        det_dicts = [{
+                                            "bbox": [d.bbox[0], d.bbox[1], d.bbox[0]+d.bbox[2], d.bbox[1]+d.bbox[3]],
+                                            "confidence": d.confidence,
+                                        } for d in dets]
+                                        uploader.handle_detection("panorama", frame, det_dicts)
+                                        for d in det_dicts:
+                                            x1, y1, x2, y2 = d["bbox"]
+                                            cv2.rectangle(marked, (x1, y1), (x2, y2), (0, 255, 0), 2)
+                                    stream.set_marked_frame(marked)
+                            except Exception as exc:
+                                print(f"[panorama_detect_loop {g}] error: {exc}")
+                            time.sleep(interval)
+
+                    t_panorama = threading.Thread(target=panorama_detect_loop, daemon=True)
+                    t_panorama.start()
+                    threads.append(t_panorama)
+
+                    if ptz is not None:
+                        def ptz_detect_loop(g=gid, uploader=uploader, detector=detector_service):
+                            interval = 0.2
+                            while not stop_event.is_set():
+                                try:
+                                    stream = stream_manager.get(f"{g}_ptz")
+                                    frame = stream.get_frame() if stream else None
+                                    if frame is not None:
+                                        dets = detector.detect(frame)
+                                        marked = frame.copy()
+                                        if dets:
+                                            det_dicts = [{
+                                                "bbox": [d.bbox[0], d.bbox[1], d.bbox[0]+d.bbox[2], d.bbox[1]+d.bbox[3]],
+                                                "confidence": d.confidence,
+                                            } for d in dets]
+                                            pos = group_state.get(g).get("ptz_position")
+                                            uploader.handle_detection("ptz", frame, det_dicts, pos)
+                                            for d in det_dicts:
+                                                x1, y1, x2, y2 = d["bbox"]
+                                                cv2.rectangle(marked, (x1, y1), (x2, y2), (0, 255, 0), 2)
+                                        stream.set_marked_frame(marked)
+                                except Exception as exc:
+                                    print(f"[ptz_detect_loop {g}] error: {exc}")
+                                time.sleep(interval)
+
+                        t_ptz = threading.Thread(target=ptz_detect_loop, daemon=True)
+                        t_ptz.start()
+                        threads.append(t_ptz)
+
+            except Exception as exc:
+                print(f"[lifespan] Group {gid} setup failed: {exc}")
+                continue
+
+        _web_state_module.web_state = WebState(group_state, stream_manager, scan_store, scanners, schedulers)
+
+        yield
+
+        # 清理
+        stop_event.set()
+        for t in threads:
+            try:
+                t.join(timeout=2.0)
+            except Exception as exc:
+                print(f"[lifespan] Thread join error: {exc}")
+
+        for s in schedulers.values():
+            try:
+                s.stop()
+            except Exception as exc:
+                print(f"[lifespan] Scheduler stop error: {exc}")
+
+        for gid, ptz in ptz_cameras.items():
+            try:
+                ptz.disconnect()
+                stream = stream_manager.get(f"{gid}_ptz")
+                if stream:
+                    stream.stop()
+            except Exception as exc:
+                print(f"[lifespan] PTZ cleanup error for {gid}: {exc}")
+
+        stream_manager.stop_all()
+
+        if pusher and getattr(pusher, "running", False):
+            try:
+                pusher.stop()
+            except Exception as exc:
+                print(f"[lifespan] Pusher stop error: {exc}")
+
+        if sdk:
+            try:
+                sdk.cleanup()
+            except Exception as exc:
+                print(f"[lifespan] SDK cleanup error: {exc}")
+
+    if test_mode:
+        # 在测试模式下预先初始化共享状态(不依赖 lifespan,兼容非上下文管理器的 TestClient)
+        group_state.reset()
+        test_stream_manager = StreamManager()
+        test_store_path = os.path.join(tempfile.mkdtemp(), "scan_models.json")
+        test_scan_store = ScanPointStore(test_store_path)
+        for group in CAMERA_GROUPS:
+            if not group.get("enabled", True):
+                continue
+            gid = group.get("group_id", group.get("id"))
+            try:
+                ptz_cfg = group["ptz"]
+                pano_cfg = group["panorama"]
+                pano_url = build_rtsp_url(pano_cfg)
+                ptz_url = build_rtsp_url(ptz_cfg)
+                group_state.init_group(gid, pano_url, ptz_url, ptz_cfg)
+                group_state.update(gid, "ptz_connected", False)
+                test_scan_store.ensure_group(gid, {
+                    "ptz_name": ptz_cfg.get("name", gid),
+                    "panorama_name": pano_cfg.get("name", gid),
+                })
+            except Exception as exc:
+                print(f"[create_app] Test group {gid} setup failed: {exc}")
+        _web_state_module.web_state = WebState(
+            group_state, test_stream_manager, test_scan_store, {}, {}
+        )
+
+    app = FastAPI(lifespan=lifespan)
+    static_dir = os.path.join(os.path.dirname(__file__), "web_static")
+    if os.path.isdir(static_dir):
+        app.mount("/static", StaticFiles(directory=static_dir), name="static")
+    app.include_router(router)
+
+    @app.get("/")
+    async def root():
+        index_path = os.path.join(static_dir, "index.html")
+        if os.path.isfile(index_path):
+            return FileResponse(index_path)
+        return {"message": "PTZ 360 scan + panorama polling system"}
+
+    return app

+ 0 - 1728
dual_camera_system/calibration.py

@@ -1,1728 +0,0 @@
-"""
-相机校准模块
-实现全景相机与球机的自动校准
-建立画面坐标到PTZ角度的映射关系
-
-核心改进:先发现视野重叠区域,再在重叠区内校准,
-避免球机指向与全景画面无重叠的方向导致校准失败。
-"""
-
-import time
-import math
-import threading
-import logging
-import numpy as np
-import cv2
-from typing import List, Tuple, Dict, Optional, Callable
-from dataclasses import dataclass, field
-from enum import Enum
-
-from ptz_camera import PTZCamera
-
-logger = logging.getLogger(__name__)
-
-# 加载PTZ配置
-def _get_ptz_config():
-    try:
-        from config import PTZ_CONFIG
-        return PTZ_CONFIG
-    except ImportError:
-        return {
-            'mount_type': 'wall',
-            'tilt_flip': False,
-            'pan_flip': False
-        }
-
-
-class CalibrationState(Enum):
-    IDLE = 0
-    RUNNING = 1
-    SUCCESS = 2
-    FAILED = 3
-
-
-@dataclass
-class CalibrationPoint:
-    pan: float
-    tilt: float
-    zoom: float = 1.0
-    x_ratio: float = 0.0
-    y_ratio: float = 0.0
-    detected: bool = False
-    match_count: int = 0
-
-
-@dataclass
-class CalibrationResult:
-    success: bool
-    points: List[CalibrationPoint]
-    transform_matrix: Optional[np.ndarray] = None
-    error_message: str = ""
-    rms_error: float = 0.0
-
-
-@dataclass
-class OverlapRange:
-    pan_start: float
-    pan_end: float
-    tilt_start: float
-    tilt_end: float
-    match_count: int
-    panorama_center_x: float
-    panorama_center_y: float
-
-
-MIN_MATCH_THRESHOLD = 10          # 最少匹配点数
-LOWE_RATIO = 0.70                  # Lowe's ratio test 阈值,越小越严格
-RANSAC_THRESHOLD = 4.0             # RANSAC 单应矩阵内点阈值(像素)
-MIN_INLIERS = 5                    # 最少几何内点数
-
-
-class OverlapDiscovery:
-    """
-    视野重叠发现器
-    扫描球机视野范围,找出与全景画面有视觉重叠的角度区间
-    """
-
-    def __init__(self, feature_type: str = 'SIFT'):
-        try:
-            self.feature_detector = cv2.SIFT_create()
-            self.feature_type = 'SIFT'
-        except AttributeError:
-            self.feature_detector = cv2.ORB_create(nfeatures=500)
-            self.feature_type = 'ORB'
-
-        norm_type = cv2.NORM_L2 if self.feature_type == 'SIFT' else cv2.NORM_HAMMING
-        self.matcher = cv2.BFMatcher(norm_type)
-
-    def match_frames(self, ptz_frame: np.ndarray, panorama_frame: np.ndarray
-                     ) -> Tuple[bool, int, float, float]:
-        """
-        特征匹配球机画面与全景画面
-        Returns: (是否匹配成功, 匹配点数, 全景画面中心x, 全景画面中心y)
-        """
-        if ptz_frame is None or panorama_frame is None:
-            return (False, 0, 0.0, 0.0)
-
-        try:
-            ptz_gray = cv2.cvtColor(ptz_frame, cv2.COLOR_BGR2GRAY) if len(ptz_frame.shape) == 3 else ptz_frame
-            pan_gray = cv2.cvtColor(panorama_frame, cv2.COLOR_BGR2GRAY) if len(panorama_frame.shape) == 3 else panorama_frame
-
-            # 缩小图像加速特征提取(匹配坐标按比例还原)
-            ptz_scale = 1.0
-            pan_scale = 1.0
-            max_dim = 960
-            if ptz_gray.shape[1] > max_dim:
-                ptz_scale = max_dim / ptz_gray.shape[1]
-                ptz_gray = cv2.resize(ptz_gray, None, fx=ptz_scale, fy=ptz_scale,
-                                      interpolation=cv2.INTER_AREA)
-            if pan_gray.shape[1] > max_dim:
-                pan_scale = max_dim / pan_gray.shape[1]
-                pan_gray = cv2.resize(pan_gray, None, fx=pan_scale, fy=pan_scale,
-                                      interpolation=cv2.INTER_AREA)
-
-            kp1, des1 = self.feature_detector.detectAndCompute(ptz_gray, None)
-            kp2, des2 = self.feature_detector.detectAndCompute(pan_gray, None)
-
-            if des1 is None or des2 is None or len(kp1) < 4 or len(kp2) < 4:
-                return (False, 0, 0.0, 0.0)
-
-            matches = self.matcher.knnMatch(des1, des2, k=2)
-
-            good_matches = []
-            for match_pair in matches:
-                if len(match_pair) == 2:
-                    m, n = match_pair
-                    if m.distance < LOWE_RATIO * n.distance:
-                        good_matches.append(m)
-
-            if len(good_matches) < MIN_MATCH_THRESHOLD:
-                return (False, len(good_matches), 0.0, 0.0)
-
-            # 几何验证:用 RANSAC 单应矩阵剔除空间不一致的匹配
-            ptz_pts = np.float32([kp1[m.queryIdx].pt for m in good_matches])
-            pan_pts = np.float32([kp2[m.trainIdx].pt for m in good_matches])
-
-            try:
-                _, mask = cv2.findHomography(ptz_pts, pan_pts, cv2.RANSAC, RANSAC_THRESHOLD)
-                inlier_mask = mask.ravel().astype(bool)
-                inlier_count = int(np.sum(inlier_mask))
-            except Exception:
-                inlier_count = 0
-                inlier_mask = np.zeros(len(good_matches), dtype=bool)
-
-            if inlier_count < MIN_INLIERS:
-                logger.debug(f"几何验证失败: {inlier_count}/{len(good_matches)} 个内点")
-                return (False, inlier_count, 0.0, 0.0)
-
-            # 只使用几何一致的内点计算中心
-            inlier_pan_pts = pan_pts[inlier_mask]
-            center_x = np.mean(inlier_pan_pts[:, 0]) / pan_scale
-            center_y = np.mean(inlier_pan_pts[:, 1]) / pan_scale
-
-            logger.debug(f"特征匹配: 原始={len(good_matches)}, 内点={inlier_count}, "
-                        f"中心=({center_x:.1f}, {center_y:.1f})")
-            return (True, inlier_count, center_x, center_y)
-
-        except Exception as e:
-            logger.error(f"特征匹配异常: {e}")
-            return (False, 0, 0.0, 0.0)
-
-    def discover_overlap_ranges(
-        self,
-        ptz: PTZCamera,
-        get_panorama_frame: Callable[[], np.ndarray],
-        ptz_capture: Callable[[], Optional[np.ndarray]],
-        pan_range: Tuple[float, float] = (0, 360),
-        tilt_range: Tuple[float, float] = (-20, 40),
-        pan_step: float = 20,
-        tilt_step: float = 15,
-        stabilize_time: float = 2.0,
-        on_progress: Callable[[int, int, str], None] = None,
-        max_ranges: int = 3,
-        min_positions_per_range: int = 3
-    ) -> List[OverlapRange]:
-        """
-        扫描球机视野范围,发现与全景画面有重叠的角度区间
-
-        1. 先拍一张全景参考帧
-        2. 逐步移动球机到各个角度
-        3. 在每个位置抓拍球机画面,与全景做特征匹配
-        4. 记录有足够匹配点的角度
-        5. 合并相邻的有重叠的角度形成区间
-        """
-        logger.info(f"阶段1: 视野重叠发现, 扫描范围: pan={pan_range}, tilt={tilt_range}, 步进: pan={pan_step}°, tilt={tilt_step}°")
-
-        # 1. 拍全景参考帧
-        logger.info("获取全景参考帧...")
-        ref_frames = []
-        for _ in range(3):
-            frame = get_panorama_frame()
-            if frame is not None:
-                ref_frames.append(frame)
-            time.sleep(0.1)
-
-        if not ref_frames:
-            logger.error("无法获取全景参考帧!")
-            return []
-
-        panorama_ref = ref_frames[0]
-        logger.info(f"全景参考帧: {panorama_ref.shape}")
-
-        # 2. 扫描各个角度
-        scan_results: List[Tuple[float, float, int, float, float]] = []
-
-        pan_values = np.arange(pan_range[0], pan_range[1] + pan_step, pan_step)
-        tilt_values = np.arange(tilt_range[0], tilt_range[1] + tilt_step, tilt_step)
-
-        total_positions = len(pan_values) * len(tilt_values)
-        current_idx = 0
-
-        for pan in pan_values:
-            for tilt in tilt_values:
-                current_idx += 1
-                pos_desc = f"pan={pan:.0f}°, tilt={tilt:.0f}°"
-
-                if on_progress:
-                    on_progress(current_idx, total_positions, f"扫描 {pos_desc}")
-
-                logger.info(f"[{current_idx}/{total_positions}] {pos_desc}")
-
-                # 移动球机
-                if not ptz.goto_exact_position(float(pan), float(tilt), 1):
-                    logger.warning(f"移动球机失败, 跳过")
-                    continue
-
-                time.sleep(stabilize_time)
-
-                # 抓拍球机画面
-                ptz_frame = ptz_capture() if ptz_capture else None
-                if ptz_frame is None:
-                    logger.warning(f"球机抓拍失败, 跳过")
-                    continue
-
-                # 获取当前全景帧并匹配
-                cur_panorama = get_panorama_frame()
-                if cur_panorama is None:
-                    continue
-
-                success, match_count, cx, cy = self.match_frames(ptz_frame, cur_panorama)
-
-                if success:
-                    h, w = cur_panorama.shape[:2]
-                    x_ratio = cx / w
-                    y_ratio = cy / h
-                    logger.info(f"匹配成功: {match_count}个特征点, 全景位置=({x_ratio:.3f}, {y_ratio:.3f})")
-                    scan_results.append((float(pan), float(tilt), match_count, x_ratio, y_ratio))
-                else:
-                    logger.debug(f"匹配不足: {match_count}个特征点")
-
-        if not scan_results:
-            logger.warning("未发现任何视野重叠位置!")
-            return []
-
-        logger.info(f"发现 {len(scan_results)} 个有重叠的扫描位置")
-
-        # 保存原始扫描结果供后续校准使用
-        self.scan_results = scan_results
-
-        # 3. 合并相邻位置为重叠区间
-        overlap_ranges = self._merge_scan_results(
-            scan_results,
-            max_ranges=max_ranges,
-            min_positions=min_positions_per_range
-        )
-
-        for i, r in enumerate(overlap_ranges):
-            logger.info(f"重叠区间 {i+1}: pan=[{r.pan_start:.0f}°, {r.pan_end:.0f}°], "
-                        f"tilt=[{r.tilt_start:.0f}°, {r.tilt_end:.0f}°], "
-                        f"匹配点={r.match_count}")
-
-        return overlap_ranges
-
-    def _merge_scan_results(
-        self,
-        results: List[Tuple[float, float, int, float, float]],
-        pan_tolerance: float = 20,
-        tilt_tolerance: float = 35,
-        max_ranges: int = 3,
-        min_positions: int = 2
-    ) -> List[OverlapRange]:
-        """
-        使用union-find连通分量聚类合并相邻扫描结果
-        只保留最大的 max_ranges 个区间
-        """
-        if not results:
-            return []
-
-        n = len(results)
-
-        # union-find
-        parent = list(range(n))
-
-        def find(x):
-            while parent[x] != x:
-                parent[x] = parent[parent[x]]
-                x = parent[x]
-            return x
-
-        def union(a, b):
-            ra, rb = find(a), find(b)
-            if ra != rb:
-                parent[ra] = rb
-
-        # 判断两点是否相邻
-        for i in range(n):
-            for j in range(i + 1, n):
-                pi, ti = results[i][0], results[i][1]
-                pj, tj = results[j][0], results[j][1]
-                if abs(pi - pj) <= pan_tolerance and abs(ti - tj) <= tilt_tolerance:
-                    union(i, j)
-
-        # 按连通分量分组
-        groups: Dict[int, List[int]] = {}
-        for i in range(n):
-            root = find(i)
-            if root not in groups:
-                groups[root] = []
-            groups[root].append(i)
-
-        # 转换为OverlapRange,过滤太小的组
-        ranges = []
-        for indices in groups.values():
-            if len(indices) < min_positions:
-                continue
-            group_data = [results[i] for i in indices]
-            ranges.append(self._group_to_range(group_data))
-
-        # 按match_count降序排序,只保留最大的 max_ranges 个
-        ranges.sort(key=lambda r: r.match_count, reverse=True)
-        ranges = ranges[:max_ranges]
-
-        # 按pan_start排序输出
-        ranges.sort(key=lambda r: r.pan_start)
-
-        return ranges
-
-    def _group_to_range(self, group: List[Tuple[float, float, int, float, float]]) -> OverlapRange:
-        """将一组扫描结果转换为一个OverlapRange"""
-        pans = [r[0] for r in group]
-        tilts = [r[1] for r in group]
-        match_counts = [r[2] for r in group]
-        x_ratios = [r[3] for r in group]
-        y_ratios = [r[4] for r in group]
-
-        step = 5  # 在边缘各扩展5度
-        return OverlapRange(
-            pan_start=min(pans) - step,
-            pan_end=max(pans) + step,
-            tilt_start=min(tilts) - step,
-            tilt_end=max(tilts) + step,
-            match_count=max(match_counts),
-            panorama_center_x=float(np.mean(x_ratios)),
-            panorama_center_y=float(np.mean(y_ratios))
-        )
-
-
-class VisualCalibrationDetector:
-    """
-    视觉校准检测器
-    通过运动检测和特征匹配定位球机在全景画面中的位置
-    """
-
-    def __init__(self):
-        try:
-            self.feature_detector = cv2.SIFT_create()
-            self.feature_type = 'SIFT'
-        except AttributeError:
-            self.feature_detector = cv2.ORB_create(nfeatures=500)
-            self.feature_type = 'ORB'
-
-        self.matcher = cv2.BFMatcher(
-            cv2.NORM_L2 if self.feature_type == 'SIFT' else cv2.NORM_HAMMING
-        )
-
-        self.use_motion_detection = True
-        self.use_feature_matching = True
-
-    def detect_by_motion(self, frames_before: np.ndarray,
-                         frames_after: np.ndarray) -> Optional[Tuple[float, float]]:
-        """通过运动检测定位球机指向位置"""
-        if frames_before is None or frames_after is None:
-            return None
-
-        before_gray = cv2.cvtColor(frames_before, cv2.COLOR_BGR2GRAY) \
-            if len(frames_before.shape) == 3 else frames_before
-        after_gray = cv2.cvtColor(frames_after, cv2.COLOR_BGR2GRAY) \
-            if len(frames_after.shape) == 3 else frames_after
-
-        diff = cv2.absdiff(before_gray, after_gray)
-        _, thresh = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)
-
-        kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
-        thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
-        thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel)
-
-        contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
-        if not contours:
-            return None
-
-        max_contour = max(contours, key=cv2.contourArea)
-        area = cv2.contourArea(max_contour)
-        if area < 500:
-            return None
-
-        M = cv2.moments(max_contour)
-        if M["m00"] == 0:
-            return None
-
-        cx = M["m10"] / M["m00"]
-        cy = M["m01"] / M["m00"]
-
-        h, w = before_gray.shape
-        logger.debug(f"运动检测: 中心=({cx:.1f}, {cy:.1f}), 面积={area:.0f})")
-
-        return (cx / w, cy / h)
-
-    def detect_by_feature_match(self, panorama_frame: np.ndarray,
-                                 ptz_frame: np.ndarray) -> Optional[Tuple[float, float]]:
-        """通过特征匹配定位"""
-        if panorama_frame is None or ptz_frame is None:
-            return None
-
-        try:
-            pan_gray = cv2.cvtColor(panorama_frame, cv2.COLOR_BGR2GRAY) \
-                if len(panorama_frame.shape) == 3 else panorama_frame
-            ptz_gray = cv2.cvtColor(ptz_frame, cv2.COLOR_BGR2GRAY) \
-                if len(ptz_frame.shape) == 3 else ptz_frame
-
-            # 缩小图像加速
-            max_dim = 960
-            ptz_scale = 1.0
-            pan_scale = 1.0
-            if ptz_gray.shape[1] > max_dim:
-                ptz_scale = max_dim / ptz_gray.shape[1]
-                ptz_gray = cv2.resize(ptz_gray, None, fx=ptz_scale, fy=ptz_scale,
-                                      interpolation=cv2.INTER_AREA)
-            if pan_gray.shape[1] > max_dim:
-                pan_scale = max_dim / pan_gray.shape[1]
-                pan_gray = cv2.resize(pan_gray, None, fx=pan_scale, fy=pan_scale,
-                                      interpolation=cv2.INTER_AREA)
-
-            kp1, des1 = self.feature_detector.detectAndCompute(ptz_gray, None)
-            kp2, des2 = self.feature_detector.detectAndCompute(pan_gray, None)
-
-            if des1 is None or des2 is None or len(kp1) < 4 or len(kp2) < 4:
-                return None
-
-            matches = self.matcher.knnMatch(des1, des2, k=2)
-            good_matches = []
-            for match_pair in matches:
-                if len(match_pair) == 2:
-                    m, n = match_pair
-                    if m.distance < LOWE_RATIO * n.distance:
-                        good_matches.append(m)
-
-            if len(good_matches) < MIN_MATCH_THRESHOLD:
-                return None
-
-            # 几何验证:用 RANSAC 单应矩阵剔除空间不一致的匹配
-            ptz_pts = np.float32([kp1[m.queryIdx].pt for m in good_matches])
-            pan_pts = np.float32([kp2[m.trainIdx].pt for m in good_matches])
-
-            try:
-                _, mask = cv2.findHomography(ptz_pts, pan_pts, cv2.RANSAC, RANSAC_THRESHOLD)
-                inlier_mask = mask.ravel().astype(bool)
-                inlier_count = int(np.sum(inlier_mask))
-            except Exception:
-                return None
-
-            if inlier_count < MIN_INLIERS:
-                return None
-
-            inlier_pan_pts = pan_pts[inlier_mask]
-            center_x = np.mean(inlier_pan_pts[:, 0])
-            center_y = np.mean(inlier_pan_pts[:, 1])
-
-            h, w = pan_gray.shape
-            logger.debug(f"特征匹配: 匹配点={len(good_matches)}, 内点={inlier_count}, "
-                        f"中心=({center_x:.1f}, {center_y:.1f})")
-
-            return (center_x / w, center_y / h)
-
-        except Exception as e:
-            logger.error(f"特征匹配错误: {e}")
-            return None
-
-    def detect_position(self, panorama_frame: np.ndarray,
-                        frames_before: np.ndarray = None,
-                        frames_after: np.ndarray = None,
-                        ptz_frame: np.ndarray = None) -> Tuple[bool, float, float]:
-        """综合检测球机在全景画面中的位置"""
-        results = []
-
-        if self.use_motion_detection and frames_before is not None and frames_after is not None:
-            motion_result = self.detect_by_motion(frames_before, frames_after)
-            if motion_result:
-                results.append(('motion', motion_result, 0.4))
-
-        if self.use_feature_matching and ptz_frame is not None:
-            feature_result = self.detect_by_feature_match(panorama_frame, ptz_frame)
-            if feature_result:
-                results.append(('feature', feature_result, 0.6))
-
-        if not results:
-            return (False, 0.0, 0.0)
-
-        total_weight = sum(r[2] for r in results)
-        x_ratio = sum(r[1][0] * r[2] for r in results) / total_weight
-        y_ratio = sum(r[1][1] * r[2] for r in results) / total_weight
-
-        logger.debug(f"融合结果: ({x_ratio:.3f}, {y_ratio:.3f})")
-        return (True, x_ratio, y_ratio)
-
-
-class CameraCalibrator:
-    """
-    相机校准器
-    两阶段校准:先发现视野重叠区域,再在重叠区内校准
-    """
-
-    def __init__(self, ptz_camera: PTZCamera,
-                 get_frame_func: Callable[[], np.ndarray],
-                 detect_marker_func: Callable[[np.ndarray], Optional[Tuple[float, float]]] = None,
-                 ptz_capture_func: Callable[[], Optional[np.ndarray]] = None):
-        self.ptz = ptz_camera
-        self.get_frame = get_frame_func
-        self.detect_marker = detect_marker_func
-        self.ptz_capture = ptz_capture_func
-
-        self.visual_detector = VisualCalibrationDetector()
-        self.overlap_discovery = OverlapDiscovery()
-
-        self.state = CalibrationState.IDLE
-        self.result: Optional[CalibrationResult] = None
-
-        # 变换参数 (线性模型 - 作为后备)
-        self.pan_offset = 0.0
-        self.pan_scale_x = 1.0
-        self.pan_scale_y = 0.0
-        self.tilt_offset = 0.0
-        self.tilt_scale_x = 0.0
-        self.tilt_scale_y = 1.0
-
-        # 分段线性查找表 (主变换方法)
-        # 存储 x_ratio → pan 和 y_ratio → tilt 的映射
-        self.pan_lookup: List[Tuple[float, float]] = []  # [(x_ratio, pan), ...] sorted by x_ratio
-        self.tilt_lookup: List[Tuple[float, float]] = []  # [(y_ratio, tilt), ...] sorted by y_ratio
-
-        # tilt偏移补偿(度),正值=向下补偿,优先从传入的球机配置读取
-        ptz_config = getattr(ptz_camera, 'ptz_config', None)
-        if ptz_config is None:
-            from config import PTZ_CONFIG
-            ptz_config = PTZ_CONFIG
-        self.tilt_offset_deg = ptz_config.get('tilt_offset', 0)
-        self.pan_offset_deg = ptz_config.get('pan_offset', 0)
-        self.pan_edge_offset = ptz_config.get('pan_edge_offset', 0)
-        self.pan_curve_power = ptz_config.get('pan_curve_power', 1.0)
-        # tilt线性映射(替代不稳定的查找表)
-        self.tilt_linear_enabled = ptz_config.get('tilt_linear_enabled', False)
-        self.tilt_y0 = ptz_config.get('tilt_y0', 0)
-        self.tilt_y1 = ptz_config.get('tilt_y1', 45)
-        self.tilt_curve_power = ptz_config.get('tilt_curve_power', 1.0)
-        # 安装方向翻转(与 ptz_camera.calculate_ptz_position 保持一致)
-        self.tilt_flip = ptz_config.get('tilt_flip', False)
-        self.pan_flip = ptz_config.get('pan_flip', False)
-
-        # 校准配置
-        self.stabilize_time = 1.0
-        self.use_motion_detection = True
-        self.use_feature_matching = True
-
-        # 重叠发现配置(可从 ptz_config 覆盖,避免在无效方向浪费扫描时间)
-        self.overlap_pan_range = ptz_config.get('overlap_pan_range', (0, 360))
-        self.overlap_tilt_range = ptz_config.get('overlap_tilt_range', (-20, 50))
-        self.overlap_pan_step = ptz_config.get('overlap_pan_step', 20)
-        self.overlap_tilt_step = ptz_config.get('overlap_tilt_step', 15)
-        self.max_overlap_ranges = 3
-        self.min_positions_per_range = 3
-
-        # 回调
-        self.on_progress: Optional[Callable[[int, int, str], None]] = None
-        self.on_complete: Optional[Callable[[CalibrationResult], None]] = None
-
-        # 发现的重叠区间
-        self.overlap_ranges: List[OverlapRange] = []
-
-    def _angular_diff(self, a: float, b: float) -> float:
-        """计算两个角度之间的最小差值,考虑360°环绕"""
-        diff = a - b
-        while diff > 180:
-            diff -= 360
-        while diff < -180:
-            diff += 360
-        return diff
-
-    def _unwrap_pan_angles(self, pan_values: np.ndarray) -> np.ndarray:
-        """
-        将pan角度展开为连续值,避免0°/360°边界的不连续性
-
-        使用中位数作为参考点,将所有角度调整到参考点的±180°范围内。
-        这样即使校准点跨越0°/360°边界,也能正确拟合线性变换。
-        例如: [350, 355, 5, 10] → [-10, -5, 5, 10] (ref=5)
-        """
-        if len(pan_values) == 0:
-            return pan_values
-
-        ref = float(np.median(pan_values))
-        unwrapped = pan_values.astype(float).copy()
-
-        for i in range(len(unwrapped)):
-            diff = unwrapped[i] - ref
-            while diff > 180:
-                unwrapped[i] -= 360
-                diff = unwrapped[i] - ref
-            while diff < -180:
-                unwrapped[i] += 360
-                diff = unwrapped[i] - ref
-
-        return unwrapped
-
-    def calibrate(self, quick_mode: bool = True) -> CalibrationResult:
-        """
-        执行校准 - 两阶段流程
-        
-        阶段1: 视野重叠发现 - 扫描球机范围,找出与全景有重叠的角度区间
-        阶段2: 精确校准 - 仅在重叠区间内生成校准点,逐一验证后拟合变换
-        """
-        self.state = CalibrationState.RUNNING
-
-        # ===================== 阶段1: 视野重叠发现 =====================
-        logger.info("阶段1: 视野重叠发现 - 确定球机与全景的重叠区域")
-
-        self.overlap_ranges = self.overlap_discovery.discover_overlap_ranges(
-            ptz=self.ptz,
-            get_panorama_frame=self.get_frame,
-            ptz_capture=self.ptz_capture,
-            pan_range=self.overlap_pan_range,
-            tilt_range=self.overlap_tilt_range,
-            pan_step=self.overlap_pan_step,
-            tilt_step=self.overlap_tilt_step,
-            stabilize_time=self.stabilize_time,
-            on_progress=self.on_progress,
-            max_ranges=self.max_overlap_ranges,
-            min_positions_per_range=self.min_positions_per_range
-        )
-
-        if not self.overlap_ranges:
-            self.state = CalibrationState.FAILED
-            self.result = CalibrationResult(
-                success=False,
-                points=[],
-                error_message="未发现球机与全景的视野重叠区域,无法校准。请检查两台摄像头的安装位置和朝向。"
-            )
-            logger.error(f"校准失败: {self.result.error_message}")
-            if self.on_complete:
-                self.on_complete(self.result)
-            return self.result
-
-        logger.info(f"发现 {len(self.overlap_ranges)} 个重叠区间")
-
-        # 保留所有重叠区间用于校准(覆盖更广的视野范围)
-        logger.info(f"使用全部 {len(self.overlap_ranges)} 个重叠区间进行校准(覆盖更广视野)")
-        for i, r in enumerate(self.overlap_ranges):
-            logger.info(f"  区间{i+1}: pan=[{r.pan_start:.0f}°, {r.pan_end:.0f}°], "
-                       f"tilt=[{r.tilt_start:.0f}°, {r.tilt_end:.0f}°], 匹配点={r.match_count}")
-
-        # ===================== 阶段2: 使用阶段1扫描数据 + 补充校准 =====================
-        # 阶段1已对整个视野扫描并记录了(pan, tilt) → (x_ratio, y_ratio)对应关系
-        # 直接使用这些数据比阶段2重新在单个区间内采集更全面、更高效
-
-        valid_points = []
-
-        # 直接从阶段1扫描结果构建校准点
-        scan_results = getattr(self.overlap_discovery, 'scan_results', [])
-        if scan_results:
-            logger.info(f"使用阶段1扫描数据: {len(scan_results)}个有效匹配位置")
-            for pan, tilt, match_count, x_ratio, y_ratio in scan_results:
-                valid_points.append(CalibrationPoint(
-                    pan=pan, tilt=tilt, zoom=1.0,
-                    x_ratio=x_ratio, y_ratio=y_ratio,
-                    detected=True, match_count=match_count
-                ))
-        else:
-            logger.warning("阶段1无扫描数据,回退到阶段2逐点校准")
-
-        # 如果扫描数据不足,补充在重叠区内采集更多点
-        min_scan_points = 8
-        if len(valid_points) < min_scan_points:
-            logger.info(f"扫描数据不足({len(valid_points)}<{min_scan_points}),在重叠区间内补充采集")
-            supplement_points = self._generate_points_in_overlaps(quick_mode)
-            total_supplement = len(supplement_points)
-            supplement_valid = 0
-
-            for idx, point in enumerate(supplement_points):
-                if self.on_progress:
-                    self.on_progress(idx + 1, total_supplement,
-                                    f"补充校准点 {idx + 1}/{total_supplement}: pan={point.pan:.1f}°, tilt={point.tilt:.1f}°")
-
-                logger.info(f"补充校准点 {idx + 1}/{total_supplement}: pan={point.pan:.1f}°, tilt={point.tilt:.1f}°")
-
-                # 获取移动前全景帧
-                frames_before_list = []
-                for _ in range(3):
-                    frame = self.get_frame()
-                    if frame is not None:
-                        frames_before_list.append(frame)
-                    time.sleep(0.1)
-
-                if not frames_before_list:
-                    continue
-
-                frames_before = np.mean(frames_before_list, axis=0).astype(np.uint8)
-
-                # 移动球机
-                if not self.ptz.goto_exact_position(point.pan, point.tilt, 1):
-                    continue
-
-                time.sleep(self.stabilize_time)
-
-                # 获取移动后帧
-                frames_after_list = []
-                for _ in range(3):
-                    frame = self.get_frame()
-                    if frame is not None:
-                        frames_after_list.append(frame)
-                    time.sleep(0.1)
-
-                if not frames_after_list:
-                    continue
-
-                panorama_frame = np.mean(frames_after_list, axis=0).astype(np.uint8)
-
-                # 球机抓拍
-                ptz_frame = None
-                if self.ptz_capture:
-                    try:
-                        ptz_frame = self.ptz_capture()
-                    except Exception:
-                        pass
-
-                # 特征匹配验证
-                if ptz_frame is not None and panorama_frame is not None:
-                    success, match_count, cx, cy = self.overlap_discovery.match_frames(ptz_frame, panorama_frame)
-                    if success:
-                        h, w = panorama_frame.shape[:2]
-                        point.x_ratio = cx / w
-                        point.y_ratio = cy / h
-                        point.detected = True
-                        valid_points.append(point)
-                        supplement_valid += 1
-                        logger.info(f"补充点验证通过: {match_count}个匹配点, "
-                                   f"全景位置=({point.x_ratio:.3f}, {point.y_ratio:.3f})")
-                        continue
-
-                # 运动检测备选
-                if self.use_motion_detection and frames_before is not None and panorama_frame is not None:
-                    motion_result = self.visual_detector.detect_by_motion(frames_before, panorama_frame)
-                    if motion_result:
-                        point.x_ratio, point.y_ratio = motion_result
-                        point.detected = True
-                        valid_points.append(point)
-                        supplement_valid += 1
-                        logger.info(f"运动检测定位: ({point.x_ratio:.3f}, {point.y_ratio:.3f})")
-
-            logger.info(f"补充采集: {supplement_valid}/{total_supplement} 个点验证通过")
-
-        # ===================== 检查有效校准点 =====================
-        min_valid = 4
-        if len(valid_points) < min_valid:
-            self.state = CalibrationState.FAILED
-            self.result = CalibrationResult(
-                success=False,
-                points=valid_points,
-                error_message=f"有效校准点不足 (需要至少{min_valid}个, 实际{len(valid_points)}个)。"
-                              f"请检查球机与全景的视野重叠是否足够。"
-            )
-            logger.error(f"校准失败: {self.result.error_message}")
-            if self.on_complete:
-                self.on_complete(self.result)
-            return self.result
-
-        # ===================== 计算变换参数 =====================
-        success = self._calculate_transform(valid_points)
-
-        if success:
-            # 构建分段线性查找表(主变换方法,处理pan环绕)
-            lookup_ok = self._build_lookup_tables(valid_points)
-
-            self.state = CalibrationState.SUCCESS
-            rms_error = self._calculate_rms_error(valid_points)
-
-            self.result = CalibrationResult(
-                success=True,
-                points=valid_points,
-                rms_error=rms_error
-            )
-            logger.info(f"校准成功! 有效校准点: {len(valid_points)}, "
-                        f"重叠区间数: {len(self.overlap_ranges)}, RMS误差: {rms_error:.4f}°")
-
-            # 校准验证:将球机移到全景画面中心,检查是否指向正确位置
-            verify_ok = self._verify_calibration()
-            if not verify_ok:
-                logger.warning("校准验证未通过,校准结果可能不准确")
-
-            # 自动保存校准结果
-            try:
-                from config import CALIBRATION_CONFIG
-                if CALIBRATION_CONFIG.get('auto_save', True):
-                    filepath = CALIBRATION_CONFIG.get('calibration_file', 'calibration.json')
-                    self.save_calibration(filepath)
-            except Exception:
-                pass
-
-            # 校准完成后,将球机复位到初始位置
-            self._reset_ptz_position()
-        else:
-            self.state = CalibrationState.FAILED
-            self.result = CalibrationResult(
-                success=False,
-                points=valid_points,
-                error_message="变换参数计算失败"
-            )
-            logger.error(f"校准失败: {self.result.error_message}")
-
-        if self.on_complete:
-            self.on_complete(self.result)
-
-        return self.result
-
-    def _reset_ptz_position(self):
-        """校准完成后将球机复位到初始位置"""
-        if self.ptz is None:
-            return
-
-        try:
-            # 获取默认位置配置,优先从传入的球机配置读取
-            ptz_config = getattr(self.ptz, 'ptz_config', None)
-            if ptz_config is None:
-                from config import PTZ_CONFIG
-                ptz_config = PTZ_CONFIG
-            default_pan = ptz_config.get('default_pan', 0)
-            default_tilt = ptz_config.get('default_tilt', 0)
-            default_zoom = ptz_config.get('default_zoom', 1)
-
-            logger.info(f"球机复位到位置: pan={default_pan}, tilt={default_tilt}, zoom={default_zoom}")
-            self.ptz.goto_exact_position(default_pan, default_tilt, default_zoom)
-            time.sleep(0.5)
-        except Exception as e:
-            logger.warning(f"球机复位失败: {e}")
-
-    def _verify_calibration(self) -> bool:
-        """
-        校准验证:将球机移到全景画面中心对应的PTZ角度,
-        通过特征匹配验证球机是否指向了全景画面中心区域。
-
-        同时验证全景画面中的多个关键位置(左、中、右),
-        确保变换在整个视野范围内基本正确。
-
-        Returns:
-            验证是否通过
-        """
-        logger.info("=" * 50)
-        logger.info("校准验证: 将球机移到全景画面中心位置")
-        logger.info("=" * 50)
-
-        if self.ptz is None or self.get_frame is None:
-            logger.warning("无法执行校准验证: PTZ或全景帧获取函数不可用")
-            return False
-
-        # 验证位置列表:全景画面中的关键位置
-        verify_positions = [
-            ("全景中心", 0.5, 0.5),
-            ("全景左侧", 0.25, 0.5),
-            ("全景右侧", 0.75, 0.5),
-        ]
-
-        passed = 0
-        total = len(verify_positions)
-
-        for name, x_ratio, y_ratio in verify_positions:
-            pan, tilt = self.transform(x_ratio, y_ratio)
-            logger.info(f"验证 {name} ({x_ratio:.2f}, {y_ratio:.2f}) → "
-                        f"PTZ角度: pan={pan:.1f}°, tilt={tilt:.1f}°")
-
-            # 检查角度是否在合理范围
-            if pan < -10 or pan > 370 or tilt < -95 or tilt > 95:
-                logger.warning(f"  变换结果异常: pan={pan:.1f}°, tilt={tilt:.1f}° 超出合理范围")
-                continue
-
-            # 移动球机到计算出的位置
-            if not self.ptz.goto_exact_position(pan, tilt, 1):
-                logger.warning(f"  移动球机失败")
-                continue
-
-            time.sleep(self.stabilize_time)
-
-            # 获取全景帧和球机帧
-            panorama_frame = self.get_frame()
-            ptz_frame = self.ptz_capture() if self.ptz_capture else None
-
-            if panorama_frame is None or ptz_frame is None:
-                logger.warning(f"  获取帧失败: 全景={'OK' if panorama_frame is not None else '失败'}, "
-                              f"球机={'OK' if ptz_frame is not None else '失败'}")
-                continue
-
-            # 特征匹配验证
-            success, match_count, cx, cy = self.overlap_discovery.match_frames(
-                ptz_frame, panorama_frame
-            )
-
-            h, w = panorama_frame.shape[:2]
-            match_x_ratio = cx / w
-            match_y_ratio = cy / h
-
-            # 计算期望位置与实际匹配位置的偏差
-            position_error = math.sqrt(
-                (match_x_ratio - x_ratio) ** 2 + (match_y_ratio - y_ratio) ** 2
-            )
-
-            if success:
-                logger.info(f"  匹配成功: {match_count}个特征点, "
-                           f"匹配位置=({match_x_ratio:.3f}, {match_y_ratio:.3f}), "
-                           f"期望位置=({x_ratio:.3f}, {y_ratio:.3f}), "
-                           f"位置偏差={position_error:.3f}")
-                if position_error < 0.15:
-                    passed += 1
-                    logger.info(f"  验证通过 (偏差 < 15%)")
-                else:
-                    logger.warning(f"  验证偏差较大 ({position_error:.1%}),校准精度可能不足")
-            else:
-                logger.warning(f"  特征匹配不足({match_count}点), "
-                              f"球机可能未指向全景画面中期望的位置")
-
-        logger.info(f"校准验证结果: {passed}/{total} 个位置验证通过")
-
-        if passed == 0:
-            logger.error("所有验证位置均未通过,校准结果可能完全错误!请检查:")
-            logger.error("  1. 球机安装方向配置是否正确 (mount_type, pan_flip, tilt_flip)")
-            logger.error("  2. 两台摄像头的相对位置是否合理")
-            logger.error("  3. 球机PTZ角度范围是否配置正确")
-            return False
-
-        if passed < total:
-            logger.warning(f"部分验证未通过,校准精度可能有限")
-            return True
-
-        return True
-
-    def _generate_points_in_overlaps(self, quick_mode: bool = True) -> List[CalibrationPoint]:
-        """
-        在发现的重叠区间内生成校准点
-        只在球机和全景有视觉重叠的区域生成点
-        """
-        points = []
-
-        if quick_mode:
-            # 快速模式: 每个重叠区间内生成9个点(3x3网格)
-            for overlap in self.overlap_ranges:
-                # 在区间中心生成点
-                pan_center = (overlap.pan_start + overlap.pan_end) / 2
-                tilt_center = (overlap.tilt_start + overlap.tilt_end) / 2
-                pan_span = overlap.pan_end - overlap.pan_start
-                tilt_span = overlap.tilt_end - overlap.tilt_start
-
-                # 3x3网格分布
-                pan_positions = [0.25, 0.5, 0.75] if pan_span > 10 else [0.5]
-                tilt_positions = [0.25, 0.5, 0.75] if tilt_span > 10 else [0.5]
-
-                for pf in pan_positions:
-                    for tf in tilt_positions:
-                        points.append(CalibrationPoint(
-                            pan=overlap.pan_start + pan_span * pf,
-                            tilt=overlap.tilt_start + tilt_span * tf,
-                            zoom=1.0))
-        else:
-            # 完整模式: 在每个重叠区间内均匀分布
-            grid_size = 5
-            for overlap in self.overlap_ranges:
-                for i in range(grid_size):
-                    for j in range(grid_size):
-                        pan = overlap.pan_start + (overlap.pan_end - overlap.pan_start) * i / (grid_size - 1)
-                        tilt = overlap.tilt_start + (overlap.tilt_end - overlap.tilt_start) * j / (grid_size - 1)
-                        points.append(CalibrationPoint(pan=pan, tilt=tilt, zoom=1.0))
-
-        return points
-
-    def _calculate_transform(self, points: List[CalibrationPoint]) -> bool:
-        """使用RANSAC + 最小二乘法拟合变换参数,剔除异常值"""
-        try:
-            if len(points) < 4:
-                logger.error(f"计算变换参数错误: 有效点不足({len(points)}个)")
-                return False
-
-            pan_values = np.array([p.pan for p in points])
-            tilt_values = np.array([p.tilt for p in points])
-            x_ratios = np.array([p.x_ratio for p in points])
-            y_ratios = np.array([p.y_ratio for p in points])
-
-            # 记录原始校准数据便于调试
-            logger.info("校准点原始数据:")
-            for i, p in enumerate(points):
-                logger.info(f"  点{i+1}: pan={p.pan:.1f}°, tilt={p.tilt:.1f}° → "
-                           f"全景位置=({p.x_ratio:.3f}, {p.y_ratio:.3f})")
-
-            # 展开pan角度避免0°/360°边界不连续性
-            pan_unwrapped = self._unwrap_pan_angles(pan_values)
-
-            if not np.allclose(pan_values, pan_unwrapped, atol=0.1):
-                logger.info(f"Pan角度展开: 原始={pan_values.tolist()} → 展开后={pan_unwrapped.tolist()}")
-            else:
-                logger.info("Pan角度无需展开(无0°/360°边界跨越)")
-
-            # RANSAC剔除异常值 (使用展开后的pan)
-            inlier_mask = self._ransac_filter(x_ratios, y_ratios, pan_unwrapped, tilt_values)
-
-            inlier_count = np.sum(inlier_mask)
-            if inlier_count < 4:
-                logger.warning(f"RANSAC后有效点不足({inlier_count}个),使用全部点")
-                inlier_mask = np.ones(len(points), dtype=bool)
-                inlier_count = np.sum(inlier_mask)
-            else:
-                logger.info(f"RANSAC: {len(points)}个点中{inlier_count}个内点,"
-                           f"剔除{len(points) - inlier_count}个异常值")
-
-            # 记录内点数据
-            logger.info("RANSAC内点数据:")
-            for i, p in enumerate(points):
-                if inlier_mask[i]:
-                    logger.info(f"  点{i+1}: pan={pan_unwrapped[i]:.1f}°(原始={p.pan:.1f}°), "
-                               f"tilt={p.tilt:.1f}° → ({p.x_ratio:.3f}, {p.y_ratio:.3f})")
-
-            # 用内点拟合完整模型
-            A = np.ones((inlier_count, 3))
-            A[:, 1] = x_ratios[inlier_mask]
-            A[:, 2] = y_ratios[inlier_mask]
-
-            pan_params, _, _, _ = np.linalg.lstsq(A, pan_unwrapped[inlier_mask], rcond=None)
-            tilt_params, _, _, _ = np.linalg.lstsq(A, tilt_values[inlier_mask], rcond=None)
-
-            self.pan_offset = pan_params[0]
-            self.pan_scale_x = pan_params[1]
-            self.pan_scale_y = pan_params[2]
-            self.tilt_offset = tilt_params[0]
-            self.tilt_scale_x = tilt_params[1]
-            self.tilt_scale_y = tilt_params[2]
-
-            # 系数合理性检查
-            pan_coeffs_ok = (abs(self.pan_scale_x) < 500 and abs(self.pan_scale_y) < 500)
-            tilt_coeffs_ok = (abs(self.tilt_scale_x) < 300 and abs(self.tilt_scale_y) < 300)
-
-            if not (pan_coeffs_ok and tilt_coeffs_ok):
-                logger.warning(f"完整模型系数异常: pan_scale_x={self.pan_scale_x:.1f}, "
-                             f"pan_scale_y={self.pan_scale_y:.1f}, "
-                             f"tilt_scale_x={self.tilt_scale_x:.1f}, "
-                             f"tilt_scale_y={self.tilt_scale_y:.1f}")
-                logger.info("尝试简化模型: pan仅依赖x, tilt仅依赖y")
-
-                # 简化模型: pan = offset + scale_x * x
-                #            tilt = offset + scale_y * y
-                A_pan = np.ones((inlier_count, 2))
-                A_pan[:, 1] = x_ratios[inlier_mask]
-                pan_params_s, _, _, _ = np.linalg.lstsq(A_pan, pan_unwrapped[inlier_mask], rcond=None)
-
-                A_tilt = np.ones((inlier_count, 2))
-                A_tilt[:, 1] = y_ratios[inlier_mask]
-                tilt_params_s, _, _, _ = np.linalg.lstsq(A_tilt, tilt_values[inlier_mask], rcond=None)
-
-                self.pan_offset = pan_params_s[0]
-                self.pan_scale_x = pan_params_s[1]
-                self.pan_scale_y = 0.0
-                self.tilt_offset = tilt_params_s[0]
-                self.tilt_scale_x = 0.0
-                self.tilt_scale_y = tilt_params_s[1]
-
-                logger.info(f"简化模型: pan={self.pan_offset:.2f} + {self.pan_scale_x:.2f}*x, "
-                           f"tilt={self.tilt_offset:.2f} + {self.tilt_scale_y:.2f}*y")
-
-            # 验证变换对全景中心的预测是否合理
-            center_pan, center_tilt = self.transform(0.5, 0.5)
-            logger.info(f"全景中心(0.5,0.5)预测: pan={center_pan:.1f}°, tilt={center_tilt:.1f}°")
-
-            logger.info(f"最终变换参数: pan = {self.pan_offset:.2f} + {self.pan_scale_x:.2f}*x + {self.pan_scale_y:.2f}*y, "
-                       f"tilt = {self.tilt_offset:.2f} + {self.tilt_scale_x:.2f}*x + {self.tilt_scale_y:.2f}*y")
-
-            return True
-
-        except Exception as e:
-            logger.error(f"计算变换参数错误: {e}")
-            import traceback
-            logger.error(traceback.format_exc())
-            return False
-
-    def _ransac_filter(self, x: np.ndarray, y: np.ndarray,
-                       pan: np.ndarray, tilt: np.ndarray,
-                       max_iterations: int = 200, threshold: float = 15.0,
-                       min_samples: int = 4) -> np.ndarray:
-        """RANSAC剔除变换拟合中的异常值(pan应已展开为连续值)"""
-        n = len(x)
-        best_inliers = np.zeros(n, dtype=bool)
-        best_inlier_count = 0
-
-        rng = np.random.RandomState(42)
-
-        for _ in range(max_iterations):
-            # 随机选min_samples个点
-            indices = rng.choice(n, min_samples, replace=False)
-
-            # 用这些点拟合
-            A = np.ones((min_samples, 3))
-            A[:, 1] = x[indices]
-            A[:, 2] = y[indices]
-
-            try:
-                pan_params, _, _, _ = np.linalg.lstsq(A, pan[indices], rcond=None)
-                tilt_params, _, _, _ = np.linalg.lstsq(A, tilt[indices], rcond=None)
-            except np.linalg.LinAlgError:
-                continue
-
-            # 计算所有点的误差
-            pred_pan = pan_params[0] + pan_params[1] * x + pan_params[2] * y
-            pred_tilt = tilt_params[0] + tilt_params[1] * x + tilt_params[2] * y
-
-            # 使用角度差计算pan误差(即使已展开,仍用角度差以防边界情况)
-            pan_errors = np.array([self._angular_diff(float(pred_pan[i]), float(pan[i]))
-                                   for i in range(n)])
-            tilt_errors = pred_tilt - tilt
-
-            errors = np.sqrt(pan_errors ** 2 + tilt_errors ** 2)
-
-            inliers = errors < threshold
-            inlier_count = np.sum(inliers)
-
-            if inlier_count > best_inlier_count:
-                best_inlier_count = inlier_count
-                best_inliers = inliers
-
-        if best_inlier_count == 0:
-            return np.ones(n, dtype=bool)
-
-        return best_inliers
-
-    def _calculate_rms_error(self, points: List[CalibrationPoint]) -> float:
-        """计算均方根误差(使用角度差处理pan环绕)"""
-        total_error = 0.0
-
-        for p in points:
-            pred_pan, pred_tilt = self.transform(p.x_ratio, p.y_ratio)
-            # 使用角度差计算pan误差,处理0°/360°环绕
-            pan_error = self._angular_diff(pred_pan, p.pan)
-            tilt_error = pred_tilt - p.tilt
-            error = math.sqrt(pan_error ** 2 + tilt_error ** 2)
-            total_error += error ** 2
-
-        return math.sqrt(total_error / len(points))
-
-    def transform(self, x_ratio: float, y_ratio: float) -> Tuple[float, float]:
-        """将全景坐标转换为PTZ角度 - 梯形透视补偿"""
-        # 优先使用分段线性查找表(pan)
-        if self.pan_lookup:
-            pan = self._interp_lookup(self.pan_lookup, x_ratio)
-        else:
-            pan = self.pan_offset + self.pan_scale_x * x_ratio + self.pan_scale_y * y_ratio
-
-        # pan边缘曲线补偿:越靠近边缘补偿越大,中心不补偿
-        # 梯形透视:底部(y大)更宽,边缘补偿更大;顶部(y小)更窄,补偿更小
-        if self.pan_edge_offset != 0:
-            dx = 2 * x_ratio - 1  # -1(左) ~ 0(中) ~ +1(右)
-            y_scale = 0.3 + 0.7 * y_ratio  # 顶部0.3倍,底部1.0倍
-            pan_correction = self.pan_edge_offset * y_scale * math.copysign(abs(dx) ** self.pan_curve_power, dx)
-            pan += pan_correction
-
-        # pan 方向翻转(与 ptz_camera.calculate_ptz_position 保持一致)
-        if self.pan_flip:
-            pan = -pan
-
-        # 将pan归一化到[0, 360),便于发送给球机
-        pan = pan % 360
-
-        # tilt:优先使用曲线映射(查找表tilt数据不稳定),后备查找表
-        if self.tilt_linear_enabled:
-            tilt = self.tilt_y0 + (self.tilt_y1 - self.tilt_y0) * (y_ratio ** self.tilt_curve_power)
-        elif self.tilt_lookup:
-            tilt = self._interp_lookup(self.tilt_lookup, y_ratio)
-        else:
-            tilt = self.tilt_offset + self.tilt_scale_x * x_ratio + self.tilt_scale_y * y_ratio
-
-        # tilt 方向翻转(与 ptz_camera.calculate_ptz_position 保持一致)
-        if self.tilt_flip:
-            tilt = -tilt
-
-        # 应用全局 tilt 偏移补偿(配置中正值=向下补偿)
-        tilt += self.tilt_offset_deg
-
-        return (pan, tilt)
-
-    def _interp_lookup(self, lookup: List[Tuple[float, float]], ratio: float) -> float:
-        """分段线性插值"""
-        if not lookup:
-            return 0.0
-        if len(lookup) == 1:
-            return lookup[0][1]
-        if ratio <= lookup[0][0]:
-            return lookup[0][1]
-        if ratio >= lookup[-1][0]:
-            return lookup[-1][1]
-
-        # 二分查找插入位置
-        lo, hi = 0, len(lookup) - 1
-        while lo < hi - 1:
-            mid = (lo + hi) // 2
-            if lookup[mid][0] <= ratio:
-                lo = mid
-            else:
-                hi = mid
-
-        # 线性插值
-        x0, v0 = lookup[lo]
-        x1, v1 = lookup[hi]
-        if abs(x1 - x0) < 1e-10:
-            return v0
-        t = (ratio - x0) / (x1 - x0)
-        return v0 + t * (v1 - v0)
-
-    def _build_lookup_tables(self, points: List[CalibrationPoint]) -> bool:
-        """
-        从校准点构建分段线性查找表
-
-        核心策略:
-        1. 将所有校准点按x_ratio分桶,取匹配点数加权的pan值
-        2. 用最长连续单调子序列(LCMA)过滤假阳性:x_ratio→pan应近似单调
-        3. 处理pan角度环绕
-        """
-        if len(points) < 3:
-            return False
-
-        sorted_by_x = sorted(points, key=lambda p: p.x_ratio)
-
-        # ===== 构建 x_ratio → pan 映射 =====
-        grid_size = 0.05
-        x_buckets: Dict[float, List[Tuple[float, int]]] = {}
-        for p in sorted_by_x:
-            x_key = round(p.x_ratio / grid_size) * grid_size
-            if x_key not in x_buckets:
-                x_buckets[x_key] = []
-            match_count = getattr(p, 'match_count', 10)
-            x_buckets[x_key].append((p.pan, match_count))
-
-        # 加权中位数
-        raw_entries = []
-        for x_key in sorted(x_buckets.keys()):
-            entries = x_buckets[x_key]
-            total_weight = sum(mc for _, mc in entries)
-            weighted_pans = []
-            for pan, mc in entries:
-                weighted_pans.extend([pan] * max(1, mc // 5))
-            weighted_pan = float(np.median(weighted_pans))
-            raw_entries.append((x_key, weighted_pan, total_weight))
-
-        logger.info(f"Pan原始映射 ({len(raw_entries)} 个x_key):")
-        for x, pan, w in raw_entries:
-            logger.info(f"  x={x:.3f} → pan={pan:.1f}° (weight={w})")
-
-        # 用LCMA过滤:找到最长的近似连续单调子序列
-        # pan随x_ratio应该是近似单调递减或递增的
-        if len(raw_entries) >= 3:
-            filtered = self._filter_continuous_monotonic(raw_entries)
-            self.pan_lookup = [(x, pan) for x, pan, w in filtered]
-        else:
-            self.pan_lookup = [(x, pan % 360) for x, pan, w in raw_entries]
-
-        # ===== 构建 y_ratio → tilt 映射 =====
-        # 只使用通过pan过滤的点(x_ratio对应的pan与查找表一致)
-        pan_valid_x = set(x for x, _ in self.pan_lookup)
-        pan_tolerance = grid_size * 1.5  # 允许在pan有效区域附近的点
-        valid_points_for_tilt = []
-        for p in sorted_by_x:
-            for vx in pan_valid_x:
-                if abs(p.x_ratio - vx) <= pan_tolerance:
-                    valid_points_for_tilt.append(p)
-                    break
-
-        logger.info(f"Tilt映射使用 {len(valid_points_for_tilt)}/{len(sorted_by_x)} 个经过pan验证的点")
-
-        y_buckets: Dict[float, List[Tuple[float, int]]] = {}
-        for p in valid_points_for_tilt:
-            y_key = round(p.y_ratio / grid_size) * grid_size
-            if y_key not in y_buckets:
-                y_buckets[y_key] = []
-            match_count = getattr(p, 'match_count', 10)
-            y_buckets[y_key].append((p.tilt, match_count))
-
-        tilt_entries = []
-        for y_key in sorted(y_buckets.keys()):
-            entries = y_buckets[y_key]
-            weighted_tilts = []
-            for tilt, mc in entries:
-                weighted_tilts.extend([tilt] * max(1, mc // 5))
-            tilt_median = float(np.median(weighted_tilts))
-            tilt_entries.append((y_key, tilt_median))
-
-        self.tilt_lookup = tilt_entries
-
-        # 记录查找表内容
-        logger.info(f"Pan查找表 ({len(self.pan_lookup)} 项):")
-        for x, pan in self.pan_lookup:
-            logger.info(f"  x={x:.3f} → pan={pan:.1f}°")
-        logger.info(f"Tilt查找表 ({len(self.tilt_lookup)} 项):")
-        for y, tilt in self.tilt_lookup:
-            logger.info(f"  y={y:.3f} → tilt={tilt:.1f}°")
-
-        return True
-
-    def _filter_continuous_monotonic(
-        self, entries: List[Tuple[float, float, int]],
-        max_step: float = 60.0
-    ) -> List[Tuple[float, float, int]]:
-        """
-        过滤出最长的连续单调子序列
-
-        x_ratio→pan应该是近似单调的(递增或递减,可能环绕一次)。
-        假阳性匹配会导致pan突然跳变到完全不相关的角度,
-        这个方法通过寻找最长的"步长<max_step"的子序列来过滤。
-
-        Args:
-            entries: [(x_key, pan, weight), ...] 已按x_key排序
-            max_step: 相邻两个点允许的最大pan差(度)
-
-        Returns:
-            过滤后的entries子集
-        """
-        n = len(entries)
-        if n <= 2:
-            return [(x, pan % 360, w) for x, pan, w in entries]
-
-        # 尝试两种方向:pan递减和pan递增
-        # 对于递减方向:每个点的pan应比前一个小(允许环绕)
-        best_result = []
-
-        for direction in ['decreasing', 'increasing']:
-            # 动态规划找最长连续子序列
-            # dp[i] = 以entries[i]结尾的最长连续子序列长度
-            # parent[i] = 前驱索引
-            dp = [1] * n
-            parent = [-1] * n
-
-            for i in range(1, n):
-                for j in range(i):
-                    # 检查j→i的pan变化是否合理
-                    pan_j = entries[j][1]
-                    pan_i = entries[i][1]
-
-                    # 计算角度差(考虑环绕)
-                    diff = pan_i - pan_j
-                    while diff > 180:
-                        diff -= 360
-                    while diff < -180:
-                        diff += 360
-
-                    # 检查方向
-                    if direction == 'decreasing':
-                        ok = diff <= 0 and abs(diff) <= max_step
-                    else:
-                        ok = diff >= 0 and abs(diff) <= max_step
-
-                    if ok and dp[j] + 1 > dp[i]:
-                        dp[i] = dp[j] + 1
-                        parent[i] = j
-
-            # 找最长子序列的终点
-            end = max(range(n), key=lambda i: dp[i])
-
-            # 回溯构建子序列
-            seq = []
-            idx = end
-            while idx >= 0:
-                seq.append(idx)
-                idx = parent[idx]
-            seq.reverse()
-
-            # 展开pan角度
-            result = self._unwrap_sequence(entries, seq)
-
-            if len(result) > len(best_result):
-                best_result = result
-
-        logger.info(f"LCMA过滤: {n}个点 → {len(best_result)}个点 (方向: "
-                    f"{'递减' if len(best_result) > 0 else '无'})")
-
-        # 如果过滤后太少,放宽条件重试
-        if len(best_result) < 3 and n >= 3:
-            logger.info("LCMA结果太少,放宽步长限制重试")
-            for wider_step in [90, 120, 180]:
-                for direction in ['decreasing', 'increasing']:
-                    dp = [1] * n
-                    parent = [-1] * n
-                    for i in range(1, n):
-                        for j in range(i):
-                            diff = entries[i][1] - entries[j][1]
-                            while diff > 180:
-                                diff -= 360
-                            while diff < -180:
-                                diff += 360
-                            if direction == 'decreasing':
-                                ok = diff <= 0 and abs(diff) <= wider_step
-                            else:
-                                ok = diff >= 0 and abs(diff) <= wider_step
-                            if ok and dp[j] + 1 > dp[i]:
-                                dp[i] = dp[j] + 1
-                                parent[i] = j
-                    end = max(range(n), key=lambda i: dp[i])
-                    seq = []
-                    idx = end
-                    while idx >= 0:
-                        seq.append(idx)
-                        idx = parent[idx]
-                    seq.reverse()
-                    result = self._unwrap_sequence(entries, seq)
-                    if len(result) > len(best_result):
-                        best_result = result
-                if len(best_result) >= 3:
-                    break
-
-        if not best_result:
-            # 全部过滤后为空,使用原始数据
-            logger.warning("LCMA过滤后为空,使用原始数据")
-            return [(x, pan % 360, w) for x, pan, w in entries]
-
-        return best_result
-
-    def _unwrap_sequence(
-        self, entries: List[Tuple[float, float, int]],
-        indices: List[int]
-    ) -> List[Tuple[float, float, int]]:
-        """将子序列的pan角度展开为连续值(不强制归一化到[0,360),便于插值)"""
-        result = []
-        prev_unwrapped = None
-        for idx in indices:
-            x, pan, w = entries[idx]
-            if prev_unwrapped is None:
-                unwrapped = pan
-            else:
-                diff = pan - prev_unwrapped
-                while diff > 180:
-                    pan -= 360
-                    diff = pan - prev_unwrapped
-                while diff < -180:
-                    pan += 360
-                    diff = pan - prev_unwrapped
-                unwrapped = pan
-            prev_unwrapped = unwrapped
-            # 保持连续值,transform 返回前再归一化到 [0,360)
-            result.append((x, unwrapped, w))
-        return result
-
-    def inverse_transform(self, pan: float, tilt: float) -> Tuple[float, float]:
-        """将PTZ角度转换为全景坐标"""
-        # 优先使用查找表的反向查找
-        if self.pan_lookup and self.tilt_lookup:
-            # 反向查找: pan → x_ratio
-            x_ratio = self._reverse_lookup(self.pan_lookup, pan % 360)
-            y_ratio = self._reverse_lookup(self.tilt_lookup, tilt)
-            return (max(0, min(1, x_ratio)), max(0, min(1, y_ratio)))
-
-        # 后备:线性模型逆变换
-        M = np.array([
-            [self.pan_scale_x, self.pan_scale_y],
-            [self.tilt_scale_x, self.tilt_scale_y]
-        ])
-
-        det = np.linalg.det(M)
-        if abs(det) < 1e-10:
-            x_ratio = (pan - self.pan_offset) / self.pan_scale_x if abs(self.pan_scale_x) > 1e-10 else 0.5
-            y_ratio = (tilt - self.tilt_offset) / self.tilt_scale_y if abs(self.tilt_scale_y) > 1e-10 else 0.5
-        else:
-            M_inv = np.linalg.inv(M)
-            offset = np.array([pan - self.pan_offset, tilt - self.tilt_offset])
-            result = M_inv @ offset
-            x_ratio, y_ratio = result[0], result[1]
-
-        return (max(0, min(1, x_ratio)), max(0, min(1, y_ratio)))
-
-    def _reverse_lookup(self, lookup: List[Tuple[float, float]], value: float) -> float:
-        """查找表反向查找:从value找ratio"""
-        if not lookup:
-            return 0.5
-
-        # 处理pan环绕:找到最接近的段
-        best_idx = 0
-        best_diff = float('inf')
-        for i, (ratio, v) in enumerate(lookup):
-            diff = self._angular_diff(value, v)
-            if abs(diff) < abs(best_diff):
-                best_diff = diff
-                best_idx = i
-
-        # 精确定位到最近的两个点之间
-        if best_idx == 0:
-            return lookup[0][0]
-        if best_idx == len(lookup) - 1:
-            return lookup[-1][0]
-
-        # 检查前一个和后一个点,选择更近的段
-        prev_v = lookup[best_idx - 1][1]
-        curr_v = lookup[best_idx][1]
-        next_v = lookup[best_idx + 1][1] if best_idx + 1 < len(lookup) else curr_v
-
-        # 在 (best_idx-1, best_idx) 和 (best_idx, best_idx+1) 之间选择
-        if abs(self._angular_diff(value, prev_v)) < abs(self._angular_diff(value, next_v)):
-            lo, hi = best_idx - 1, best_idx
-        else:
-            lo, hi = best_idx, best_idx + 1
-
-        x0, v0 = lookup[lo]
-        x1, v1 = lookup[hi]
-        # 考虑角度环绕
-        diff_v = self._angular_diff(v1, v0)
-        if abs(diff_v) < 1e-10:
-            return (x0 + x1) / 2
-
-        t = self._angular_diff(value, v0) / diff_v
-        t = max(0, min(1, t))
-        return x0 + t * (x1 - x0)
-
-    def is_calibrated(self) -> bool:
-        return self.state == CalibrationState.SUCCESS
-
-    def get_state(self) -> CalibrationState:
-        return self.state
-
-    def get_result(self) -> Optional[CalibrationResult]:
-        return self.result
-
-    def get_overlap_ranges(self) -> List[OverlapRange]:
-        """返回发现的重叠区间"""
-        return self.overlap_ranges
-
-    def save_calibration(self, filepath: str) -> bool:
-        """保存校准结果"""
-        if not self.is_calibrated():
-            return False
-
-        try:
-            import json
-            ptz_config = getattr(self.ptz, 'ptz_config', None) or _get_ptz_config()
-            data = {
-                'pan_offset': self.pan_offset,
-                'pan_scale_x': self.pan_scale_x,
-                'pan_scale_y': self.pan_scale_y,
-                'tilt_offset': self.tilt_offset,
-                'tilt_scale_x': self.tilt_scale_x,
-                'tilt_scale_y': self.tilt_scale_y,
-                'rms_error': self.result.rms_error if self.result else 0,
-                'overlap_ranges': [
-                    {
-                        'pan_start': r.pan_start,
-                        'pan_end': r.pan_end,
-                        'tilt_start': r.tilt_start,
-                        'tilt_end': r.tilt_end,
-                        'match_count': r.match_count
-                    }
-                    for r in self.overlap_ranges
-                ],
-                # 分段线性查找表
-                'pan_lookup': self.pan_lookup,
-                'tilt_lookup': self.tilt_lookup,
-                # 保存安装方向配置
-                'mount_type': ptz_config.get('mount_type', 'wall'),
-                'tilt_flip': ptz_config.get('tilt_flip', False),
-                'pan_flip': ptz_config.get('pan_flip', False),
-            }
-
-            with open(filepath, 'w') as f:
-                json.dump(data, f, indent=2)
-
-            logger.info(f"校准结果已保存: {filepath}")
-            return True
-
-        except Exception as e:
-            logger.error(f"保存校准结果失败: {e}")
-            return False
-
-    def load_calibration(self, filepath: str) -> bool:
-        """加载校准结果"""
-        try:
-            import json
-
-            with open(filepath, 'r') as f:
-                data = json.load(f)
-
-            self.pan_offset = data['pan_offset']
-            self.pan_scale_x = data['pan_scale_x']
-            self.pan_scale_y = data['pan_scale_y']
-            self.tilt_offset = data['tilt_offset']
-            self.tilt_scale_x = data['tilt_scale_x']
-            self.tilt_scale_y = data['tilt_scale_y']
-
-            # 加载分段线性查找表
-            self.pan_lookup = [tuple(p) for p in data.get('pan_lookup', [])]
-            self.tilt_lookup = [tuple(t) for t in data.get('tilt_lookup', [])]
-
-            # 加载重叠区间(如果有)
-            if 'overlap_ranges' in data:
-                self.overlap_ranges = [
-                    OverlapRange(
-                        pan_start=r['pan_start'],
-                        pan_end=r['pan_end'],
-                        tilt_start=r['tilt_start'],
-                        tilt_end=r['tilt_end'],
-                        match_count=r.get('match_count', 0),
-                        panorama_center_x=0,
-                        panorama_center_y=0
-                    )
-                    for r in data['overlap_ranges']
-                ]
-
-            self.state = CalibrationState.SUCCESS
-            self.result = CalibrationResult(
-                success=True,
-                points=[],
-                rms_error=data.get('rms_error', 0)
-            )
-            
-            # 检查安装方向配置是否匹配
-            ptz_config = getattr(self.ptz, 'ptz_config', None) or _get_ptz_config()
-            current_mount = ptz_config.get('mount_type', 'wall')
-            saved_mount = data.get('mount_type', 'wall')
-            if current_mount != saved_mount:
-                logger.warning(f"当前安装类型({current_mount})与校准时的({saved_mount})不同,建议重新校准!")
-
-            logger.info(f"校准结果已加载: {filepath}")
-            return True
-
-        except FileNotFoundError:
-            logger.warning(f"校准文件不存在: {filepath}")
-            return False
-        except Exception as e:
-            logger.error(f"加载校准结果失败: {e}")
-            return False
-
-
-class CalibrationManager:
-    """校准管理器"""
-
-    def __init__(self, calibrator: CameraCalibrator, calibration_file: str = None):
-        self.calibrator = calibrator
-        # 优先使用传入的路径,否则从配置读取,最后使用默认值
-        if calibration_file:
-            self.calibration_file = calibration_file
-        else:
-            try:
-                from config import CALIBRATION_CONFIG
-                self.calibration_file = CALIBRATION_CONFIG.get(
-                    'calibration_file', 'calibration.json'
-                )
-            except ImportError:
-                self.calibration_file = 'calibration.json'
-
-    def auto_calibrate(self, force: bool = False, fallback_on_failure: bool = True) -> CalibrationResult:
-        """
-        自动校准
-        
-        Args:
-            force: 是否强制重新校准(不加载已有数据)
-            fallback_on_failure: 校准失败时是否回退使用已有数据
-        
-        Returns:
-            校准结果
-        """
-        # 检查是否启用加载上次校准数据
-        load_on_startup = True  # 默认启用
-        try:
-            from config import CALIBRATION_CONFIG
-            load_on_startup = CALIBRATION_CONFIG.get('load_on_startup', True)
-        except:
-            pass
-        
-        # 如果不是强制校准,尝试加载已有数据
-        if not force and load_on_startup:
-            if self.calibrator.load_calibration(self.calibration_file):
-                logger.info("使用已有校准结果")
-                return self.calibrator.get_result()
-
-        # 执行新校准
-        if force:
-            logger.info("强制重新校准(不使用已有数据)...")
-        elif not load_on_startup:
-            logger.info("已禁用加载校准数据,开始新校准...")
-        else:
-            logger.info("开始自动校准...")
-            
-        result = self.calibrator.calibrate(quick_mode=True)
-
-        if result.success:
-            self.calibrator.save_calibration(self.calibration_file)
-        elif fallback_on_failure:
-            # 校准失败,尝试回退使用已有数据
-            logger.warning("校准失败,尝试回退使用已有校准数据...")
-            if self.calibrator.load_calibration(self.calibration_file):
-                logger.info("已回退到已有校准数据")
-                result = self.calibrator.get_result()
-
-        return result
-
-    def check_calibration(self) -> Tuple[bool, str]:
-        """检查校准状态"""
-        state = self.calibrator.get_state()
-
-        if state == CalibrationState.SUCCESS:
-            result = self.calibrator.get_result()
-            overlaps = self.calibrator.get_overlap_ranges()
-            overlap_info = f", {len(overlaps)}个重叠区间" if overlaps else ""
-            return (True, f"校准有效, RMS误差: {result.rms_error:.4f}°{overlap_info}")
-        elif state == CalibrationState.FAILED:
-            return (False, "校准失败")
-        elif state == CalibrationState.RUNNING:
-            return (False, "校准进行中")
-        else:
-            return (False, "未校准")

+ 0 - 464
dual_camera_system/camera_group.py

@@ -1,464 +0,0 @@
-"""
-摄像头组封装类
-封装一组全景+球机摄像头的所有组件
-"""
-
-import os
-import time
-import logging
-import threading
-from typing import Optional, Dict, Any, Callable
-from dataclasses import dataclass
-import numpy as np
-
-from panorama_camera import PanoramaCamera, ObjectDetector, DetectedObject
-from ptz_camera import PTZCamera
-from coordinator import SequentialCoordinator
-from calibration import CameraCalibrator, CalibrationManager
-from paired_image_saver import PairedImageSaver, get_paired_saver
-from third_party_pusher import get_third_party_pusher
-from config import THIRD_PARTY_CONFIG
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass
-class GroupConfig:
-    """摄像头组配置"""
-    group_id: str
-    name: str
-    panorama_config: Dict[str, Any]
-    ptz_config: Dict[str, Any]
-    calibration_file: str
-    paired_image_dir: str
-
-
-class CameraGroup:
-    """
-    摄像头组封装类
-    
-    封装一组全景+球机摄像头的所有组件,包括:
-    - 全景摄像头实例
-    - 球机实例
-    - 校准器
-    - 联动控制器
-    - 配对图片保存器
-    """
-    
-    def __init__(self,
-                 group_config: Dict[str, Any],
-                 sdk,
-                 detector: ObjectDetector,
-                 shared_config: Optional[Dict[str, Any]] = None):
-        """
-        初始化摄像头组
-
-        Args:
-            group_config: 组配置字典
-            sdk: 大华SDK实例(共享)
-            detector: 检测器实例(共享)
-            shared_config: 共享配置(校准配置、联动配置等)
-        """
-        self.group_id = group_config.get('group_id', 'unknown')
-        self.name = group_config.get('name', self.group_id)
-        self.config = group_config
-        self.sdk = sdk
-        self.detector = detector
-        self.shared_config = shared_config or {}
-        
-        # 组件实例
-        self.panorama_camera: Optional[PanoramaCamera] = None
-        self.ptz_camera: Optional[PTZCamera] = None
-        self.calibrator: Optional[CameraCalibrator] = None
-        self.calibration_manager: Optional[CalibrationManager] = None
-        self.coordinator: Optional[SequentialCoordinator] = None
-        self.paired_saver: Optional[PairedImageSaver] = None
-        
-        # 校准数据文件路径
-        self.calibration_file = group_config.get(
-            'calibration_file', 
-            f'/home/admin/dsh/calibration_{self.group_id}.json'
-        )
-        
-        # 配对图片目录
-        self.paired_image_dir = group_config.get(
-            'paired_image_dir',
-            f'/home/admin/dsh/paired_images_{self.group_id}'
-        )
-        
-        # 运行状态
-        self.running = False
-        self.initialized = False
-        
-        # 定时校准
-        self.calibration_thread = None
-        self.calibration_running = False
-        
-        logger.info(f"[{self.group_id}] 摄像头组实例创建: {self.name}")
-    
-    def initialize(self, skip_calibration: bool = False,
-                   force_calibration: bool = False) -> bool:
-        """
-        初始化组内所有组件
-        
-        Args:
-            skip_calibration: 是否跳过校准
-            force_calibration: 是否强制重新校准
-            
-        Returns:
-            是否成功
-        """
-        logger.info(f"[{self.group_id}] 开始初始化摄像头组...")
-        
-        # 1. 初始化全景摄像头
-        panorama_config = self.config.get('panorama', {})
-        panorama_config['group_id'] = self.group_id
-        self.panorama_camera = PanoramaCamera(self.sdk, panorama_config)
-        
-        # 2. 初始化球机
-        ptz_config = self.config.get('ptz', {})
-        ptz_config['group_id'] = self.group_id
-        self.ptz_camera = PTZCamera(self.sdk, ptz_config)
-        
-        # 3. 初始化配对图片保存器(PairedImageSaver 会自动从配置模块读取 OSS 和设备配置)
-        try:
-            # 创建设备配置(包含 group_id,传入覆盖默认配置)
-            device_config = {'group_id': self.group_id}
-            
-            # 使用单例获取配对保存器
-            self.paired_saver = get_paired_saver(
-                base_dir=self.paired_image_dir,
-                time_window=5.0,
-                enable_oss=True,  # 启用 OSS 上传
-                device_config=device_config
-            )
-            
-            # 设置第三方平台推送回调
-            if THIRD_PARTY_CONFIG.get('enabled', False):
-                pusher = get_third_party_pusher()
-                if not pusher.running:
-                    pusher.start()
-                self.paired_saver.set_upload_callback(pusher.report_batch)
-                logger.info(f"[{self.group_id}] 第三方平台推送已启用")
-            
-            logger.info(f"[{self.group_id}] 配对图片保存器初始化成功: {self.paired_image_dir}, "
-                       f"OSS={self.paired_saver.enable_oss}, "
-                       f"device_id={self.paired_saver.device_config.get('device_id', 'N/A')}")
-        except Exception as e:
-            logger.warning(f"[{self.group_id}] 配对图片保存器初始化失败: {e}")
-        
-        # 4. 初始化联动控制器
-        self.coordinator = SequentialCoordinator(
-            self.panorama_camera,
-            self.ptz_camera,
-            self.detector
-        )
-        
-        # 应用顺序模式配置
-        from config import COORDINATOR_CONFIG
-        seq_config = COORDINATOR_CONFIG.get('sequential_mode', {})
-        self.coordinator.set_capture_config(
-            ptz_stabilize_time=seq_config.get('ptz_stabilize_time', 2.5),
-            capture_wait_time=seq_config.get('capture_wait_time', 0.5),
-            return_to_panorama=seq_config.get('return_to_panorama', True),
-            default_pan=seq_config.get('default_pan', 0.0),
-            default_tilt=seq_config.get('default_tilt', 0.0),
-            default_zoom=seq_config.get('default_zoom', 1)
-        )
-        
-        # 设置配对保存器
-        if self.paired_saver:
-            self.coordinator._paired_saver = self.paired_saver
-            self.coordinator._enable_paired_saving = True
-        
-        # 5. 设置回调
-        self._setup_callbacks()
-        
-        logger.info(f"[{self.group_id}] 组件初始化完成")
-        
-        # 6. 执行校准或加载已有校准文件
-        from config import SYSTEM_CONFIG
-        should_calibrate = SYSTEM_CONFIG.get('enable_calibration', True)
-
-        if not should_calibrate:
-            logger.info(f"[{self.group_id}] 自动校准已禁用")
-        elif force_calibration:
-            # 强制重新校准
-            if not self._auto_calibrate(force=True):
-                logger.error(f"[{self.group_id}] 强制校准失败!")
-                return False
-        elif os.path.exists(self.calibration_file) and skip_calibration:
-            # 跳过完整校准,但加载已有校准文件
-            self._try_load_calibration()
-            logger.info(f"[{self.group_id}] 自动校准已跳过,加载已有校准文件")
-        elif not skip_calibration:
-            # 常规启动:auto_calibrate(force=False) 会先尝试加载已有校准
-            if not self._auto_calibrate(force=False):
-                logger.error(f"[{self.group_id}] 自动校准失败!")
-                return False
-        else:
-            logger.info(f"[{self.group_id}] 自动校准已跳过(无校准文件)")
-
-        self.initialized = True
-        return True
-    
-    def _auto_calibrate(self, force: bool = False) -> bool:
-        """
-        执行自动校准
-        
-        Args:
-            force: 是否强制重新校准
-            
-        Returns:
-            是否成功
-        """
-        logger.info(f"[{self.group_id}] 开始自动校准...")
-        
-        # 连接摄像头
-        if not self.panorama_camera.connect():
-            logger.error(f"[{self.group_id}] 连接全景摄像头失败")
-            return False
-        
-        if not self.ptz_camera.connect():
-            logger.error(f"[{self.group_id}] 连接球机失败")
-            self.panorama_camera.disconnect()
-            return False
-        
-        # 启动视频流
-        if not self.panorama_camera.start_stream_rtsp():
-            logger.warning(f"[{self.group_id}] 全景RTSP启动失败,尝试SDK方式...")
-            self.panorama_camera.start_stream()
-        
-        if not self.ptz_camera.start_stream_rtsp():
-            logger.warning(f"[{self.group_id}] 球机RTSP启动失败")
-        
-        # 等待视频流稳定
-        logger.info(f"[{self.group_id}] 等待视频流稳定...")
-        time.sleep(3)
-        
-        # 创建校准器
-        self.calibrator = CameraCalibrator(
-            ptz_camera=self.ptz_camera,
-            get_frame_func=self.panorama_camera.get_frame,
-            detect_marker_func=None,
-            ptz_capture_func=self._capture_ptz_frame
-        )
-
-        # 配置校准参数
-        # 优先使用球机自身配置中的扫描范围,未配置时回退到 CALIBRATION_CONFIG 默认值
-        from config import CALIBRATION_CONFIG
-        overlap_cfg = CALIBRATION_CONFIG.get('overlap_discovery', {})
-        ptz_overlap = getattr(self.ptz_camera, 'ptz_config', {}) or {}
-        self.calibrator.overlap_pan_range = ptz_overlap.get(
-            'overlap_pan_range', overlap_cfg.get('pan_range', (0, 360))
-        )
-        self.calibrator.overlap_tilt_range = ptz_overlap.get(
-            'overlap_tilt_range', overlap_cfg.get('tilt_range', (-20, 40))
-        )
-        self.calibrator.overlap_pan_step = ptz_overlap.get(
-            'overlap_pan_step', overlap_cfg.get('pan_step', 20)
-        )
-        self.calibrator.overlap_tilt_step = ptz_overlap.get(
-            'overlap_tilt_step', overlap_cfg.get('tilt_step', 15)
-        )
-        self.calibrator.stabilize_time = ptz_overlap.get(
-            'overlap_stabilize_time', overlap_cfg.get('stabilize_time', 2.0)
-        )
-
-        # 创建校准管理器(传入校准文件路径)
-        self.calibration_manager = CalibrationManager(
-            self.calibrator,
-            calibration_file=self.calibration_file
-        )
-        
-        # 执行校准
-        result = self.calibration_manager.auto_calibrate(
-            force=force,
-            fallback_on_failure=True
-        )
-        
-        if not result.success:
-            logger.error(f"[{self.group_id}] 校准失败: {result.error_message}")
-            return False
-        
-        logger.info(f"[{self.group_id}] 校准成功! RMS误差: {result.rms_error:.4f}")
-        
-        # 设置校准器到联动控制器
-        if self.coordinator and self.calibrator.is_calibrated():
-            self.coordinator.set_calibrator(self.calibrator)
-        
-        return True
-    
-    def _try_load_calibration(self) -> bool:
-        """尝试加载已有校准文件(无需连接摄像头)"""
-        if not os.path.exists(self.calibration_file):
-            logger.debug(f"[{self.group_id}] 校准文件不存在: {self.calibration_file}")
-            return False
-
-        from calibration import CameraCalibrator
-
-        try:
-            self.calibrator = CameraCalibrator(
-                ptz_camera=self.ptz_camera,
-                get_frame_func=self.panorama_camera.get_frame if hasattr(self.panorama_camera, 'get_frame') else None,
-                ptz_capture_func=self._capture_ptz_frame
-            )
-            if self.calibrator.load_calibration(self.calibration_file):
-                logger.info(f"[{self.group_id}] 校准文件加载成功: {self.calibration_file}")
-                if self.coordinator and self.calibrator.is_calibrated():
-                    self.coordinator.set_calibrator(self.calibrator)
-                return True
-            else:
-                logger.warning(f"[{self.group_id}] 校准文件加载失败: {self.calibration_file}")
-                return False
-        except Exception as e:
-            logger.warning(f"[{self.group_id}] 加载校准文件异常: {e}")
-            return False
-
-    def _capture_ptz_frame(self) -> Optional[np.ndarray]:
-        """从球机抓拍一帧"""
-        if self.ptz_camera is None:
-            return None
-        try:
-            return self.ptz_camera.get_frame()
-        except Exception as e:
-            logger.error(f"[{self.group_id}] 球机抓拍失败: {e}")
-            return None
-    
-    def _setup_callbacks(self):
-        """设置回调函数"""
-        def on_person_detected(person: DetectedObject, frame: np.ndarray):
-            logger.info(f"[{self.group_id}] 检测到人体: 位置={person.center}, 置信度={person.confidence:.2f}")
-
-        if self.coordinator:
-            self.coordinator.on_person_detected = on_person_detected
-    
-    def start(self) -> bool:
-        """
-        启动组内联动
-        
-        Returns:
-            是否成功
-        """
-        if not self.initialized:
-            logger.error(f"[{self.group_id}] 组件未初始化,无法启动")
-            return False
-        
-        logger.info(f"[{self.group_id}] 启动摄像头组...")
-        
-        # 启动联动控制器
-        if self.coordinator:
-            if not self.coordinator.start():
-                logger.error(f"[{self.group_id}] 联动控制器启动失败")
-                return False
-        
-        self.running = True
-        logger.info(f"[{self.group_id}] 摄像头组启动成功")
-        return True
-    
-    def stop(self):
-        """停止组内所有组件"""
-        logger.info(f"[{self.group_id}] 停止摄像头组...")
-        
-        self.running = False
-        self.calibration_running = False
-        
-        # 停止联动控制器
-        if self.coordinator:
-            self.coordinator.stop()
-        
-        # 停止视频流
-        if self.panorama_camera:
-            self.panorama_camera.stop_stream()
-            self.panorama_camera.disconnect()
-        
-        if self.ptz_camera:
-            self.ptz_camera.stop_stream()
-            self.ptz_camera.disconnect()
-        
-        # 关闭配对保存器
-        if self.paired_saver:
-            self.paired_saver.close()
-        
-        # 停止 OSS 上传器和第三方推送器(只在最后一个组停止时执行)
-        try:
-            from oss_uploader import reset_oss_uploader
-            from third_party_pusher import reset_third_party_pusher
-            reset_oss_uploader()
-            reset_third_party_pusher()
-        except Exception as e:
-            logger.debug(f"[{self.group_id}] 清理全局上传器: {e}")
-        
-        logger.info(f"[{self.group_id}] 摄像头组已停止")
-    
-    def get_status(self) -> Dict[str, Any]:
-        """获取组状态"""
-        return {
-            'group_id': self.group_id,
-            'name': self.name,
-            'running': self.running,
-            'initialized': self.initialized,
-            'calibrated': self.calibrator.is_calibrated() if self.calibrator else False,
-            'panorama_connected': self.panorama_camera.is_connected() if self.panorama_camera else False,
-            'ptz_connected': self.ptz_camera.is_connected() if self.ptz_camera else False,
-        }
-    
-    def start_scheduled_calibration(self, interval_hours: int = 24, daily_time: str = '08:00'):
-        """
-        启动定时校准
-        
-        Args:
-            interval_hours: 校准间隔(小时)
-            daily_time: 每日校准时间 (HH:MM)
-        """
-        if self.calibration_thread and self.calibration_thread.is_alive():
-            logger.warning(f"[{self.group_id}] 定时校准已在运行")
-            return
-        
-        self.calibration_running = True
-        self.calibration_thread = threading.Thread(
-            target=self._calibration_scheduler,
-            args=(interval_hours, daily_time),
-            name=f"calibration-{self.group_id}",
-            daemon=True
-        )
-        self.calibration_thread.start()
-        logger.info(f"[{self.group_id}] 定时校准已启动,每日 {daily_time} 执行")
-    
-    def _calibration_scheduler(self, interval_hours: int, daily_time: str):
-        """定时校准调度器"""
-        from datetime import datetime, timedelta
-        import time
-        
-        while self.calibration_running and self.running:
-            try:
-                # 计算下次校准时间
-                now = datetime.now()
-                target_time = datetime.strptime(daily_time, '%H:%M')
-                next_calibration = now.replace(
-                    hour=target_time.hour,
-                    minute=target_time.minute,
-                    second=0,
-                    microsecond=0
-                )
-                
-                if next_calibration <= now:
-                    next_calibration += timedelta(days=1)
-                
-                wait_seconds = (next_calibration - now).total_seconds()
-                logger.info(f"[{self.group_id}] 下次校准时间: {next_calibration} (等待 {wait_seconds/3600:.1f} 小时)")
-                
-                # 等待
-                time.sleep(min(wait_seconds, 60))  # 每分钟检查一次
-                
-                if not self.calibration_running or not self.running:
-                    break
-                
-                # 检查是否到达校准时间
-                if datetime.now() >= next_calibration:
-                    logger.info(f"[{self.group_id}] 开始定时校准...")
-                    self._auto_calibrate(force=True)
-                    
-            except Exception as e:
-                logger.error(f"[{self.group_id}] 定时校准错误: {e}")
-                time.sleep(60)

+ 0 - 79
dual_camera_system/capture_and_detect.py

@@ -1,79 +0,0 @@
-"""
-抓取一枪机实时帧,使用当前配置的人体检测模型检测并保存标记图。
-用于在 RK3588 上快速验证模型输出是否正常。
-"""
-
-import os
-import sys
-import cv2
-import time
-import numpy as np
-from pathlib import Path
-
-sys.path.insert(0, str(Path(__file__).parent))
-
-from panorama_camera import ObjectDetector
-from config import DETECTION_CONFIG
-
-RTSP_URL = 'rtsp://admin:QAZwsx12@192.168.8.2:554/Streaming/Channels/101'
-OUT_DIR = Path('/home/admin/dsh/detection_check')
-OUT_DIR.mkdir(parents=True, exist_ok=True)
-
-
-def main():
-    print(f"加载模型: {DETECTION_CONFIG['model_path']}")
-    detector = ObjectDetector(
-        model_path=DETECTION_CONFIG['model_path'],
-        use_gpu=DETECTION_CONFIG.get('use_gpu', True),
-        model_type=DETECTION_CONFIG.get('model_type', 'auto')
-    )
-
-    print(f"打开 RTSP: {RTSP_URL}")
-    cap = cv2.VideoCapture(RTSP_URL, cv2.CAP_FFMPEG)
-    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
-    if not cap.isOpened():
-        print("无法打开 RTSP 流")
-        return
-
-    # 读取几帧清缓存
-    for _ in range(10):
-        cap.read()
-
-    ret, frame = cap.read()
-    cap.release()
-    if not ret or frame is None:
-        print("未获取到帧")
-        return
-
-    h, w = frame.shape[:2]
-    print(f"帧尺寸: {w}x{h}")
-
-    # 检测
-    dets = detector.detect(frame)
-    persons = [d for d in dets if d.class_name == 'person']
-    print(f"检测到 {len(persons)} 个人 (原始目标 {len(dets)})")
-    for i, p in enumerate(persons):
-        print(f"  {i}: conf={p.confidence:.3f} bbox={p.bbox} center={p.center}")
-
-    # 保存原图
-    orig_path = OUT_DIR / 'capture_original.jpg'
-    cv2.imwrite(str(orig_path), frame)
-
-    # 绘制标记图
-    marked = frame.copy()
-    for p in persons:
-        x, y, bw, bh = p.bbox
-        cv2.rectangle(marked, (x, y), (x + bw, y + bh), (0, 255, 0), 2)
-        label = f"person {p.confidence:.2f}"
-        cv2.putText(marked, label, (x, max(y - 5, 20)),
-                    cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2)
-
-    marked_path = OUT_DIR / 'capture_marked.jpg'
-    cv2.imwrite(str(marked_path), marked)
-    print(f"已保存: {orig_path}, {marked_path}")
-
-    detector.release()
-
-
-if __name__ == '__main__':
-    main()

+ 0 - 37
dual_camera_system/config.py

@@ -1,37 +0,0 @@
-"""
-双摄像头联动系统配置文件
-
-注意: 此文件已重构为模块化配置
-实际配置定义在 config/ 子目录中
-此文件保留用于向后兼容
-"""
-
-# 从模块化配置导入所有配置
-from config.camera import (
-    LOG_CONFIG,
-    PANORAMA_CAMERA, PTZ_CAMERA, SDK_PATH,
-)
-from config.detection import DETECTION_CONFIG
-from config.ptz import PTZ_CONFIG
-from config.coordinator import COORDINATOR_CONFIG, CALIBRATION_CONFIG
-from config.tracking import TRACKING_CONFIG
-from config.event import EVENT_PUSHER_CONFIG, EVENT_LISTENER_CONFIG
-from config.system import SYSTEM_CONFIG
-
-
-__all__ = [
-    # 日志
-    'LOG_CONFIG',
-    # 摄像头
-    'PANORAMA_CAMERA', 'PTZ_CAMERA', 'SDK_PATH',
-    # 检测
-    'DETECTION_CONFIG',
-    # PTZ
-    'PTZ_CONFIG',
-    # 联动、校准与跟踪
-    'COORDINATOR_CONFIG', 'CALIBRATION_CONFIG', 'TRACKING_CONFIG',
-    # 事件
-    'EVENT_PUSHER_CONFIG', 'EVENT_LISTENER_CONFIG',
-    # 系统
-    'SYSTEM_CONFIG',
-]

+ 4 - 20
dual_camera_system/config/__init__.py

@@ -5,40 +5,24 @@
 
 from .camera import (
     PANORAMA_CAMERA, PTZ_CAMERA, SDK_PATH,
-    LOG_CONFIG, CAMERA_GROUPS, get_enabled_groups
+    CAMERA_GROUPS, get_enabled_groups
 )
 from .detection import DETECTION_CONFIG
-from .tracking import TRACKING_CONFIG
 from .ptz import PTZ_CONFIG
-from .coordinator import COORDINATOR_CONFIG, CALIBRATION_CONFIG
-from .event import EVENT_PUSHER_CONFIG, EVENT_LISTENER_CONFIG
 from .system import SYSTEM_CONFIG
-from .oss import S3_COMPATIBLE_CONFIG
-from .device import (
-    DEVICE_CONFIG, THIRD_PARTY_CONFIG, BATCH_REPORT_CONFIG, PAIRED_IMAGE_CONFIG
-)
+from .device import DEVICE_CONFIG, THIRD_PARTY_CONFIG
 
 
-# 导出所有配置 (保持向后兼容)
+# 导出活跃代码实际使用的配置
 __all__ = [
-    # 日志
-    'LOG_CONFIG',
     # 摄像头
     'PANORAMA_CAMERA', 'PTZ_CAMERA', 'SDK_PATH', 'CAMERA_GROUPS', 'get_enabled_groups',
     # 检测
     'DETECTION_CONFIG',
-    # 跟踪
-    'TRACKING_CONFIG',
     # PTZ
     'PTZ_CONFIG',
-    # 联动与校准
-    'COORDINATOR_CONFIG', 'CALIBRATION_CONFIG',
-    # 事件
-    'EVENT_PUSHER_CONFIG', 'EVENT_LISTENER_CONFIG',
     # 系统
     'SYSTEM_CONFIG',
-    # OSS
-    'S3_COMPATIBLE_CONFIG',
     # 设备与第三方平台
-    'DEVICE_CONFIG', 'THIRD_PARTY_CONFIG', 'BATCH_REPORT_CONFIG', 'PAIRED_IMAGE_CONFIG',
+    'DEVICE_CONFIG', 'THIRD_PARTY_CONFIG',
 ]

+ 2 - 56
dual_camera_system/config/coordinator.py

@@ -1,60 +1,6 @@
-"""
-联动控制配置
-"""
+"""联动协调配置(已简化为扫描轮询参数)."""
 
 COORDINATOR_CONFIG = {
-    'tracking_timeout': 5.0,
-    'min_person_size': 50,
-    'max_tracking_targets': 3,
-    # detection_fps 已移至 DETECTION_CONFIG,此处不再重复定义
+    'ptz_stabilize_time': 1.5,
     'ptz_command_cooldown': 0.5,
-    'ptz_position_threshold': 0.03,
-    
-    # 目标选择策略配置
-    'target_selection': {
-        'strategy': 'area',           # 策略: 'area'(面积优先), 'confidence'(置信度优先), 'hybrid'(混合)
-        'area_weight': 0.6,           # 混合模式下面积权重
-        'confidence_weight': 0.4,     # 混合模式下置信度权重
-        'min_area_threshold': 5000,   # 最小面积阈值(像素²),小于此值的目标优先级降低
-        'prefer_center': True,        # 是否优先选择靠近画面中心的目标
-        'center_weight': 0.2,         # 中心位置权重
-        'switch_on_lost': True,       # 当前目标丢失时是否切换到次优目标
-        'stickiness': 0.3,            # 目标粘性(0-1),值越大越不容易切换目标
-    },
-    
-    # 顺序抓拍模式配置
-    'sequential_mode': {
-        'enabled': True,              # 是否启用顺序抓拍模式
-        'ptz_stabilize_time': 2.5,    # PTZ到位后稳定等待时间(秒) - 增加以确保画面清晰
-        'capture_wait_time': 0.5,     # 抓拍间隔时间(秒)
-        'auto_zoom_wait_time': 1.0,   # AutoZoom变焦后额外等待时间(秒) - 等待镜头对焦
-        'return_to_panorama': True,   # 完成后是否回到全景默认位置
-        'default_pan': 0.0,           # 默认pan角度
-        'default_tilt': 0.0,          # 默认tilt角度
-        'default_zoom': 1,            # 默认zoom(广角,1=最小变焦)
-    },
 }
-
-CALIBRATION_CONFIG = {
-    'interval': 24 * 60 * 60,        # 校准间隔(秒),默认24小时
-    'enable_scheduled_calibration': False,  # 是否启用定时校准
-    'daily_calibration_time': '08:00',  # 每日自动校准时间 (HH:MM格式)
-    'quick_mode': True,
-    'auto_save': True,
-    'load_on_startup': True,         # 启动时是否加载上次保存的校准数据
-    'force_daily_recalibration': True,  # 每日校准是否强制重新校准(不使用已有数据)
-    'min_valid_points': 4,
-    'rms_error_threshold': 5.0,
-    # 校准结果保存路径
-    'calibration_file': '/home/admin/dsh/calibration.json',
-    'overlap_discovery': {
-        'pan_range': (0, 360),
-        'tilt_range': (-20, 50),
-        'pan_step': 20,
-        'tilt_step': 15,
-        'min_match_threshold': 8,
-        'stabilize_time': 2.0,
-        'max_overlap_ranges': 3,
-        'min_positions_per_range': 3,
-    },
-}

+ 3 - 0
dual_camera_system/config/device.py

@@ -19,6 +19,9 @@ DEVICE_CONFIG = {
     
     # 项目密钥(用于接口鉴权)
     'project_secret': '',
+
+    # API Key(用于 Web 控制接口鉴权;留空则不鉴权,保持本地开发兼容)
+    'api_key': '',
 }
 
 # 第三方平台接口配置

+ 0 - 7
dual_camera_system/config/ptz.py

@@ -37,13 +37,6 @@ PTZ_CONFIG = {
     'pan_flip': False,
     'tilt_flip': False,
 
-    # tilt 线性映射参数(用于 CameraCalibrator.transform)
-    # 设备端 ceiling 已处理,代码层按 wall:y=0 略向上,y=1 略向下
-    'tilt_linear_enabled': True,
-    'tilt_y0': 13,                   # 经 RK3588 实测微调:y=0(画面上方/远处)略向上,使人头/上半身不被切出画面
-    'tilt_y1': -5,
-    'tilt_curve_power': 0.8,
-
     # 自动校准扫描范围(完整 360° 粗扫 + 每步由下朝上细扫)
     'overlap_pan_range': (0, 360),     # 完整水平扫描
     'overlap_tilt_range': (-35, 45),   # 更广的 tilt 范围,覆盖竖装+倒影场景

+ 5 - 25
dual_camera_system/config/system.py

@@ -1,28 +1,8 @@
-"""
-系统配置
-"""
+"""系统级功能开关."""
 
-# 系统配置
 SYSTEM_CONFIG = {
-    'name': '施工现场安全行为智能识别系统',
-    'version': '2.0.0',
-
-    # === 功能开关 ===
-    # 摄像头模块
-    'enable_panorama_camera': True,      # 启用全景摄像头
-    'enable_ptz_camera': True,           # 启用 PTZ 球机
-
-    # 检测模块
-    'enable_detection': True,            # 启用人体检测 (YOLO)
-
-    # 联动与校准
-    'enable_calibration': True,          # 启用自动校准
-    'enable_ptz_tracking': True,         # 启用 PTZ 跟踪联动
-
-    # 事件推送
-    'enable_event_push': False,           # 启用事件推送
-
-    # === 工作模式 ===
-    'tracking_mode': 'polling',          # 'polling'(默认) | 'async' | 'sequential'
-                                         # 注:async/sequential 为历史 OCR 联动模式,当前默认已移除 OCR
+    'enable_panorama_camera': True,
+    'enable_ptz_camera': True,
+    'enable_detection': True,
+    'enable_event_push': False,
 }

+ 13 - 6
dual_camera_system/config/tracking.py

@@ -1,5 +1,4 @@
-"""
-跟踪与轮询抓拍配置
+"""跟踪与轮询抓拍配置
 
 说明:
 - Darwin/macOS 下的 model_path 指向 /Users/wenhongquan/Desktop/阿里云同步/项目/dnn/sb/model,
@@ -8,7 +7,6 @@
 
 import platform
 
-from .coordinator import COORDINATOR_CONFIG
 from .ptz import PTZ_CONFIG
 
 
@@ -58,8 +56,8 @@ TRACKING_CONFIG = {
 
     # 跟踪器
     'tracker_type': 'bytetrack',           # 'bytetrack' | 'botsort'
-    'max_tracking_targets': COORDINATOR_CONFIG['max_tracking_targets'],
-    'tracking_timeout': COORDINATOR_CONFIG['tracking_timeout'],
+    'max_tracking_targets': 3,
+    'tracking_timeout': 5.0,
     'conf_threshold': 0.5,
     'person_threshold': 0.5,
     'max_lost': 30,                        # Tracker 内部参数:跟踪器允许目标丢失多少帧后仍保留 ID
@@ -77,5 +75,14 @@ TRACKING_CONFIG = {
     'enable_upload': True,                 # 跟踪抓拍独立上传开关
 
     # 目标选择(淘汰策略)
-    'target_selection': COORDINATOR_CONFIG['target_selection'],
+    'target_selection': {
+        'strategy': 'area',           # 策略: 'area'(面积优先), 'confidence'(置信度优先), 'hybrid'(混合)
+        'area_weight': 0.6,           # 混合模式下面积权重
+        'confidence_weight': 0.4,     # 混合模式下置信度权重
+        'min_area_threshold': 5000,   # 最小面积阈值(像素²),小于此值的目标优先级降低
+        'prefer_center': True,        # 是否优先选择靠近画面中心的目标
+        'center_weight': 0.2,         # 中心位置权重
+        'switch_on_lost': True,       # 当前目标丢失时是否切换到次优目标
+        'stickiness': 0.3,            # 目标粘性(0-1),值越大越不容易切换目标
+    },
 }

+ 0 - 2154
dual_camera_system/coordinator.py

@@ -1,2154 +0,0 @@
-"""
-联动控制器
-协调全景摄像头和球机的工作
-"""
-
-import time
-import threading
-import queue
-import logging
-import math
-from typing import Optional, List, Dict, Tuple, Callable
-from dataclasses import dataclass, field
-from enum import Enum
-
-import numpy as np
-import cv2
-
-from config import COORDINATOR_CONFIG, SYSTEM_CONFIG, PTZ_CONFIG, DETECTION_CONFIG
-from panorama_camera import PanoramaCamera, ObjectDetector, DetectedObject
-from ptz_camera import PTZCamera, PTZController
-from ptz_person_tracker import PTZPersonDetector, PTZAutoZoomController
-from paired_image_saver import PairedImageSaver, get_paired_saver, PersonInfo
-
-logger = logging.getLogger(__name__)
-
-
-class TrackingState(Enum):
-    """跟踪状态"""
-    IDLE = 0           # 空闲
-    SEARCHING = 1      # 搜索目标
-    TRACKING = 2       # 跟踪中
-    ZOOMING = 3        # 变焦中
-
-
-@dataclass
-class TrackingTarget:
-    """跟踪目标"""
-    track_id: int                    # 跟踪ID
-    position: Tuple[float, float]    # 位置比例 (x_ratio, y_ratio)
-    last_update: float              # 最后更新时间
-    person_info: Optional[dict] = None  # 人员信息
-    priority: int = 0               # 优先级
-    area: int = 0                   # 目标面积(像素²)
-    confidence: float = 0.0         # 置信度
-    center_distance: float = 1.0    # 到画面中心的距离比例(0-1)
-    score: float = 0.0              # 综合得分
-
-
-class TargetSelector:
-    """
-    目标选择策略类
-    支持按面积、置信度、混合模式排序,支持优先级切换
-    """
-    
-    def __init__(self, config: Dict = None):
-        """
-        初始化目标选择器
-        Args:
-            config: 目标选择配置
-        """
-        self.config = config or {
-            'strategy': 'area',
-            'area_weight': 0.6,
-            'confidence_weight': 0.4,
-            'min_area_threshold': 5000,
-            'prefer_center': True,
-            'center_weight': 0.2,
-            'switch_on_lost': True,
-            'stickiness': 0.3,
-        }
-        self.current_target_id: Optional[int] = None
-        self.current_target_score: float = 0.0
-    
-    def calculate_score(self, target: TrackingTarget, frame_size: Tuple[int, int] = None) -> float:
-        """
-        计算目标综合得分
-        Args:
-            target: 跟踪目标
-            frame_size: 帧尺寸(w, h),用于计算中心距离
-        Returns:
-            综合得分(0-1)
-        """
-        strategy = self.config.get('strategy', 'area')
-        area_weight = self.config.get('area_weight', 0.6)
-        conf_weight = self.config.get('confidence_weight', 0.4)
-        min_area = self.config.get('min_area_threshold', 5000)
-        prefer_center = self.config.get('prefer_center', False)
-        center_weight = self.config.get('center_weight', 0.2)
-        
-        # 归一化面积得分 (对数缩放,避免大目标得分过高)
-        import math
-        area_score = min(1.0, math.log10(max(target.area, 1)) / 5.0)  # 100000像素² ≈ 1.0
-        
-        # 小面积惩罚
-        if target.area < min_area:
-            area_score *= 0.5
-        
-        # 置信度得分直接使用
-        conf_score = target.confidence
-        
-        # 中心距离得分 (距离中心越近得分越高)
-        center_score = 1.0 - target.center_distance
-        
-        # 根据策略计算综合得分
-        if strategy == 'area':
-            score = area_score * 0.8 + conf_score * 0.2
-        elif strategy == 'confidence':
-            score = conf_score * 0.8 + area_score * 0.2
-        else:  # hybrid
-            score = area_score * area_weight + conf_score * conf_weight
-        
-        # 加入中心距离权重
-        if prefer_center:
-            score = score * (1 - center_weight) + center_score * center_weight
-        
-        return score
-    
-    def select_target(self, targets: Dict[int, TrackingTarget], 
-                       frame_size: Tuple[int, int] = None) -> Optional[TrackingTarget]:
-        """
-        从多个目标中选择最优目标
-        Args:
-            targets: 目标字典 {track_id: TrackingTarget}
-            frame_size: 帧尺寸
-        Returns:
-            最优目标
-        """
-        if not targets:
-            self.current_target_id = None
-            return None
-        
-        stickiness = self.config.get('stickiness', 0.3)
-        switch_on_lost = self.config.get('switch_on_lost', True)
-        
-        # 计算所有目标得分
-        scored_targets = []
-        for track_id, target in targets.items():
-            target.score = self.calculate_score(target, frame_size)
-            scored_targets.append((track_id, target, target.score))
-        
-        # 按得分排序
-        scored_targets.sort(key=lambda x: x[2], reverse=True)
-        
-        # 检查当前目标是否仍在列表中
-        if self.current_target_id is not None:
-            current_exists = self.current_target_id in targets
-            if current_exists:
-                # 应用粘性:当前目标得分需要显著低于最优目标才切换
-                best_id, best_target, best_score = scored_targets[0]
-                current_target = targets[self.current_target_id]
-                
-                # 粘性阈值: 当前目标得分 > 最优得分 * (1 - stickiness) 时保持
-                stickiness_threshold = best_score * (1 - stickiness)
-                if current_target.score > stickiness_threshold:
-                    return current_target
-        
-        
-        # 选择得分最高的目标
-        best_id, best_target, best_score = scored_targets[0]
-        self.current_target_id = best_id
-        self.current_target_score = best_score
-        
-        logger.debug(
-            f"[目标选择] 选择目标ID={best_id} 得分={best_score:.3f} "
-            f"面积={best_target.area} 置信度={best_target.confidence:.2f}"
-        )
-        
-        return best_target
-    
-    def get_sorted_targets(self, targets: Dict[int, TrackingTarget],
-                            frame_size: Tuple[int, int] = None) -> List[Tuple[TrackingTarget, float]]:
-        """
-        获取按得分排序的目标列表
-        Args:
-            targets: 目标字典
-            frame_size: 帧尺寸
-        Returns:
-            排序后的目标列表 [(target, score), ...]
-        """
-        scored = []
-        for target in targets.values():
-            target.score = self.calculate_score(target, frame_size)
-            scored.append((target, target.score))
-        
-        scored.sort(key=lambda x: x[1], reverse=True)
-        return scored
-    
-    def set_strategy(self, strategy: str):
-        """设置选择策略"""
-        self.config['strategy'] = strategy
-        logger.info(f"[目标选择] 策略已切换为: {strategy}")
-    
-    def set_stickiness(self, stickiness: float):
-        """设置目标粘性"""
-        self.config['stickiness'] = max(0.0, min(1.0, stickiness))
-        logger.info(f"[目标选择] 粘性已设置为: {self.config['stickiness']}")
-
-
-class Coordinator:
-    """
-    联动控制器
-    协调全景摄像头和球机实现联动抓拍
-    """
-    
-    def __init__(self, panorama_camera: PanoramaCamera,
-                 ptz_camera: PTZCamera,
-                 detector: ObjectDetector = None,
-                 calibrator = None):
-        """
-        初始化联动控制器
-        Args:
-            panorama_camera: 全景摄像头
-            ptz_camera: 球机
-            detector: 物体检测器
-            calibrator: 校准器 (用于坐标转换)
-        """
-        self.panorama = panorama_camera
-        self.ptz = ptz_camera
-        self.detector = detector
-        self.calibrator = calibrator
-        
-        self.config = COORDINATOR_CONFIG
-        
-        # 功能开关 - 从 SYSTEM_CONFIG 读取
-        self.enable_ptz_camera = SYSTEM_CONFIG.get('enable_ptz_camera', True)
-        self.enable_ptz_tracking = SYSTEM_CONFIG.get('enable_ptz_tracking', True)
-        self.enable_calibration = SYSTEM_CONFIG.get('enable_calibration', True)
-        self.enable_detection = SYSTEM_CONFIG.get('enable_detection', True)
-        
-        # 球机端人体检测与自动对焦
-        self.enable_ptz_detection = PTZ_CONFIG.get('enable_ptz_detection', False)
-        self.auto_zoom_config = PTZ_CONFIG.get('auto_zoom', {})
-        self.ptz_detector = None
-        self.auto_zoom_controller = None
-        
-        # 状态
-        self.state = TrackingState.IDLE
-        self.state_lock = threading.Lock()
-        
-        # 跟踪目标
-        self.tracking_targets: Dict[int, TrackingTarget] = {}
-        self.targets_lock = threading.Lock()
-        
-        # 当前跟踪目标
-        self.current_target: Optional[TrackingTarget] = None
-        
-        # 回调函数
-        self.on_person_detected: Optional[Callable] = None
-        self.on_tracking_started: Optional[Callable] = None
-        self.on_tracking_stopped: Optional[Callable] = None
-        
-        # 控制标志
-        self.running = False
-        self._paused = False
-        self._paused_event = threading.Event()
-        self._paused_event.set()  # 默认非暂停状态
-        self.coordinator_thread = None
-        
-        # PTZ优化 - 避免频繁发送相同位置的命令
-        self.last_ptz_position = None
-        self.ptz_position_threshold = self.config.get('ptz_position_threshold', 0.03)
-        
-        # 目标选择器
-        self.target_selector = TargetSelector(
-            self.config.get('target_selection', {})
-        )
-        
-        # 结果队列
-        self.result_queue = queue.Queue()
-        
-        # ByteTrack 跟踪器(基于 Ultralytics BYTETracker)
-        self.byte_tracker = None
-        self._byte_tracker_init_lock = threading.Lock()
-
-        # 跨帧跟踪:全局track_id计数器
-        self._next_track_id = 1
-        self._track_id_lock = threading.Lock()
-
-        # 性能统计
-        self.stats = {
-            'frames_processed': 0,
-            'persons_detected': 0,
-            'start_time': None,
-            'last_frame_time': None,
-        }
-        self.stats_lock = threading.Lock()
-    
-    def set_calibrator(self, calibrator):
-        """设置校准器"""
-        self.calibrator = calibrator
-
-    def pause_detection(self):
-        """暂停检测(校准时使用,线程不退出,仅跳过检测逻辑)"""
-        self._paused = True
-        self._paused_event.clear()
-        logger.info("[协调器] 检测已暂停")
-
-    def resume_detection(self):
-        """恢复检测(校准完成后恢复)"""
-        self._paused = False
-        self._paused_event.set()
-        logger.info("[协调器] 检测已恢复")
-
-    def is_paused(self) -> bool:
-        """检测是否暂停"""
-        return self._paused
-    
-    def _transform_position(self, x_ratio: float, y_ratio: float) -> Tuple[float, float, int]:
-        """
-        将全景坐标转换为PTZ角度
-        Args:
-            x_ratio: X方向比例
-            y_ratio: Y方向比例
-        Returns:
-            (pan, tilt, zoom)
-        """
-        if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
-            # 使用校准结果进行转换(tilt偏移已在calibrator.transform中应用)
-            pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
-            zoom = 8  # 默认变倍
-        else:
-            # 使用默认估算
-            pan, tilt, zoom = self.ptz.calculate_ptz_position(x_ratio, y_ratio)
-        
-        return (pan, tilt, zoom)
-    
-    def start(self) -> bool:
-        """
-        启动联动系统
-        Returns:
-            是否成功
-        """
-        # 连接全景摄像头
-        if not self.panorama.connect():
-            print("连接全景摄像头失败")
-            return False
-        
-        # 连接 PTZ 球机 (可选)
-        if self.enable_ptz_camera:
-            if not self.ptz.connect():
-                print("连接球机失败")
-                self.panorama.disconnect()
-                return False
-        else:
-            print("PTZ 球机功能已禁用")
-        
-        # 启动视频流(优先RTSP,SDK回调不可用时回退)
-        if not self.panorama.start_stream_rtsp():
-            print("RTSP视频流启动失败,尝试SDK方式...")
-            if not self.panorama.start_stream():
-                print("启动视频流失败")
-                self.panorama.disconnect()
-                if self.enable_ptz_camera:
-                    self.ptz.disconnect()
-                return False
-        
-        # 启动联动线程
-        self.running = True
-        self.coordinator_thread = threading.Thread(target=self._coordinator_worker, daemon=True)
-        self.coordinator_thread.start()
-        
-        print("联动系统已启动")
-        return True
-    
-    def stop(self):
-        """停止联动系统"""
-        self.running = False
-        
-        if self.coordinator_thread:
-            self.coordinator_thread.join(timeout=3)
-        
-        self.panorama.disconnect()
-        
-        if self.enable_ptz_camera:
-            self.ptz.disconnect()
-        
-        # 清理 BYTETracker
-        self.byte_tracker = None
-        
-        # 打印统计信息
-        self._print_stats()
-        
-        print("联动系统已停止")
-    
-    def _update_stats(self, key: str, value: int = 1):
-        """更新统计信息"""
-        with self.stats_lock:
-            if key in self.stats:
-                self.stats[key] += value
-    
-    def _print_stats(self):
-        """打印统计信息"""
-        with self.stats_lock:
-            if self.stats['start_time'] and self.stats['frames_processed'] > 0:
-                elapsed = time.time() - self.stats['start_time']
-                fps = self.stats['frames_processed'] / elapsed
-                print("\n=== 性能统计 ===")
-                print(f"运行时长: {elapsed:.1f}秒")
-                print(f"处理帧数: {self.stats['frames_processed']}")
-                print(f"平均帧率: {fps:.1f} fps")
-                print(f"检测人体: {self.stats['persons_detected']}次")
-                print("================\n")
-    
-    def get_stats(self) -> dict:
-        """获取统计信息"""
-        with self.stats_lock:
-            return self.stats.copy()
-    
-    def _coordinator_worker(self):
-        """联动工作线程"""
-        # 暂停时阻塞等待恢复,不消耗CPU
-        self._paused_event.wait()
-
-        last_detection_time = 0
-        # 从 DETECTION_CONFIG 获取检测帧率,默认每秒2帧
-        detection_fps = self.config.get('detection_fps', DETECTION_CONFIG.get('detection_fps', 2))
-        detection_interval = 1.0 / detection_fps  # 根据FPS计算间隔
-        
-        # 初始化统计
-        with self.stats_lock:
-            self.stats['start_time'] = time.time()
-        
-        while self.running:
-            try:
-                current_time = time.time()
-                
-                # 获取当前帧
-                frame = self.panorama.get_frame()
-                if frame is None:
-                    time.sleep(0.01)
-                    continue
-                
-                # 更新帧统计
-                self._update_stats('frames_processed')
-                
-                frame_size = (frame.shape[1], frame.shape[0])
-                
-                # 周期性检测(暂停时跳过)
-                if not self._paused and current_time - last_detection_time >= detection_interval:
-                    last_detection_time = current_time
-                    
-                    # 检测人体
-                    detections = self._detect_persons(frame)
-                    # 使用 BYTETracker 进行跟踪(失败时回退到位置匹配)
-                    detections = self._update_with_bytetrack(detections, frame, frame_size)
-                    
-                    # 更新检测统计
-                    if detections:
-                        self._update_stats('persons_detected', len(detections))
-                    
-                    # 处理检测结果
-                    if detections:
-                        self._process_detections(detections, frame, frame_size)
-                
-                # 处理当前跟踪目标(暂停时跳过PTZ控制)
-                if not self._paused:
-                    self._process_current_target(frame, frame_size)
-                
-                # 清理过期目标
-                self._cleanup_expired_targets()
-                
-                time.sleep(0.01)
-                
-            except Exception as e:
-                print(f"联动处理错误: {e}")
-                time.sleep(0.1)
-    
-    def _init_byte_tracker(self):
-        """初始化 BYTETracker"""
-        with self._byte_tracker_init_lock:
-            if self.byte_tracker is not None:
-                return
-            try:
-                from ultralytics.trackers.byte_tracker import BYTETracker
-                import types
-                self._bt_args = types.SimpleNamespace(
-                    track_high_thresh=0.5,
-                    track_low_thresh=0.1,
-                    new_track_thresh=0.3,
-                    match_thresh=0.8,
-                    fuse_score=False,
-                    track_buffer=30,
-                    mot20=False,
-                )
-                self.byte_tracker = BYTETracker(args=self._bt_args)
-                logger.info("[跟踪] BYTETracker 初始化成功")
-            except Exception as e:
-                logger.warning(f"[跟踪] BYTETracker 初始化失败: {e},将使用简化位置匹配跟踪")
-                self.byte_tracker = None
-
-    def _update_with_bytetrack(self, detections: List[DetectedObject],
-                                frame: np.ndarray,
-                                frame_size: Tuple[int, int]) -> List[DetectedObject]:
-        """
-        使用 ObjectDetector + BYTETracker 进行跟踪
-        ByteTrack 失败时回退到旧位置匹配
-        """
-        conf_thr = DETECTION_CONFIG.get('confidence_threshold', 0.35)
-        person_dets = [d for d in detections if d.class_name == 'person'
-                       and d.confidence >= conf_thr]
-
-        self._init_byte_tracker()
-        if self.byte_tracker is not None and person_dets:
-            try:
-                import torch
-                dets_t = torch.tensor([[d.bbox[0], d.bbox[1], d.bbox[0]+d.bbox[2], d.bbox[1]+d.bbox[3], d.confidence, 0] for d in person_dets], dtype=torch.float32)
-                class _R:
-                    def __init__(s, x):
-                        s._raw = x
-                        s.xywh = torch.stack([(x[:,0]+x[:,2])/2,(x[:,1]+x[:,3])/2,x[:,2]-x[:,0],x[:,3]-x[:,1]], dim=-1)
-                        s.conf = x[:, 4]; s.cls = x[:, 5].long()
-                    def __getitem__(s, i): return _R(s._raw[i])
-                    def __len__(s): return len(s.conf)
-                tracks = self.byte_tracker.update(_R(dets_t), None)
-                if tracks is not None and len(tracks) > 0:
-                    frame_w, frame_h = frame_size
-                    cx_, cy_ = frame_w / 2, frame_h / 2
-                    now = time.time()
-                    with self.targets_lock:
-                        self.tracking_targets.clear()
-                        for tr in tracks:
-                            tx1, ty1, tx2, ty2, tid, tsc = int(tr[0]), int(tr[1]), int(tr[2]), int(tr[3]), int(tr[4]), float(tr[5])
-                            cxc, cyc = (tx1+tx2)//2, (ty1+ty2)//2
-                            self.tracking_targets[tid] = TrackingTarget(
-                                track_id=tid, position=(cxc/frame_w, cyc/frame_h),
-                                last_update=now, area=(tx2-tx1)*(ty2-ty1),
-                                confidence=tsc, center_distance=(abs(cxc-cx_)/cx_ + abs(cyc-cy_)/cy_)/2 if cx_>0 else 0)
-                        # IOU match
-                        import numpy as np
-                        for tr in np.array(tracks):
-                            tx1, ty1, tx2, ty2, tid = int(tr[0]), int(tr[1]), int(tr[2]), int(tr[3]), int(tr[4])
-                            best = None
-                            for d in person_dets:
-                                dx1, dy1, dw, dh = d.bbox; dx2, dy2 = dx1+dw, dy1+dh
-                                ix1, iy1 = max(tx1,dx1), max(ty1,dy1); ix2, iy2 = min(tx2,dx2), min(ty2,dy2)
-                                if ix1<ix2 and iy1<iy2 and ((ix2-ix1)*(iy2-iy1))/((tx2-tx1)*(ty2-ty1)+dw*dh-(ix2-ix1)*(iy2-iy1)+1e-6) > 0.3:
-                                    best = d
-                            if best is not None:
-                                best.track_id = tid
-                    return [d for d in person_dets if d.track_id is not None]
-            except Exception as e:
-                logger.warning(f"[跟踪] ByteTrack 执行异常: {e}")
-
-        # ByteTrack 不可用/无结果 → 回退旧位置匹配
-        self._update_tracking_targets(detections, frame_size)
-        return detections
-
-    def _detect_persons(self, frame: np.ndarray) -> List[DetectedObject]:
-        """检测人体"""
-        if not self.enable_detection or self.detector is None:
-            return []
-        return self.detector.detect_persons(frame)
-    
-    def _update_tracking_targets(self, detections: List[DetectedObject],
-                                  frame_size: Tuple[int, int]):
-        """更新跟踪目标(跨帧匹配,支持粘性跟踪)
-        
-        改进:不再每轮清空目标,而是使用位置匹配关联连续帧的目标
-        """
-        current_time = time.time()
-        frame_w, frame_h = frame_size
-        center_x, center_y = frame_w / 2, frame_h / 2
-        
-        # 获取人员置信度阈值
-        person_threshold = DETECTION_CONFIG.get('person_threshold', 0.8)
-        
-        # 过滤有效人员
-        valid_detections = []
-        low_conf_count = 0
-        for det in detections:
-            if det.class_name != 'person':
-                continue
-            if det.confidence < person_threshold:
-                low_conf_count += 1
-                continue
-            valid_detections.append(det)
-        
-        # 【调试日志】显示过滤结果
-        if detections:
-            logger.info(f"[跟踪] 检测到 {len(detections)} 个目标, 置信度>={person_threshold} 的有 {len(valid_detections)} 个 (过滤掉 {low_conf_count} 个)")
-        
-        if not valid_detections:
-            return
-        
-        with self.targets_lock:
-            # 匹配阈值:位置距离小于此值认为是同一目标
-            MATCH_THRESHOLD = 0.15  # 画面比例
-            
-            # 已匹配的检测索引
-            matched_det_indices = set()
-            
-            # 步骤1:尝试匹配现有目标
-            for track_id, target in list(self.tracking_targets.items()):
-                best_match_idx = None
-                best_match_dist = MATCH_THRESHOLD
-                
-                for idx, det in enumerate(valid_detections):
-                    if idx in matched_det_indices:
-                        continue
-                    
-                    det_x = det.center[0] / frame_w
-                    det_y = det.center[1] / frame_h
-                    
-                    # 计算位置距离
-                    dist = math.sqrt(
-                        (det_x - target.position[0]) ** 2 + 
-                        (det_y - target.position[1]) ** 2
-                    )
-                    
-                    if dist < best_match_dist:
-                        best_match_dist = dist
-                        best_match_idx = idx
-                
-                
-                if best_match_idx is not None:
-                    # 找到匹配,更新目标
-                    det = valid_detections[best_match_idx]
-                    matched_det_indices.add(best_match_idx)
-                    
-                    x_ratio = det.center[0] / frame_w
-                    y_ratio = det.center[1] / frame_h
-                    _, _, width, height = det.bbox
-                    area = width * height
-                    
-                    dx = abs(det.center[0] - center_x) / center_x
-                    dy = abs(det.center[1] - center_y) / center_y
-                    center_distance = (dx + dy) / 2
-                    
-                    # 更新目标属性
-                    self.tracking_targets[track_id] = TrackingTarget(
-                        track_id=track_id,
-                        position=(x_ratio, y_ratio),
-                        last_update=current_time,
-                        area=area,
-                        confidence=det.confidence,
-                        center_distance=center_distance,
-                        person_info=target.person_info  # 保留之前识别的信息
-                    )
-            
-            # 步骤2:为未匹配的检测创建新目标
-            for idx, det in enumerate(valid_detections):
-                if idx in matched_det_indices:
-                    continue
-                
-                x_ratio = det.center[0] / frame_w
-                y_ratio = det.center[1] / frame_h
-                _, _, width, height = det.bbox
-                area = width * height
-                
-                dx = abs(det.center[0] - center_x) / center_x
-                dy = abs(det.center[1] - center_y) / center_y
-                center_distance = (dx + dy) / 2
-                
-                # 分配全局唯一track_id
-                with self._track_id_lock:
-                    new_track_id = self._next_track_id
-                    self._next_track_id += 1
-                
-                
-                det.track_id = new_track_id  # 更新检测对象的track_id
-                
-                self.tracking_targets[new_track_id] = TrackingTarget(
-                    track_id=new_track_id,
-                    position=(x_ratio, y_ratio),
-                    last_update=current_time,
-                    area=area,
-                    confidence=det.confidence,
-                    center_distance=center_distance
-                )
-    
-    def _process_detections(self, detections: List[DetectedObject],
-                            frame: np.ndarray, frame_size: Tuple[int, int]):
-        """处理检测结果"""
-        if self.on_person_detected:
-            for det in detections:
-                self.on_person_detected(det, frame)
-    
-    def _process_current_target(self, frame: np.ndarray, frame_size: Tuple[int, int]):
-        """处理当前跟踪目标"""
-        with self.targets_lock:
-            if not self.tracking_targets:
-                self._set_state(TrackingState.IDLE)
-                self.current_target = None
-                return
-            
-            # 使用目标选择器选择最优目标
-            self.current_target = self.target_selector.select_target(
-                self.tracking_targets, frame_size
-            )
-        
-        if self.current_target:
-            # 移动球机到目标位置 (仅在 PTZ 跟踪启用时)
-            if self.enable_ptz_tracking and self.enable_ptz_camera:
-                self._set_state(TrackingState.TRACKING)
-                
-                x_ratio, y_ratio = self.current_target.position
-                
-                # 检查位置是否变化超过阈值
-                should_move = True
-                if self.last_ptz_position is not None:
-                    last_x, last_y = self.last_ptz_position
-                    if (abs(x_ratio - last_x) < self.ptz_position_threshold and
-                        abs(y_ratio - last_y) < self.ptz_position_threshold):
-                        should_move = False
-                
-                if should_move:
-                    if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
-                        # 校准器返回的是可直接发送给球机的真实 PTZ 角度,不再应用 pan_flip
-                        pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
-                        zoom = self.ptz.ptz_config.get('default_zoom', 8)
-                        self.ptz.goto_exact_position(pan, tilt, zoom)
-                    else:
-                        self.ptz.track_target(x_ratio, y_ratio)
-                    self.last_ptz_position = (x_ratio, y_ratio)
-    
-    def _cleanup_expired_targets(self):
-        """清理过期目标"""
-        current_time = time.time()
-        timeout = self.config['tracking_timeout']
-        
-        with self.targets_lock:
-            expired_ids = [
-                target_id for target_id, target in self.tracking_targets.items()
-                if current_time - target.last_update > timeout
-            ]
-            
-            for target_id in expired_ids:
-                del self.tracking_targets[target_id]
-                if self.current_target and self.current_target.track_id == target_id:
-                    self.current_target = None
-    
-    def _set_state(self, state: TrackingState):
-        """设置状态"""
-        with self.state_lock:
-            self.state = state
-    
-    def get_state(self) -> TrackingState:
-        """获取状态"""
-        with self.state_lock:
-            return self.state
-    
-    def get_results(self) -> List[PersonInfo]:
-        """
-        获取识别结果
-        Returns:
-            人员信息列表
-        """
-        results = []
-        while not self.result_queue.empty():
-            try:
-                results.append(self.result_queue.get_nowait())
-            except queue.Empty:
-                break
-        return results
-    
-    def get_tracking_targets(self) -> List[TrackingTarget]:
-        """获取当前跟踪目标"""
-        with self.targets_lock:
-            return list(self.tracking_targets.values())
-    
-    def force_track_position(self, x_ratio: float, y_ratio: float, zoom: int = None):
-        """
-        强制跟踪指定位置
-        Args:
-            x_ratio: X方向比例
-            y_ratio: Y方向比例
-            zoom: 变倍
-        """
-        if self.enable_ptz_tracking and self.enable_ptz_camera:
-            if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
-                # 校准器返回的是可直接发送给球机的真实 PTZ 角度,不再应用 pan_flip
-                pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
-                self.ptz.goto_exact_position(pan, tilt, zoom or self.ptz.ptz_config.get('default_zoom', 8))
-            else:
-                self.ptz.move_to_target(x_ratio, y_ratio, zoom)
-    
-    def capture_snapshot(self) -> Optional[np.ndarray]:
-        """
-        抓拍快照
-        Returns:
-            快照图像
-        """
-        return self.panorama.get_frame()
-
-
-class EventDrivenCoordinator(Coordinator):
-    """事件驱动联动控制器,当全景摄像头检测到事件时触发联动"""
-    
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.event_types = {
-            'intruder': True,
-            'crossline': True,
-            'motion': True,
-        }
-        self.event_queue = queue.Queue()
-    
-    def on_event(self, event_type: str, event_data: dict):
-        if not self.event_types.get(event_type, False):
-            return
-        self.event_queue.put({'type': event_type, 'data': event_data, 'time': time.time()})
-    
-    def _coordinator_worker(self):
-        while self.running:
-            try:
-                try:
-                    event = self.event_queue.get(timeout=0.1)
-                    self._process_event(event)
-                except queue.Empty:
-                    pass
-                
-                frame = self.panorama.get_frame()
-                if frame is not None:
-                    frame_size = (frame.shape[1], frame.shape[0])
-                    detections = self._detect_persons(frame)
-                    if detections:
-                        # 更新跟踪目标(track_id 在此方法内分配)
-                        self._update_tracking_targets(detections, frame_size)
-                        self._process_current_target(frame, frame_size)
-                
-                self._cleanup_expired_targets()
-            except Exception as e:
-                print(f"事件处理错误: {e}")
-                time.sleep(0.1)
-    
-    def _process_event(self, event: dict):
-        event_type = event['type']
-        event_data = event['data']
-        print(f"处理事件: {event_type}")
-        
-        if event_type == 'intruder' and 'position' in event_data:
-            x_ratio, y_ratio = event_data['position']
-            self.force_track_position(x_ratio, y_ratio)
-
-
-@dataclass
-class PTZCommand:
-    """PTZ控制命令"""
-    pan: float
-    tilt: float
-    zoom: int
-    x_ratio: float = 0.0
-    y_ratio: float = 0.0
-    use_calibration: bool = True
-    track_id: Optional[int] = None  # 跟踪目标ID(用于配对图片保存)
-    batch_id: Optional[str] = None  # 批次ID(用于配对图片保存)
-    person_index: int = -1  # 人员在批次中的序号(用于配对图片保存)
-
-
-class AsyncCoordinator(Coordinator):
-    """
-    异步联动控制器 — 检测线程与PTZ控制线程分离
-    
-    改进:
-    1. 检测线程:持续读取全景帧 + YOLO推理
-    2. PTZ控制线程:通过命令队列接收目标位置,独立控制球机
-    3. 两线程通过 queue 通信,互不阻塞
-    4. PTZ位置确认:移动后等待球机到位并验证帧
-    """
-    
-    PTZ_CONFIRM_WAIT = 0.3          # PTZ命令后等待稳定的秒数
-    PTZ_CONFIRM_TIMEOUT = 2.0       # PTZ位置确认超时
-    PTZ_COMMAND_COOLDOWN = 0.15     # PTZ命令最小间隔秒数
-    
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        
-        # PTZ命令队列(检测→PTZ)
-        self._ptz_queue: queue.Queue = queue.Queue(maxsize=10)
-        
-        # 线程
-        self._detection_thread = None
-        self._ptz_thread = None
-        
-        # PTZ确认回调
-        self._on_ptz_confirmed: Optional[Callable] = None
-        
-        # 上次PTZ命令时间(添加线程锁保护)
-        self._last_ptz_time = 0.0
-        self._last_ptz_time_lock = threading.Lock()
-
-        # 帧获取配置
-        self._frame_config = {
-            'wait_interval': PTZ_CONFIG.get('frame_wait_interval', 0.2),
-            'max_attempts': PTZ_CONFIG.get('frame_max_attempts', 8),
-            'min_clarity': PTZ_CONFIG.get('min_clarity', 200),
-        }
-
-        # 配对图片保存器
-        self._enable_paired_saving = DETECTION_CONFIG.get('enable_paired_saving', False)
-        self._paired_saver: Optional[PairedImageSaver] = None
-        self._current_batch_id: Optional[str] = None
-        self._person_ptz_index: Dict[int, int] = {}  # track_id -> person_index
-        
-        if self._enable_paired_saving:
-            save_dir = DETECTION_CONFIG.get('paired_image_dir', '/home/admin/dsh/paired_images')
-            time_window = DETECTION_CONFIG.get('paired_time_window', 5.0)
-            self._paired_saver = get_paired_saver(base_dir=save_dir, time_window=time_window)
-            logger.info(f"[AsyncCoordinator] 配对图片保存已启用: 目录={save_dir}, 时间窗口={time_window}s")
-    
-    def start(self) -> bool:
-        """启动联动(覆盖父类,启动双线程)"""
-        if not self.panorama.connect():
-            print("连接全景摄像头失败")
-            return False
-        
-        if self.enable_ptz_camera:
-            if not self.ptz.connect():
-                print("连接球机失败")
-                self.panorama.disconnect()
-                return False
-            
-            # 启动球机RTSP流(用于球机端人体检测)
-            if self.enable_ptz_detection:
-                if not self.ptz.start_stream_rtsp():
-                    print("球机RTSP流启动失败,禁用球机端检测功能")
-                    self.enable_ptz_detection = False
-                else:
-                    # 初始化球机端人体检测器
-                    self._init_ptz_detector()
-        else:
-            print("PTZ球机功能已禁用")
-        
-        if not self.panorama.start_stream_rtsp():
-            print("RTSP视频流启动失败,尝试SDK方式...")
-            if not self.panorama.start_stream():
-                print("启动视频流失败")
-                self.panorama.disconnect()
-                if self.enable_ptz_camera:
-                    self.ptz.disconnect()
-                return False
-        
-        self.running = True
-        
-        # 启动检测线程
-        self._detection_thread = threading.Thread(
-            target=self._detection_worker, name="detection-worker", daemon=True)
-        self._detection_thread.start()
-        
-        # 启动PTZ控制线程
-        if self.enable_ptz_camera and self.enable_ptz_tracking:
-            self._ptz_thread = threading.Thread(
-                target=self._ptz_worker, name="ptz-worker", daemon=True)
-            self._ptz_thread.start()
-        
-        print("异步联动系统已启动 (检测线程 + PTZ控制线程)")
-        return True
-    
-    def stop(self):
-        """停止联动"""
-        self.running = False
-        
-        # 清空PTZ队列,让工作线程退出
-        while not self._ptz_queue.empty():
-            try:
-                self._ptz_queue.get_nowait()
-            except queue.Empty:
-                break
-        
-        if self._detection_thread:
-            self._detection_thread.join(timeout=3)
-        if self._ptz_thread:
-            self._ptz_thread.join(timeout=3)
-        
-        # 停止父类线程(如果有的话)
-        if self.coordinator_thread:
-            self.coordinator_thread.join(timeout=1)
-        
-        # 关闭配对保存器
-        if self._paired_saver is not None:
-            self._paired_saver.close()
-            self._paired_saver = None
-        
-        # 清理 BYTETracker
-        self.byte_tracker = None
-        
-        self.panorama.disconnect()
-        if self.enable_ptz_camera:
-            self.ptz.disconnect()
-        
-        self._print_stats()
-        print("异步联动系统已停止")
-    
-    def _detection_worker(self):
-        """检测线程:持续读帧 + YOLO推理 + 发送PTZ命令 + 打印检测日志"""
-        # 暂停时阻塞等待恢复
-        self._paused_event.wait()
-
-        last_detection_time = 0
-        # 从 DETECTION_CONFIG 获取检测帧率,默认每秒2帧
-        detection_fps = self.config.get('detection_fps', DETECTION_CONFIG.get('detection_fps', 2))
-        detection_interval = 1.0 / detection_fps  # 根据FPS计算间隔
-        ptz_cooldown = self.config.get('ptz_command_cooldown', 0.5)
-        ptz_threshold = self.config.get('ptz_position_threshold', 0.03)
-        frame_count = 0
-        last_log_time = time.time()
-        log_interval = 5.0  # 每5秒打印一次帧率统计
-        detection_run_count = 0
-        detection_person_count = 0
-        detection_last_seen = 0
-        last_no_detect_log_time = 0
-        no_detect_log_interval = 30.0
-
-        with self.stats_lock:
-            self.stats['start_time'] = time.time()
-        
-        if self.detector is None:
-            logger.warning("[检测线程] ⚠️ 人体检测器未初始化! 检测功能不可用, 请检查 YOLO 模型是否正确加载")
-        elif not self.enable_detection:
-            logger.warning("[检测线程] ⚠️ 人体检测已禁用 (enable_detection=False)")
-        else:
-            logger.info(f"[检测线程] ✓ 人体检测器已就绪, 检测帧率={detection_fps}fps(间隔={detection_interval:.2f}s), PTZ冷却={ptz_cooldown}s")
-        
-        while self.running:
-            try:
-                current_time = time.time()
-                frame = self.panorama.get_frame()
-                if frame is None:
-                    time.sleep(0.01)
-                    continue
-                
-                frame_count += 1
-                self._update_stats('frames_processed')
-                frame_size = (frame.shape[1], frame.shape[0])
-                
-                if current_time - last_log_time >= log_interval:
-                    elapsed = current_time - last_log_time
-                    fps = frame_count / elapsed if elapsed > 0 else 0
-                    
-                    state_str = self.state.name if hasattr(self.state, 'name') else str(self.state)
-                    stats_parts = [f"帧率={fps:.1f}fps", f"处理帧={frame_count}", f"状态={state_str}"]
-                    
-                    if self.detector is None:
-                        stats_parts.append("检测器=未加载")
-                    elif not self.enable_detection:
-                        stats_parts.append("检测=已禁用")
-                    else:
-                        if detection_last_seen > 0:
-                            ago = int(current_time - detection_last_seen)
-                            stats_parts.append(f"检测轮次={detection_run_count}(最后有人={ago}s前)")
-                        else:
-                            stats_parts.append(f"检测轮次={detection_run_count}(未检出)")
-                    
-                    with self.targets_lock:
-                        target_count = len(self.tracking_targets)
-                    stats_parts.append(f"跟踪目标={target_count}")
-                    
-                    logger.info(f"[检测线程] {', '.join(stats_parts)}")
-                    frame_count = 0
-                    last_log_time = current_time
-                
-                # 周期性检测(暂停时跳过检测和PTZ命令)
-                if not self._paused and current_time - last_detection_time >= detection_interval:
-                    last_detection_time = current_time
-                    detection_run_count += 1
-                    
-                    # YOLO 人体检测
-                    detections = self._detect_persons(frame)
-                    # 使用 BYTETracker 进行跟踪(失败时回退到位置匹配)
-                    detections = self._update_with_bytetrack(detections, frame, frame_size)
-
-                    if detections:
-                        self._update_stats('persons_detected', len(detections))
-                        detection_person_count += 1
-                        detection_last_seen = current_time
-                    
-                    # 配对图片保存:创建新批次
-                    if detections and self._enable_paired_saving and self._paired_saver is not None:
-                        self._create_detection_batch(frame, detections, frame_size)
-                    
-                    # 打印检测日志(使用连续序号,与图片标记一致)
-                    if detections:
-                        person_threshold = DETECTION_CONFIG.get('person_threshold', 0.8)
-                        person_idx = 0
-                        for t in detections:
-                            # detections 是 DetectedObject,使用 center 计算位置
-                            x_ratio = t.center[0] / frame_size[0]
-                            y_ratio = t.center[1] / frame_size[1]
-                            _, _, w, h = t.bbox
-                            area = w * h
-                            # 只对达到阈值的人员打印日志并分配序号
-                            if t.class_name == 'person' and t.confidence >= person_threshold:
-                                logger.info(
-                                    f"[检测] ✓ person_{person_idx} "
-                                    f"位置=({x_ratio:.3f}, {y_ratio:.3f}) "
-                                    f"面积={area} 置信度={t.confidence:.2f}"
-                                )
-                                person_idx += 1
-                            else:
-                                logger.debug(
-                                    f"[检测] · 目标ID={t.track_id}({t.class_name}) "
-                                    f"位置=({x_ratio:.3f}, {y_ratio:.3f}) "
-                                    f"置信度={t.confidence:.2f}(低于阈值{person_threshold})"
-                                )
-                    else:
-                        if current_time - last_no_detect_log_time >= no_detect_log_interval:
-                            logger.info(
-                                f"[检测] · YOLO检测运行正常, 本轮未检测到人员 "
-                                f"(累计检测{detection_run_count}轮, 检测到人{detection_person_count}轮)"
-                            )
-                            last_no_detect_log_time = current_time
-                    
-                    if detections:
-                        self._process_detections(detections, frame, frame_size)
-                    
-                    # 为每个检测到的人发送PTZ命令(不再只选一个)
-                    if self.enable_ptz_tracking and self.enable_ptz_camera:
-                        targets = self._get_all_valid_targets()
-                        for target in targets:
-                            self._send_ptz_command_with_log(target, frame_size)
-                    elif not detections and self.current_target:
-                        # 目标消失,切回IDLE
-                        self._set_state(TrackingState.IDLE)
-                        logger.info("[检测] 目标丢失,球机进入IDLE状态")
-                        self.current_target = None
-                
-                self._cleanup_expired_targets()
-                time.sleep(0.01)
-                
-            except Exception as e:
-                logger.error(f"检测线程错误: {e}")
-                time.sleep(0.1)
-    
-    def _init_ptz_detector(self):
-        """初始化球机端人体检测器"""
-        try:
-            model_path = DETECTION_CONFIG.get('model_path')
-            model_type = DETECTION_CONFIG.get('model_type', 'auto')
-            conf_threshold = DETECTION_CONFIG.get('person_threshold', 0.5)
-            
-            if model_path:
-                self.ptz_detector = PTZPersonDetector(
-                    model_path=model_path,
-                    model_type=model_type,
-                    confidence_threshold=conf_threshold
-                )
-                self.auto_zoom_controller = PTZAutoZoomController(
-                    ptz_camera=self.ptz,
-                    detector=self.ptz_detector,
-                    config=self.auto_zoom_config
-                )
-                print(f"[AsyncCoordinator] 球机端人体检测器初始化成功")
-            else:
-                print("[AsyncCoordinator] 未配置球机检测模型路径,禁用球机端检测")
-                self.enable_ptz_detection = False
-        except Exception as e:
-            print(f"[AsyncCoordinator] 球机端检测器初始化失败: {e}")
-            self.enable_ptz_detection = False
-
-    def _deduplicate_detections(self, detections: List[DetectedObject], 
-                                frame_size: Tuple[int, int]) -> List[DetectedObject]:
-        """
-        去重检测结果(按位置合并重叠的检测框)
-        
-        Args:
-            detections: 检测列表
-            frame_size: 帧尺寸
-            
-        Returns:
-            去重后的人员检测列表
-        """
-        # 过滤有效人员
-        person_threshold = DETECTION_CONFIG.get('person_threshold', 0.5)
-        valid_persons = [d for d in detections 
-                        if d.class_name == 'person' and d.confidence >= person_threshold]
-        
-        if not valid_persons:
-            return []
-        
-        # 去重:按位置合并重叠的检测框
-        DEDUP_DISTANCE = 0.05  # 画面比例 5%
-        dedup_persons = []
-        
-        for det in valid_persons:
-            det_x = det.center[0] / frame_size[0]
-            det_y = det.center[1] / frame_size[1]
-            
-            # 检查是否与已有人员重叠
-            is_duplicate = False
-            for i, existing in enumerate(dedup_persons):
-                ex_x = existing.center[0] / frame_size[0]
-                ex_y = existing.center[1] / frame_size[1]
-                
-                dist = math.sqrt((det_x - ex_x)**2 + (det_y - ex_y)**2)
-                if dist < DEDUP_DISTANCE:
-                    # 重叠,保留置信度更高的
-                    is_duplicate = True
-                    if det.confidence > existing.confidence:
-                        dedup_persons[i] = det
-                    break
-            
-            if not is_duplicate:
-                dedup_persons.append(det)
-        
-        
-        return dedup_persons
-
-    def _create_detection_batch(self, frame: np.ndarray, 
-                                 detections: List[DetectedObject],
-                                 frame_size: Tuple[int, int]) -> List[DetectedObject]:
-        """
-        创建检测批次,用于配对图片保存
-        
-        Args:
-            frame: 全景帧
-            detections: 检测到的人员列表
-            frame_size: 帧尺寸
-            
-        Returns:
-            去重后的人员检测列表
-        """
-        if self._paired_saver is None:
-            return []
-        
-        # 过滤有效人员(必须是 person 且置信度 >= 阈值)
-        person_threshold = DETECTION_CONFIG.get('person_threshold', 0.8)
-        valid_persons = []
-        for det in detections:
-            # 只处理 class_name 为 person 的目标,排除安全帽、反光衣等
-            if det.class_name == 'person' and det.confidence >= person_threshold:
-                valid_persons.append(det)
-        
-        # 【关键修复】去重:按位置合并重叠的检测框
-        # 如果两个检测框的中心距离小于阈值,只保留置信度更高的
-        DEDUP_DISTANCE = 0.05  # 画面比例 5%
-        dedup_persons = []
-        for det in valid_persons:
-            det_x = det.center[0] / frame_size[0]
-            det_y = det.center[1] / frame_size[1]
-            
-            # 检查是否与已有人员重叠
-            is_duplicate = False
-            for i, existing in enumerate(dedup_persons):
-                ex_x = existing.center[0] / frame_size[0]
-                ex_y = existing.center[1] / frame_size[1]
-                
-                dist = math.sqrt((det_x - ex_x)**2 + (det_y - ex_y)**2)
-                if dist < DEDUP_DISTANCE:
-                    # 重叠,保留置信度更高的
-                    is_duplicate = True
-                    if det.confidence > existing.confidence:
-                        dedup_persons[i] = det
-                    break
-            
-            if not is_duplicate:
-                dedup_persons.append(det)
-        
-        
-        if not dedup_persons:
-            logger.debug(f"[配对保存] 无有效人员(阈值={person_threshold}),跳过批次创建")
-            return []
-        
-        
-        logger.info(f"[配对保存] 检测结果去重: {len(valid_persons)} -> {len(dedup_persons)} 个人员")
-        
-        # 构建人员信息列表(只包含去重后的人员)
-        persons = []
-        self._person_ptz_index = {}  # 重置索引映射
-        
-        for i, det in enumerate(dedup_persons):
-            x_ratio = det.center[0] / frame_size[0]
-            y_ratio = det.center[1] / frame_size[1]
-            
-            person_info = {
-                'track_id': det.track_id,
-                'position': (x_ratio, y_ratio),
-                'bbox': (det.bbox[0], det.bbox[1], 
-                        det.bbox[0] + det.bbox[2], 
-                        det.bbox[1] + det.bbox[3]),
-                'confidence': det.confidence
-            }
-            persons.append(person_info)
-            self._person_ptz_index[det.track_id] = i
-        
-        # 创建新批次
-        batch_id = self._paired_saver.start_new_batch(frame, persons)
-        if batch_id:
-            self._current_batch_id = batch_id
-            logger.info(f"[配对保存] 创建批次: {batch_id}, 有效人员={len(persons)}/{len(detections)}")
-        
-        return dedup_persons
-
-    def _save_ptz_image_for_person(self, track_id: int, 
-                                    ptz_frame: np.ndarray,
-                                    ptz_position: Tuple[float, float, int]):
-        """
-        保存球机聚焦图片到对应批次
-        
-        Args:
-            track_id: 人员跟踪ID
-            ptz_frame: 球机帧
-            ptz_position: PTZ位置 (pan, tilt, zoom)
-        """
-        if (self._paired_saver is None or 
-            self._current_batch_id is None or
-            track_id not in self._person_ptz_index):
-            return
-        
-        person_index = self._person_ptz_index[track_id]
-        
-        self._paired_saver.save_ptz_image(
-            batch_id=self._current_batch_id,
-            person_index=person_index,
-            ptz_frame=ptz_frame,
-            ptz_position=ptz_position,
-            ptz_bbox=getattr(self, '_last_ptz_bbox', None)
-        )
-
-    def _save_ptz_image_for_person_batch(self, batch_id: str, person_index: int,
-                                          ptz_frame: np.ndarray,
-                                          ptz_position: Tuple[float, float, int],
-                                          ptz_frame_marked: np.ndarray = None):
-        """
-        保存球机聚焦图片到指定批次(直接使用 batch_id,不依赖当前批次)
-
-        Args:
-            batch_id: 批次ID
-            person_index: 人员序号
-            ptz_frame: 球机原始帧
-            ptz_position: PTZ位置 (pan, tilt, zoom)
-            ptz_frame_marked: 球机标记帧(可选,不传则在内部标记)
-        """
-        if self._paired_saver is None:
-            return
-
-        self._paired_saver.save_ptz_image(
-            batch_id=batch_id,
-            person_index=person_index,
-            ptz_frame=ptz_frame,
-            ptz_position=ptz_position,
-            ptz_bbox=getattr(self, '_last_ptz_bbox', None),
-            ptz_frame_marked=ptz_frame_marked
-        )
-
-    def _ptz_worker(self):
-        """PTZ控制线程:从队列接收命令并控制球机"""
-        while self.running:
-            try:
-                # 暂停时等待恢复
-                if self._paused:
-                    self._paused_event.wait()
-                    continue
-
-                try:
-                    cmd = self._ptz_queue.get(timeout=0.1)
-                except queue.Empty:
-                    continue
-
-                # 执行PTZ命令(batch_id 和 person_index 已在命令中)
-                self._execute_ptz_command(cmd)
-                
-            except Exception as e:
-                print(f"PTZ控制线程错误: {e}")
-                time.sleep(0.05)
-    
-    def _select_tracking_target(self) -> Optional[TrackingTarget]:
-        """选择当前跟踪目标"""
-        with self.targets_lock:
-            if not self.tracking_targets:
-                self._set_state(TrackingState.IDLE)
-                self.current_target = None
-                return None
-                
-            # 使用目标选择器选择最优目标
-            self.current_target = self.target_selector.select_target(
-                self.tracking_targets
-            )
-                
-            return self.current_target
-        
-    def _get_all_valid_targets(self) -> List[TrackingTarget]:
-        """
-        获取所有有效的检测目标(用于多目标PTZ定位)
-        返回按优先级排序的目标列表
-        """
-        with self.targets_lock:
-            if not self.tracking_targets:
-                self._set_state(TrackingState.IDLE)
-                self.current_target = None
-                return []
-                
-            # 按得分排序所有目标
-            targets = list(self.tracking_targets.values())
-            targets.sort(key=lambda t: t.score, reverse=True)
-                
-            if targets:
-                self._set_state(TrackingState.TRACKING)
-                self.current_target = targets[0]  # 第一个作为当前目标
-                
-            return targets
-        
-    def _send_ptz_command(self, target: TrackingTarget, frame_size: Tuple[int, int]):
-        """将跟踪目标转化为PTZ命令放入队列"""
-        x_ratio, y_ratio = target.position
-        
-        # 检查位置变化是否超过阈值
-        if self.last_ptz_position is not None:
-            last_x, last_y = self.last_ptz_position
-            if abs(x_ratio - last_x) < self.ptz_position_threshold and \
-               abs(y_ratio - last_y) < self.ptz_position_threshold:
-                return
-        
-        # 冷却检查(线程安全)
-        current_time = time.time()
-        with self._last_ptz_time_lock:
-            if current_time - self._last_ptz_time < self.PTZ_COMMAND_COOLDOWN:
-                return
-        
-        cmd = PTZCommand(
-            pan=0, tilt=0, zoom=0,
-            x_ratio=x_ratio, y_ratio=y_ratio,
-            use_calibration=self.enable_calibration
-        )
-        
-        try:
-            self._ptz_queue.put_nowait(cmd)
-            self.last_ptz_position = (x_ratio, y_ratio)
-        except queue.Full:
-            pass  # 丢弃命令,下一个检测周期会重发
-    
-    def _send_ptz_command_with_log(self, target: TrackingTarget, frame_size: Tuple[int, int]):
-        """发送PTZ命令并打印日志"""
-        x_ratio, y_ratio = target.position
-        
-        # 冷却检查(线程安全)
-        current_time = time.time()
-        with self._last_ptz_time_lock:
-            if current_time - self._last_ptz_time < self.PTZ_COMMAND_COOLDOWN:
-                return
-        
-        # 位置变化阈值检查
-        if self.last_ptz_position is not None:
-            last_x, last_y = self.last_ptz_position
-            if abs(x_ratio - last_x) < self.ptz_position_threshold and \
-               abs(y_ratio - last_y) < self.ptz_position_threshold:
-                return
-        
-        # 计算PTZ角度(用于日志)
-        if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
-            pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
-            zoom = self.ptz.ptz_config.get('default_zoom', 8)
-            coord_type = "校准坐标"
-        else:
-            pan, tilt, zoom = self.ptz.calculate_ptz_position(x_ratio, y_ratio)
-            coord_type = "估算坐标"
-        
-        # 获取当前批次信息和人员序号
-        batch_id = self._current_batch_id if self._enable_paired_saving else None
-        person_index = self._person_ptz_index.get(target.track_id, -1) if self._enable_paired_saving else -1
-        
-        cmd = PTZCommand(
-            pan=0, tilt=0, zoom=0,
-            x_ratio=x_ratio, y_ratio=y_ratio,
-            use_calibration=self.enable_calibration,
-            track_id=target.track_id,  # 传递跟踪ID
-            batch_id=batch_id,  # 传递批次ID
-            person_index=person_index  # 传递人员序号
-        )
-        
-        try:
-            self._ptz_queue.put_nowait(cmd)
-            self.last_ptz_position = (x_ratio, y_ratio)  # 更新位置记录
-            self._update_stats('ptz_commands_sent' if 'ptz_commands_sent' in self.stats else 'persons_detected')
-            logger.info(
-                f"[PTZ] 命令已发送: 目标ID={target.track_id} "
-                f"全景位置=({x_ratio:.3f}, {y_ratio:.3f}) → "
-                f"PTZ角度=(pan={pan:.1f}°, tilt={tilt:.1f}°, zoom={zoom}) [{coord_type}]"
-            )
-        except queue.Full:
-            logger.warning("[PTZ] 命令队列满,丢弃本次命令")
-    
-    def _execute_ptz_command(self, cmd: PTZCommand):
-        """
-        执行PTZ命令(在PTZ线程中)
-        
-        Args:
-            cmd: PTZ命令(包含 batch_id, person_index, track_id 用于配对保存)
-        """
-        # 更新最后执行时间(线程安全)
-        with self._last_ptz_time_lock:
-            self._last_ptz_time = time.time()
-        
-        # 从命令中提取配对保存相关信息
-        track_id = cmd.track_id
-        batch_id = cmd.batch_id
-        person_index = cmd.person_index
-        
-        if cmd.use_calibration and self.calibrator and self.calibrator.is_calibrated():
-            # 校准器返回的是可直接发送给球机的真实 PTZ 角度,不再应用 pan_flip
-            pan, tilt = self.calibrator.transform(cmd.x_ratio, cmd.y_ratio)
-            zoom = self.ptz.ptz_config.get('default_zoom', 8)
-        else:
-            pan, tilt, zoom = self.ptz.calculate_ptz_position(cmd.x_ratio, cmd.y_ratio)
-        
-        self._set_state(TrackingState.TRACKING)
-        logger.info(
-            f"[PTZ] 执行: pan={pan:.1f}° tilt={tilt:.1f}° zoom={zoom} "
-            f"(全景位置=({cmd.x_ratio:.3f}, {cmd.y_ratio:.3f}), "
-            f"batch={batch_id}, person={person_index})"
-        )
-        
-        success = self.ptz.goto_exact_position(pan, tilt, zoom)
-        
-        if success:
-            # 等待球机物理移动到位(增加额外等待确保画面清晰)
-            time.sleep(self.PTZ_CONFIRM_WAIT)
-            
-            # 球机端人体检测与自动对焦
-            final_pan, final_tilt, final_zoom = pan, tilt, zoom
-            if self.enable_ptz_detection and self.auto_zoom_config.get('enabled', False):
-                auto_zoom_result = self._auto_zoom_person(pan, tilt, zoom)
-                if auto_zoom_result != zoom:
-                    final_zoom = auto_zoom_result
-                    # 自动变焦后再次等待画面稳定
-                    time.sleep(0.5)
-            
-            # 获取清晰的球机画面(尝试多次获取最新帧)
-            ptz_frame = self._get_clear_ptz_frame()
-            
-            # 保存球机图片到配对批次(使用命令中的 batch_id 和 person_index)
-            if self._enable_paired_saving and batch_id is not None and person_index >= 0 and ptz_frame is not None:
-                # 使用球机端检测器检测人体并标记(用于标记图)
-                ptz_frame_marked = self._mark_ptz_frame_with_detection(ptz_frame, person_index=person_index)
-                # 传入原始帧保存为原图,标记帧保存为标记图
-                self._save_ptz_image_for_person_batch(batch_id, person_index, ptz_frame, (final_pan, final_tilt, final_zoom), ptz_frame_marked=ptz_frame_marked)
-            elif self._enable_paired_saving:
-                logger.warning(f"[配对保存] 跳过球机图保存: batch_id={batch_id}, person_index={person_index}, frame={ptz_frame is not None}")
-            
-            logger.info(f"[PTZ] 到位确认完成: pan={final_pan:.1f}° tilt={final_tilt:.1f}° zoom={final_zoom}")
-        else:
-            logger.warning(f"[PTZ] 命令执行失败: pan={pan:.1f}° tilt={tilt:.1f}° zoom={zoom}")
-    
-    def _auto_zoom_person(self, initial_pan: float, initial_tilt: float, initial_zoom: int) -> int:
-        """
-        自动对焦人体
-        在球机画面中检测人体,自动调整zoom使人体居中且大小合适
-        
-        Returns:
-            最终的 zoom 值
-        """
-        if self.auto_zoom_controller is None:
-            return initial_zoom
-        
-        logger.info("[AutoZoom] 开始自动对焦...")
-        
-        try:
-            success, final_zoom = self.auto_zoom_controller.auto_focus_loop(
-                get_frame_func=self.ptz.get_frame,
-                max_attempts=self.auto_zoom_config.get('max_adjust_attempts', 3)
-            )
-            
-            if success:
-                logger.info(f"[AutoZoom] 自动对焦成功: zoom={final_zoom}")
-                return final_zoom
-            else:
-                logger.warning("[AutoZoom] 自动对焦未能定位人体")
-                return initial_zoom
-        except Exception as e:
-            logger.error(f"[AutoZoom] 自动对焦异常: {e}")
-            return initial_zoom
-    
-    def _get_clear_ptz_frame(self, max_attempts: int = None, wait_interval: float = None) -> Optional[np.ndarray]:
-        """
-        获取清晰的球机画面
-        尝试多次获取,丢弃模糊/过渡帧
-
-        Args:
-            max_attempts: 最大尝试次数(默认从配置读取)
-            wait_interval: 每次等待间隔(默认从配置读取)
-
-        Returns:
-            清晰的球机帧或 None
-        """
-        # 使用配置值或默认值
-        cfg = self._frame_config
-        max_attempts = max_attempts or cfg.get('max_attempts', 8)
-        wait_interval = wait_interval or cfg.get('wait_interval', 0.2)
-        min_clarity = cfg.get('min_clarity', 200)
-
-        best_frame = None
-        best_score = -1
-
-        # 先刷新缓冲区,丢弃旧帧
-        logger.debug("[帧获取] 刷新RTSP缓冲区...")
-        for _ in range(5):
-            self.ptz.get_frame()
-            time.sleep(0.05)
-
-        # 尝试获取清晰帧
-        for i in range(max_attempts):
-            frame = self.ptz.get_frame()
-            if frame is not None:
-                # 立即复制帧,防止 RTSP 流更新导致帧被覆盖
-                frame_copy = frame.copy()
-
-                # 使用拉普拉斯算子评估图像清晰度
-                gray = cv2.cvtColor(frame_copy, cv2.COLOR_BGR2GRAY)
-                laplacian_var = cv2.Laplacian(gray, cv2.CV_64F).var()
-
-                logger.debug(f"[帧获取] 尝试 {i+1}/{max_attempts}: 清晰度={laplacian_var:.1f}")
-
-                if laplacian_var > best_score:
-                    best_score = laplacian_var
-                    best_frame = frame_copy
-
-                # 如果清晰度足够高,直接返回
-                if laplacian_var > min_clarity:
-                    logger.info(f"[帧获取] 获取清晰帧: 尝试 {i+1} 次, 清晰度={laplacian_var:.1f}")
-                    return frame_copy
-
-            time.sleep(wait_interval)
-
-        if best_frame is not None:
-            logger.info(f"[帧获取] 返回最佳帧: 清晰度={best_score:.1f}")
-        else:
-            logger.warning("[帧获取] 未能获取有效帧")
-
-        return best_frame
-    
-    def _mark_ptz_frame_with_detection(self, frame: np.ndarray, person_index: int) -> np.ndarray:
-        """
-        在球机帧上标记检测到的人体
-        
-        Args:
-            frame: 球机帧
-            person_index: 人员序号
-            
-        Returns:
-            标记后的帧
-        """
-        marked_frame = frame.copy()
-        h, w = marked_frame.shape[:2]
-        
-        # 重置保存的bbox
-        self._last_ptz_bbox = None
-        
-        # 使用球机端检测器检测人体
-        if self.ptz_detector is not None:
-            try:
-                persons = self.ptz_detector.detect(frame)
-                if persons:
-                    # 找到最大的人体(假设是目标)
-                    largest_person = max(persons, key=lambda p: p.area)
-                    x1, y1, x2, y2 = largest_person.bbox
-                    
-                    # 保存bbox供后续使用
-                    self._last_ptz_bbox = (x1, y1, x2, y2)
-                    
-                    # 绘制边界框(红色,区别于全景的绿色)
-                    cv2.rectangle(marked_frame, (x1, y1), (x2, y2), (0, 0, 255), 2)
-                    
-                    # 绘制标签
-                    label = f"person_{person_index} ({largest_person.confidence:.2f})"
-                    (label_w, label_h), _ = cv2.getTextSize(
-                        label, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2
-                    )
-                    
-                    # 标签背景(红色)
-                    cv2.rectangle(
-                        marked_frame,
-                        (x1, y1 - label_h - 8),
-                        (x1 + label_w, y1),
-                        (0, 0, 255),
-                        -1
-                    )
-                    
-                    # 标签文字(白色)
-                    cv2.putText(
-                        marked_frame, label,
-                        (x1, y1 - 4),
-                        cv2.FONT_HERSHEY_SIMPLEX, 0.7,
-                        (255, 255, 255), 2
-                    )
-                    
-                    logger.info(f"[配对保存] 球机图标记: person_{person_index}, "
-                               f"位置=({x1},{y1},{x2},{y2}), 置信度={largest_person.confidence:.2f}")
-                else:
-                    # 未检测到人体,在画面中心添加提示
-                    cv2.putText(
-                        marked_frame, f"person_{person_index} (no detection)",
-                        (w // 2 - 100, h // 2),
-                        cv2.FONT_HERSHEY_SIMPLEX, 0.8,
-                        (0, 0, 255), 2
-                    )
-            except Exception as e:
-                logger.error(f"[配对保存] 球机图检测标记失败: {e}")
-        
-        return marked_frame
-    
-    def _confirm_ptz_position(self, x_ratio: float, y_ratio: float):
-        """PTZ位置确认:读取球机帧验证目标是否可见"""
-        if not hasattr(self.ptz, 'get_frame') or self.ptz.get_frame() is None:
-            return
-        
-        ptz_frame = self.ptz.get_frame()
-        if ptz_frame is None:
-            return
-        
-        # 未来可以在这里添加球机帧目标验证逻辑
-        # 例如:在球机帧中检测目标是否在画面中心附近
-    
-    def on_ptz_confirmed(self, callback: Callable):
-        """注册PTZ位置确认回调"""
-        self._on_ptz_confirmed = callback
-
-
-class SequentialCoordinator(AsyncCoordinator):
-    """
-    顺序联动控制器 — 全景检测→逐个PTZ抓拍→回到全景的循环模式
-    
-    工作流程:
-    1. 全景摄像头检测人员
-    2. 检测到人员后,暂停全景检测
-    3. 球机依次对每个检测到的人员进行PTZ定位+变焦抓拍
-    4. 所有人员抓拍完成后,球机回到默认位置
-    5. 恢复全景检测,进入下一轮循环
-    
-    适用于需要高质量抓拍的场景,确保每个目标都能被球机清晰拍摄
-    """
-    
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        
-        # 顺序抓拍状态
-        self._capture_state = 'idle'  # 'idle', 'detecting', 'capturing', 'returning'
-        self._capture_state_lock = threading.Lock()
-        
-        # 当前批次检测到的目标
-        self._batch_targets: List[TrackingTarget] = []
-        self._batch_targets_lock = threading.Lock()
-        self._current_capture_index = 0
-        
-        # 【关键修复】保存当前批次的完整信息,防止抓拍过程中被覆盖
-        self._capture_batch_id: Optional[str] = None
-        self._capture_batch_size: int = 0
-        
-        # 抓拍完成事件(用于同步)
-        self._capture_done_event = threading.Event()
-        
-        # 配置参数 - 从 PTZ_CONFIG 读取
-        ptz_capture_config = PTZ_CONFIG.get('capture', {})
-        self._capture_config = {
-            'ptz_stabilize_time': ptz_capture_config.get('stabilize_time', 3.0),  # PTZ到位后稳定等待时间(秒)
-            'capture_wait_time': 0.5,       # 抓拍等待时间
-            'auto_zoom_wait_time': 1.5,     # AutoZoom变焦后额外等待时间(秒),增加以确保对焦完成
-            'return_to_panorama': True,     # 完成后是否回到全景默认位置
-            'default_pan': 0.0,             # 默认pan角度
-            'default_tilt': 0.0,            # 默认tilt角度
-            'default_zoom': 1,              # 默认zoom(广角)
-        }
-
-        # 帧获取配置(覆盖父类默认值)
-        self._frame_config.update({
-            'wait_interval': ptz_capture_config.get('frame_wait_interval', 0.2),
-            'max_attempts': ptz_capture_config.get('frame_max_attempts', 8),
-            'min_clarity': ptz_capture_config.get('min_clarity', 200),
-        })
-        
-        # 覆盖父类的PTZ冷却时间(顺序模式下可以更短)
-        self.PTZ_COMMAND_COOLDOWN = 0.1
-        
-        logger.info("[SequentialCoordinator] 顺序联动控制器初始化完成")
-    
-    def _detection_worker(self):
-        """检测线程:顺序模式下的检测逻辑"""
-        # 从 DETECTION_CONFIG 获取检测帧率,默认每秒2帧
-        detection_fps = self.config.get('detection_fps', DETECTION_CONFIG.get('detection_fps', 2))
-        detection_interval = 1.0 / detection_fps
-        last_detection_time = 0
-        
-        frame_count = 0
-        last_log_time = time.time()
-        log_interval = 5.0
-        detection_run_count = 0
-        detection_person_count = 0
-        detection_last_seen = 0
-        last_no_detect_log_time = 0
-        no_detect_log_interval = 30.0
-        
-        with self.stats_lock:
-            self.stats['start_time'] = time.time()
-        
-        if self.detector is None:
-            logger.warning("[检测线程] ⚠️ 人体检测器未初始化!")
-        else:
-            logger.info(f"[检测线程] ✓ 顺序模式已就绪, 检测帧率={detection_fps}fps")
-        
-        while self.running:
-            try:
-                current_time = time.time()
-                
-                # 获取当前帧
-                frame = self.panorama.get_frame()
-                if frame is None:
-                    time.sleep(0.01)
-                    continue
-                
-                frame_count += 1
-                self._update_stats('frames_processed')
-                frame_size = (frame.shape[1], frame.shape[0])
-                
-                # 日志输出
-                if current_time - last_log_time >= log_interval:
-                    elapsed = current_time - last_log_time
-                    fps = frame_count / elapsed if elapsed > 0 else 0
-                    state_str = self._get_capture_state()
-                    if detection_last_seen > 0:
-                        ago = int(current_time - detection_last_seen)
-                        person_info = f"最后有人={ago}s前"
-                    else:
-                        person_info = "未检出"
-                    logger.info(f"[检测线程] 帧率={fps:.1f}fps, 状态={state_str}, "
-                               f"检测轮次={detection_run_count}({person_info})")
-                    frame_count = 0
-                    last_log_time = current_time
-                
-                # 状态机处理
-                state = self._get_capture_state()
-                
-                if state == 'idle':
-                    # 【关键修复】每轮检测开始前清空跟踪目标,防止跨帧累积
-                    with self.targets_lock:
-                        if self.tracking_targets:
-                            logger.debug(f"[顺序模式] 清空上一轮跟踪目标: {len(self.tracking_targets)} 个")
-                            self.tracking_targets.clear()
-                    
-                    # 空闲状态:周期性检测(暂停时跳过)
-                    if not self._paused and current_time - last_detection_time >= detection_interval:
-                        last_detection_time = current_time
-                        detection_run_count += 1
-                        
-                        # 执行检测
-                        detections = self._detect_persons(frame)
-                        # 使用 BYTETracker 进行跟踪(失败时回退到位置匹配)
-                        detections = self._update_with_bytetrack(detections, frame, frame_size)
-                        
-                        if detections:
-                            self._update_stats('persons_detected', len(detections))
-                            detection_last_seen = current_time
-                            detection_person_count += 1
-                            
-                            # 【调试日志】检查跟踪目标数量
-                            with self.targets_lock:
-                                tracking_count = len(self.tracking_targets)
-                            logger.info(f"[顺序模式] 检测到 {len(detections)} 个目标, 跟踪列表 {tracking_count} 个")
-                            
-                            # 【关键修复】先创建配对批次并获取去重后的人员列表
-                            dedup_persons = []
-                            if self._enable_paired_saving and self._paired_saver is not None:
-                                dedup_persons = self._create_detection_batch(frame, detections, frame_size)
-                            else:
-                                # 如果未启用配对保存,也需要去重
-                                dedup_persons = self._deduplicate_detections(detections, frame_size)
-                            
-                            # 【调试日志】显示去重后目标数量
-                            logger.info(f"[顺序模式] 去重后有效目标数量: {len(dedup_persons)}")
-                            
-                            if dedup_persons:
-                                # 将去重后的检测结果转换为抓拍目标
-                                capture_targets = []
-                                for i, det in enumerate(dedup_persons):
-                                    x_ratio = det.center[0] / frame_size[0]
-                                    y_ratio = det.center[1] / frame_size[1]
-                                    target = TrackingTarget(
-                                        track_id=det.track_id,
-                                        position=(x_ratio, y_ratio),
-                                        last_update=current_time,
-                                        area=det.bbox[2] * det.bbox[3],
-                                        confidence=det.confidence
-                                    )
-                                    capture_targets.append(target)
-                                
-                                logger.info(f"[顺序模式] 检测到 {len(capture_targets)} 个目标,开始顺序抓拍")
-                                
-                                # 【关键修复】切换到抓拍状态后立即清空 tracking_targets
-                                # 防止后续检测再次获取到同一批目标
-                                with self.targets_lock:
-                                    self.tracking_targets.clear()
-                                    logger.info("[顺序模式] 已清空跟踪目标,防止重复抓拍")
-                                
-                                # 切换到抓拍状态(使用去重后的目标)
-                                self._start_capture_sequence(capture_targets)
-                            else:
-                                logger.warning(f"[顺序模式] 去重后无有效目标,跳过抓拍")
-                        else:
-                            # 未检测到人员
-                            if current_time - last_no_detect_log_time >= no_detect_log_interval:
-                                logger.info(f"[检测] 本轮未检测到人员 (累计{detection_run_count}轮)")
-                                last_no_detect_log_time = current_time
-                
-                elif state == 'capturing':
-                    # 抓拍状态中:检测线程等待,不执行新检测
-                    # 等待PTZ线程完成当前批次
-                    pass
-                
-                elif state == 'returning':
-                    # 球机回到默认位置中
-                    pass
-                
-                # 清理过期目标
-                self._cleanup_expired_targets()
-                time.sleep(0.01)
-                
-            except Exception as e:
-                logger.error(f"[检测线程] 错误: {e}")
-                time.sleep(0.1)
-    
-    def _ptz_worker(self):
-        """PTZ控制线程:顺序模式下的PTZ控制逻辑"""
-        logger.info("[PTZ线程] 顺序模式PTZ控制线程启动")
-
-        while self.running:
-            try:
-                # 暂停时等待恢复
-                if self._paused:
-                    self._paused_event.wait()
-                    time.sleep(0.05)
-                    continue
-
-                state = self._get_capture_state()
-                
-                if state == 'capturing':
-                    # 执行顺序抓拍(阻塞直到当前目标抓拍完成)
-                    self._execute_sequential_capture()
-                
-                elif state == 'returning':
-                    # 回到默认位置
-                    self._return_to_default_position()
-                
-                # idle 状态不做任何操作,让检测线程控制
-                time.sleep(0.05)
-                
-            except Exception as e:
-                logger.error(f"[PTZ线程] 错误: {e}")
-                time.sleep(0.1)
-    
-    def _get_capture_state(self) -> str:
-        """获取当前抓拍状态"""
-        with self._capture_state_lock:
-            return self._capture_state
-    
-    def _set_capture_state(self, state: str):
-        """设置抓拍状态"""
-        with self._capture_state_lock:
-            old_state = self._capture_state
-            self._capture_state = state
-            logger.info(f"[顺序模式] 状态切换: {old_state} -> {state}")
-    
-    def _start_capture_sequence(self, targets: List[TrackingTarget]):
-        """开始顺序抓拍序列"""
-        with self._batch_targets_lock:
-            # 【关键修复】先清空再赋值,防止累积
-            old_count = len(self._batch_targets)
-            self._batch_targets = targets.copy()  # 直接替换,不追加
-            self._current_capture_index = 0
-            
-            # 【关键修复】保存批次信息,防止抓拍过程中被新批次覆盖
-            self._capture_batch_id = self._current_batch_id
-            self._capture_batch_size = len(targets)
-            
-            logger.info(f"[顺序模式] 开始抓拍序列: {old_count} -> {len(self._batch_targets)} 个目标, "
-                       f"批次ID={self._capture_batch_id}")
-        
-        self._set_capture_state('capturing')
-    
-    def _execute_sequential_capture(self):
-        """执行顺序抓拍(依次对每个目标进行PTZ定位和抓拍)"""
-        with self._batch_targets_lock:
-            targets = self._batch_targets.copy()
-            current_idx = self._current_capture_index
-            batch_size = self._capture_batch_size
-            batch_id = self._capture_batch_id
-        
-        # 【调试日志】详细输出抓拍状态
-        logger.info(f"[顺序模式] 执行抓拍: idx={current_idx}, batch_size={batch_size}, "
-                   f"targets_len={len(targets)}, batch_id={batch_id}")
-        
-        # 使用保存的批次大小进行检查,而不是 len(targets)
-        if current_idx >= batch_size:
-            # 所有目标已抓拍完成
-            logger.info(f"[顺序模式] 所有目标抓拍完成: {current_idx}/{batch_size}")
-            self._set_capture_state('returning')
-            return
-        
-        
-        # 【安全检查】确保 targets 有足够的数据
-        if current_idx >= len(targets):
-            logger.warning(f"[顺序模式] 索引越界: idx={current_idx} >= targets_len={len(targets)}, "
-                          f"batch_size={batch_size}。可能批次信息不一致!")
-            self._set_capture_state('returning')
-            return
-        
-        # 获取当前目标
-        target = targets[current_idx]
-        x_ratio, y_ratio = target.position
-        
-        # 计算PTZ角度
-        if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
-            # 校准器返回的是可直接发送给球机的真实 PTZ 角度,不再应用 pan_flip
-            pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
-            zoom = self.ptz.ptz_config.get('default_zoom', 8)
-            coord_type = "校准坐标"
-        else:
-            pan, tilt, zoom = self.ptz.calculate_ptz_position(x_ratio, y_ratio)
-            coord_type = "估算坐标"
-        
-        # 获取批次信息(使用保存的批次ID,防止被新批次覆盖)
-        with self._batch_targets_lock:
-            batch_id = self._capture_batch_id if self._enable_paired_saving else None
-            batch_size = self._capture_batch_size
-        person_index = current_idx  # 使用当前索引作为人员序号
-        
-        logger.info(f"[顺序模式] 抓拍目标 {current_idx + 1}/{batch_size}: "
-                   f"位置=({x_ratio:.3f}, {y_ratio:.3f}) -> "
-                   f"PTZ=({pan:.1f}°, {tilt:.1f}°, zoom={zoom}) [{coord_type}], 批次={batch_id}")
-        
-        # 【关键修复】先递增索引,防止 PTZ 线程重复进入时重复执行
-        with self._batch_targets_lock:
-            self._current_capture_index += 1
-        
-        # 执行PTZ移动
-        self._set_state(TrackingState.TRACKING)
-
-        # 【调试】记录移动前的 PTZ 位置
-        try:
-            current_pos = self.ptz.get_current_position()
-            logger.info(f"[顺序模式] PTZ移动前: pan={current_pos.pan:.1f}° tilt={current_pos.tilt:.1f}° zoom={current_pos.zoom}")
-        except Exception as e:
-            logger.warning(f"[顺序模式] 获取当前PTZ位置失败: {e}")
-            current_pos = None
-
-        success = self.ptz.goto_exact_position(pan, tilt, zoom)
-
-        if success:
-            # 等待球机物理移动到位
-            stabilize_time = self._capture_config['ptz_stabilize_time']
-            logger.info(f"[顺序模式] 等待球机稳定 {stabilize_time}s...")
-
-            # 【调试】记录移动后的 PTZ 位置
-            time.sleep(0.5)  # 短暂等待后检查
-            try:
-                after_pos = self.ptz.get_current_position()
-                logger.info(f"[顺序模式] PTZ移动后: pan={after_pos.pan:.1f}° tilt={after_pos.tilt:.1f}° zoom={after_pos.zoom}")
-            except Exception as e:
-                logger.warning(f"[顺序模式] 获取PTZ位置失败: {e}")
-            time.sleep(stabilize_time)
-            
-            # 【关键修复】清空RTSP缓冲区,确保获取的是新位置的帧
-            logger.debug("[顺序模式] 清空RTSP缓冲区...")
-            for _ in range(5):
-                self.ptz.get_frame()
-                time.sleep(0.05)
-            
-            # 自动变焦(如果启用)
-            final_zoom = zoom
-            if self.enable_ptz_detection and self.auto_zoom_config.get('enabled', False):
-                auto_zoom_result = self._auto_zoom_person(pan, tilt, zoom)
-                if auto_zoom_result != zoom:
-                    final_zoom = auto_zoom_result
-                    # 变焦后等待镜头对焦
-                    auto_zoom_wait = self._capture_config.get('auto_zoom_wait_time', 1.0)
-                    logger.info(f"[顺序模式] 变焦完成,等待镜头对焦 {auto_zoom_wait}s...")
-                    time.sleep(auto_zoom_wait)
-                    # 变焦后再次清空缓冲区
-                    for _ in range(3):
-                        self.ptz.get_frame()
-                        time.sleep(0.05)
-            
-            # 获取清晰的球机画面
-            ptz_frame = self._get_clear_ptz_frame()
-
-            if ptz_frame is not None:
-                # 【调试】记录抓拍的图像信息
-                frame_h, frame_w = ptz_frame.shape[:2]
-                logger.info(f"[顺序模式] 抓拍帧: {frame_w}x{frame_h}, 目标序号={person_index}, PTZ=({pan:.1f}°, {tilt:.1f}°, zoom={final_zoom})")
-
-                # 保存球机图片
-                if self._enable_paired_saving and batch_id is not None:
-                    # 使用球机端检测器检测人体并标记(用于标记图)
-                    ptz_frame_marked = self._mark_ptz_frame_with_detection(ptz_frame, person_index=person_index)
-                    # 传入原始帧保存为原图,标记帧保存为标记图
-                    self._save_ptz_image_for_person_batch(
-                        batch_id, person_index, ptz_frame,
-                        (pan, tilt, final_zoom), ptz_frame_marked=ptz_frame_marked
-                    )
-
-                logger.info(f"[顺序模式] 目标 {current_idx + 1} 抓拍完成")
-            else:
-                logger.warning(f"[顺序模式] 获取球机画面失败")
-        else:
-            logger.warning(f"[顺序模式] PTZ移动失败")
-        
-        # 抓拍间隔
-        time.sleep(self._capture_config['capture_wait_time'])
-    
-    def _return_to_default_position(self):
-        """球机回到默认位置(广角全景)"""
-        if not self._capture_config['return_to_panorama']:
-            # 不回到默认位置,直接回到空闲状态
-            self._set_capture_state('idle')
-            return
-        
-        default_pan = self._capture_config['default_pan']
-        default_tilt = self._capture_config['default_tilt']
-        default_zoom = self._capture_config['default_zoom']
-        
-        # 【关键修复】等待一小段时间,确保最后的帧已经被读取和保存
-        # 因为 _get_clear_ptz_frame 是异步获取帧,可能在抓拍后立刻触发返回默认位置
-        time.sleep(0.5)
-        
-        logger.info(f"[顺序模式] 球机回到默认位置: ({default_pan}°, {default_tilt}°, zoom={default_zoom})")
-        
-        success = self.ptz.goto_exact_position(default_pan, default_tilt, default_zoom)
-        
-        if success:
-            # 等待到位
-            time.sleep(self._capture_config['ptz_stabilize_time'])
-            logger.info("[顺序模式] 球机已回到默认位置")
-        else:
-            logger.warning("[顺序模式] 球机回到默认位置失败")
-        
-        # 回到空闲状态,恢复全景检测
-        self._set_capture_state('idle')
-        
-        # 清空批次目标
-        with self._batch_targets_lock:
-            self._batch_targets = []
-            self._current_capture_index = 0
-            # 【关键修复】清空保存的批次信息
-            self._capture_batch_id = None
-            self._capture_batch_size = 0
-        
-        # 【关键修复】清空跟踪目标,防止跨帧累积
-        with self.targets_lock:
-            self.tracking_targets.clear()
-            logger.info("[顺序模式] 已清空跟踪目标列表")
-
-    def set_capture_config(self, **kwargs):
-        """设置抓拍配置"""
-        self._capture_config.update(kwargs)
-        logger.info(f"[顺序模式] 配置已更新: {kwargs}")
-    
-    def get_capture_status(self) -> dict:
-        """获取当前抓拍状态"""
-        with self._batch_targets_lock:
-            total = len(self._batch_targets)
-            current = self._current_capture_index
-        
-        return {
-            'state': self._get_capture_state(),
-            'total_targets': total,
-            'current_index': current,
-            'remaining': max(0, total - current)
-        }

+ 0 - 0
dual_camera_system/core/__init__.py


+ 176 - 0
dual_camera_system/core/capture_uploader.py

@@ -0,0 +1,176 @@
+"""检测到人后的保存 + 上传."""
+import logging
+import os
+import threading
+import time
+import uuid
+from typing import Any, Callable, Dict, List, Optional
+import cv2
+import numpy as np
+
+from config.device import DEVICE_CONFIG
+
+logger = logging.getLogger(__name__)
+
+
+class CaptureUploader:
+    def __init__(
+        self,
+        group_id: str,
+        save_dir: str = "data/captures",
+        upload_callback: Optional[Callable[[Dict], None]] = None,
+        dedup_seconds: float = 5.0,
+    ):
+        self.group_id = group_id
+        self.save_dir = os.path.join(save_dir, group_id)
+        os.makedirs(self.save_dir, exist_ok=True)
+        self.upload_callback = upload_callback
+        self.dedup_seconds = dedup_seconds
+        self._last_uploads: List[Dict[str, Any]] = []
+        self._lock = threading.Lock()
+        self._counter = 0
+
+    def _should_upload(self, camera_type: str, bbox: List[float]) -> bool:
+        cx = (bbox[0] + bbox[2]) / 2
+        cy = (bbox[1] + bbox[3]) / 2
+        for u in self._last_uploads:
+            if u["camera_type"] != camera_type:
+                continue
+            dx = abs(u["cx"] - cx)
+            dy = abs(u["cy"] - cy)
+            if dx < 50 and dy < 50:
+                return False
+        return True
+
+    def _validate_inputs(
+        self,
+        camera_type: str,
+        frame: np.ndarray,
+        detections: List[Dict],
+    ) -> None:
+        if camera_type not in ("panorama", "ptz"):
+            raise ValueError("camera_type must be 'panorama' or 'ptz'")
+
+        if not isinstance(frame, np.ndarray):
+            raise ValueError("frame must be a numpy ndarray")
+        if frame.ndim != 3 or frame.shape[2] != 3:
+            raise ValueError("frame must have shape (H, W, 3)")
+        if frame.dtype != np.uint8:
+            raise ValueError("frame must have dtype uint8")
+
+        for i, det in enumerate(detections):
+            if not isinstance(det, dict):
+                raise ValueError(f"detection {i} must be a dict")
+            if "bbox" not in det:
+                raise ValueError(f"detection {i} missing bbox")
+            bbox = det["bbox"]
+            if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
+                raise ValueError(f"detection {i} bbox must be a list/tuple of 4 numbers")
+            try:
+                [float(v) for v in bbox]
+            except (TypeError, ValueError):
+                raise ValueError(f"detection {i} bbox must contain numbers")
+            if "confidence" not in det:
+                raise ValueError(f"detection {i} missing confidence")
+            try:
+                float(det["confidence"])
+            except (TypeError, ValueError):
+                raise ValueError(f"detection {i} confidence must be a number")
+
+    def handle_detection(
+        self,
+        camera_type: str,  # 'panorama' or 'ptz'
+        frame: np.ndarray,
+        detections: List[Dict],
+        ptz_position: Optional[Dict] = None,
+    ) -> List[Dict]:
+        self._validate_inputs(camera_type, frame, detections)
+
+        if not detections:
+            return []
+
+        with self._lock:
+            now = time.monotonic()
+            self._last_uploads = [
+                u for u in self._last_uploads
+                if now - u["time"] < self.dedup_seconds
+            ]
+
+            upload_decisions = [
+                (det, self._should_upload(camera_type, det["bbox"]))
+                for det in detections
+            ]
+            to_upload = [det for det, should in upload_decisions if should]
+            if not to_upload:
+                logger.debug("All detections deduplicated; skipping file writes for %s", camera_type)
+                return []
+
+            self._counter += 1
+            counter = self._counter
+            ts = int(time.time() * 1000)
+            original_path = os.path.join(
+                self.save_dir, f"{camera_type}_{ts}_{counter}_original.jpg"
+            )
+            marked_path = os.path.join(
+                self.save_dir, f"{camera_type}_{ts}_{counter}_marked.jpg"
+            )
+
+            # Reserve dedup slots and generate paths under lock.
+            for det in to_upload:
+                self._last_uploads.append({
+                    "camera_type": camera_type,
+                    "cx": (det["bbox"][0] + det["bbox"][2]) / 2,
+                    "cy": (det["bbox"][1] + det["bbox"][3]) / 2,
+                    "time": time.monotonic(),
+                })
+
+        # File I/O and user callback run outside the lock.
+        logger.info("Saving original image to %s", original_path)
+        if not cv2.imwrite(original_path, frame):
+            raise RuntimeError(f"Failed to write {original_path}")
+
+        marked = frame.copy()
+        for det in to_upload:
+            x1, y1, x2, y2 = map(int, det["bbox"])
+            cv2.rectangle(marked, (x1, y1), (x2, y2), (0, 255, 0), 2)
+            cv2.putText(marked, f"{det['confidence']:.2f}", (x1, y1 - 5),
+                        cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
+        logger.info("Saving marked image to %s", marked_path)
+        if not cv2.imwrite(marked_path, marked):
+            raise RuntimeError(f"Failed to write {marked_path}")
+
+        results: List[Dict] = []
+        for det in to_upload:
+            x1, y1, x2, y2 = map(int, det["bbox"])
+            payload = {
+                "group_id": self.group_id,
+                "camera_type": camera_type,
+                "timestamp": ts,
+                "original": original_path,
+                "marked": marked_path,
+                "bbox": [x1, y1, x2, y2],
+                "confidence": det["confidence"],
+                "ptz_position": ptz_position,
+            }
+            results.append(payload)
+
+        if self.upload_callback and results:
+            batch_info = {
+                "batch_id": str(uuid.uuid4()),
+                "device_id": DEVICE_CONFIG.get("device_id", "unknown"),
+                "project_id": DEVICE_CONFIG.get("project_id", ""),
+                "timestamp": ts,
+                "image_paths": [original_path, marked_path],
+                "detections": [
+                    {"bbox": det["bbox"], "confidence": det["confidence"], "camera_type": camera_type}
+                    for det in to_upload
+                ],
+                "ptz_position": ptz_position,
+            }
+            logger.info("Uploading batch info for %s", camera_type)
+            try:
+                self.upload_callback(batch_info)
+            except Exception as exc:  # noqa: BLE001
+                logger.warning("Upload callback failed: %s", exc)
+
+        return results

+ 52 - 0
dual_camera_system/core/coord_utils.py

@@ -0,0 +1,52 @@
+"""球面坐标与 PTZ pan/tilt 角度换算工具."""
+import math
+from typing import Tuple
+
+
+def pan_tilt_to_vector(pan: float, tilt: float) -> Tuple[float, float, float]:
+    """把 pan/tilt(度)转成单位向量 (x, y, z)。
+
+    坐标系:x 向右,y 向上,z 向前;pan 从正前方 z 轴开始顺时针;
+    tilt 0 为水平,+90 为天顶。
+    """
+    pan_rad = math.radians(pan)
+    tilt_rad = math.radians(tilt)
+    x = math.cos(tilt_rad) * math.sin(pan_rad)
+    y = math.sin(tilt_rad)
+    z = math.cos(tilt_rad) * math.cos(pan_rad)
+    return x, y, z
+
+
+def spherical_to_pan_tilt(x: float, y: float, z: float) -> Tuple[float, float]:
+    """把单位向量 (x, y, z) 转成 pan/tilt(度)。"""
+    norm = math.sqrt(x * x + y * y + z * z)
+    if norm < 1e-9:
+        return 0.0, 0.0
+    x, y, z = x / norm, y / norm, z / norm
+    tilt = math.degrees(math.asin(max(-1.0, min(1.0, y))))
+    pan = math.degrees(math.atan2(x, z))
+    if pan < 0:
+        pan += 360.0
+    return pan, tilt
+
+
+def compute_sample_grid(
+    pan_range: Tuple[float, float] = (0.0, 360.0),
+    tilt_layers: Tuple[float, ...] = (-20.0, 0.0, 20.0),
+    pan_step: float = 30.0,
+) -> list[tuple[float, float]]:
+    """生成扫描采样网格 [(pan, tilt), ...]。"""
+    if pan_step <= 0:
+        raise ValueError("pan_step must be positive")
+    if not tilt_layers:
+        raise ValueError("tilt_layers must not be empty")
+    if pan_range[0] >= pan_range[1]:
+        raise ValueError("pan_range start must be less than end")
+    points = []
+    pan_start, pan_end = pan_range
+    pan = pan_start
+    while pan < pan_end - 1e-6:
+        for tilt in tilt_layers:
+            points.append((round(pan, 2), round(tilt, 2)))
+        pan += pan_step
+    return points

+ 18 - 0
dual_camera_system/core/detector_service.py

@@ -0,0 +1,18 @@
+"""复用现有 ObjectDetector 的薄封装."""
+import threading
+
+from config.detection import DETECTION_CONFIG
+from panorama_camera import ObjectDetector
+
+
+class DetectorService:
+    def __init__(self, model_path: str = None, use_gpu: bool = None):
+        model_path = model_path or DETECTION_CONFIG.get("model_path")
+        if use_gpu is None:
+            use_gpu = DETECTION_CONFIG.get("use_gpu", True)
+        self.detector = ObjectDetector(model_path=model_path, use_gpu=use_gpu)
+        self._lock = threading.Lock()
+
+    def detect(self, frame):
+        with self._lock:
+            return self.detector.detect(frame)

+ 77 - 0
dual_camera_system/core/group_state.py

@@ -0,0 +1,77 @@
+"""各摄像头组运行时共享状态."""
+import copy
+import logging
+import threading
+from typing import Any, Dict
+
+
+class GroupState:
+    def __init__(self):
+        self._data: Dict[str, Dict[str, Any]] = {}
+        self._lock = threading.Lock()
+
+    def reset(self):
+        """重置所有状态(主要用于测试)."""
+        with self._lock:
+            self._data.clear()
+
+    def init_group(self, group_id: str, panorama_rtsp: str, ptz_rtsp: str, ptz_config: Dict):
+        with self._lock:
+            if group_id in self._data:
+                raise ValueError(f"Group {group_id} already initialized")
+            self._data[group_id] = {
+                "panorama_rtsp": panorama_rtsp,
+                "ptz_rtsp": ptz_rtsp,
+                "ptz_position": {"pan": 0.0, "tilt": 0.0, "zoom": 1},
+                "polling_state": "idle",  # idle|scanning|polling|paused
+                "scan_progress": {"total": 0, "current": 0, "state": "idle"},
+                "last_detection": None,
+                "logs": [],
+            }
+
+    def get(self, group_id: str) -> Dict[str, Any]:
+        with self._lock:
+            return copy.deepcopy(self._data.get(group_id, {}))
+
+    def list_groups(self) -> list:
+        with self._lock:
+            return list(self._data.keys())
+
+    def update(self, group_id: str, key: str, value: Any):
+        with self._lock:
+            if group_id not in self._data:
+                logging.warning("Cannot update unknown group: %s", group_id)
+                return
+            self._data[group_id][key] = value
+
+    def compare_and_update(
+        self, group_id: str, key: str, expected: Any, new_value: Any
+    ) -> bool:
+        with self._lock:
+            if group_id not in self._data:
+                return False
+            if self._data[group_id].get(key) != expected:
+                return False
+            self._data[group_id][key] = new_value
+            return True
+
+    def update_nested(self, group_id: str, key: str, sub_key: str, value: Any):
+        with self._lock:
+            if group_id not in self._data:
+                raise KeyError(f"Group {group_id} not initialized")
+            if key not in self._data[group_id] or not isinstance(self._data[group_id][key], dict):
+                raise KeyError(f"Key {key} is not a dict")
+            self._data[group_id][key][sub_key] = value
+
+    def append_log(self, group_id: str, message: str):
+        with self._lock:
+            if group_id not in self._data:
+                logging.warning("Cannot append log to unknown group: %s", group_id)
+                return
+            logs = self._data[group_id]["logs"]
+            logs.append(message)
+            self._data[group_id]["logs"] = logs[-200:]
+
+
+# 全局单例(保持 import 兼容性;测试可调用 reset() 重置)
+group_state = GroupState()

+ 137 - 0
dual_camera_system/core/polling_scheduler.py

@@ -0,0 +1,137 @@
+"""扫描点轮询调度器."""
+import logging
+import threading
+import time
+from typing import Callable, Dict, List, Optional
+
+
+logger = logging.getLogger(__name__)
+
+
+class PollingScheduler:
+    def __init__(
+        self,
+        group_id: str,
+        ptz_camera,
+        get_points: Callable[[], List[Dict]],
+        on_arrived: Optional[Callable[[Dict], None]] = None,
+        on_finished: Optional[Callable[[], None]] = None,
+        default_dwell: float = 3.0,
+        stabilize_time: float = 1.5,
+    ):
+        self.group_id = group_id
+        self.ptz = ptz_camera
+        self.get_points = get_points
+        self.on_arrived = on_arrived
+        self.on_finished = on_finished
+        self.default_dwell = default_dwell
+        self.stabilize_time = stabilize_time
+        self._stop_event = threading.Event()
+        self._pause_event = threading.Event()
+        self._pause_event.set()
+        self.thread: Optional[threading.Thread] = None
+        self.current_point: Optional[Dict] = None
+
+    def start(self):
+        if self.thread is not None and self.thread.is_alive():
+            logger.warning(
+                "[%s] PollingScheduler start() ignored: previous worker thread is still alive",
+                self.group_id,
+            )
+            return
+        self._stop_event.clear()
+        self._pause_event.set()
+        self.thread = threading.Thread(target=self._loop, daemon=True)
+        self.thread.start()
+
+    def stop(self):
+        self._stop_event.set()
+        self._pause_event.set()
+        if self.thread is not None:
+            self.thread.join(timeout=5.0)
+            if self.thread.is_alive():
+                logger.warning(
+                    "[%s] PollingScheduler stop() timed out waiting for worker thread to exit",
+                    self.group_id,
+                )
+            else:
+                self.thread = None
+        self.current_point = None
+
+    def pause(self):
+        self._pause_event.clear()
+
+    def resume(self):
+        self._pause_event.set()
+
+    def _loop(self):
+        while not self._stop_event.is_set():
+            try:
+                points = self.get_points()
+            except Exception:
+                logger.exception("[%s] get_points() raised an exception", self.group_id)
+                self._stop_event.wait(1.0)
+                continue
+
+            if not points:
+                self._stop_event.wait(1.0)
+                continue
+
+            for point in points:
+                if self._stop_event.is_set():
+                    break
+
+                while not self._pause_event.is_set() and not self._stop_event.is_set():
+                    self._stop_event.wait(0.5)
+
+                if self._stop_event.is_set():
+                    break
+
+                if not isinstance(point, dict) or "pan" not in point or "tilt" not in point:
+                    logger.error(
+                        "[%s] Skipping malformed point: %r",
+                        self.group_id,
+                        point,
+                    )
+                    continue
+
+                self.current_point = point
+                try:
+                    self.ptz.goto_exact_position(
+                        point["pan"], point["tilt"], point.get("zoom", 1)
+                    )
+                except Exception:
+                    logger.exception(
+                        "[%s] goto_exact_position failed for point %r",
+                        self.group_id,
+                        point,
+                    )
+                    self.current_point = None
+                    continue
+
+                self._stop_event.wait(self.stabilize_time)
+
+                if self._stop_event.is_set():
+                    break
+
+                if self.on_arrived:
+                    try:
+                        self.on_arrived(point)
+                    except Exception:
+                        logger.exception(
+                            "[%s] on_arrived callback failed for point %r",
+                            self.group_id,
+                            point,
+                        )
+
+                dwell = point.get("dwell_time", self.default_dwell)
+                if self._stop_event.wait(dwell):
+                    break
+
+            if self.on_finished:
+                try:
+                    self.on_finished()
+                except Exception:
+                    logger.exception("[%s] on_finished callback failed", self.group_id)
+
+        self.current_point = None

+ 219 - 0
dual_camera_system/core/scan_point_store.py

@@ -0,0 +1,219 @@
+"""扫描点配置持久化."""
+import copy
+import json
+import math
+import os
+import sys
+import tempfile
+import threading
+from typing import Any, Dict, List, Optional
+
+
+Number = (int, float)
+
+
+class ScanPointStore:
+    def __init__(self, filepath: str = "data/scan_models.json"):
+        self.filepath = filepath
+        self._lock = threading.Lock()
+        self._ensure_dir()
+        self._data = self._load()
+
+    def _ensure_dir(self):
+        dir_name = os.path.dirname(self.filepath)
+        if dir_name:
+            os.makedirs(dir_name, exist_ok=True)
+
+    def _load(self) -> Dict[str, Any]:
+        if os.path.exists(self.filepath):
+            try:
+                with open(self.filepath, "r", encoding="utf-8") as f:
+                    return json.load(f)
+            except json.JSONDecodeError as exc:
+                print(f"Warning: corrupted JSON in {self.filepath}: {exc}. Starting fresh.", file=sys.stderr)
+        return {"camera_groups": {}}
+
+    def _save(self):
+        dir_name = os.path.dirname(self.filepath) or "."
+        fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
+        try:
+            with os.fdopen(fd, "w", encoding="utf-8") as f:
+                json.dump(self._data, f, ensure_ascii=False, indent=2)
+            os.replace(tmp_path, self.filepath)
+        except Exception:
+            try:
+                os.remove(tmp_path)
+            except OSError:
+                pass
+            raise
+
+    def _ensure_group_locked(self, group_id: str, meta: Dict[str, str]):
+        """Assume self._lock is already held."""
+        groups = self._data.setdefault("camera_groups", {})
+        if group_id not in groups:
+            groups[group_id] = {
+                "ptz_name": meta.get("ptz_name", group_id),
+                "panorama_name": meta.get("panorama_name", group_id),
+                "scan_config": {},
+                "samples": [],
+                "enabled_points": [],
+                "panorama": {},
+            }
+            self._save()
+        # Ensure legacy data without next_point_id gets a safe seed.
+        group = groups[group_id]
+        enabled_points = group.get("enabled_points", [])
+        group.setdefault(
+            "next_point_id",
+            max((p["id"] for p in enabled_points), default=0) + 1,
+        )
+
+    def ensure_group(self, group_id: str, meta: Dict[str, str]):
+        with self._lock:
+            self._ensure_group_locked(group_id, meta)
+
+    def get_group(self, group_id: str) -> Optional[Dict[str, Any]]:
+        with self._lock:
+            group = self._data.get("camera_groups", {}).get(group_id)
+            return copy.deepcopy(group) if group is not None else None
+
+    def set_scan_config(self, group_id: str, config: Dict[str, Any]):
+        with self._lock:
+            self._ensure_group_locked(group_id, {})
+            self._data["camera_groups"][group_id]["scan_config"] = copy.deepcopy(config)
+            self._save()
+
+    def set_samples(self, group_id: str, samples: List[Dict[str, Any]]):
+        with self._lock:
+            self._ensure_group_locked(group_id, {})
+            self._data["camera_groups"][group_id]["samples"] = copy.deepcopy(samples)
+            self._save()
+
+    def set_panorama(self, group_id: str, panorama: Dict[str, Any]):
+        with self._lock:
+            self._ensure_group_locked(group_id, {})
+            self._data["camera_groups"][group_id]["panorama"] = copy.deepcopy(panorama)
+            self._save()
+
+    @staticmethod
+    def _validate_point_field(key: str, value: Any):
+        if key in ("pan", "tilt"):
+            if isinstance(value, bool) or not isinstance(value, Number):
+                raise ValueError(f"{key} must be numeric")
+            if not math.isfinite(value):
+                raise ValueError(f"{key} must be finite")
+        elif key == "zoom":
+            if isinstance(value, bool) or not isinstance(value, int) or value < 1:
+                raise ValueError("zoom must be an integer >= 1")
+        elif key == "dwell_time":
+            if isinstance(value, bool) or not isinstance(value, Number):
+                raise ValueError("dwell_time must be a number > 0")
+            if not math.isfinite(value):
+                raise ValueError("dwell_time must be finite")
+            if value <= 0:
+                raise ValueError("dwell_time must be a number > 0")
+
+    def add_enabled_point(
+        self,
+        group_id: str,
+        pan: float,
+        tilt: float,
+        zoom: int = 1,
+        dwell_time: float = 3.0,
+    ) -> Dict[str, Any]:
+        self._validate_point_field("pan", pan)
+        self._validate_point_field("tilt", tilt)
+        self._validate_point_field("zoom", zoom)
+        self._validate_point_field("dwell_time", dwell_time)
+
+        with self._lock:
+            self._ensure_group_locked(group_id, {})
+            group = self._data["camera_groups"][group_id]
+            points = group["enabled_points"]
+            point_id = group.get("next_point_id")
+            if point_id is None:
+                point_id = max((p["id"] for p in points), default=0) + 1
+            point = {
+                "id": point_id,
+                "pan": round(float(pan), 2),
+                "tilt": round(float(tilt), 2),
+                "zoom": int(zoom),
+                "dwell_time": float(dwell_time),
+                "order": len(points),
+            }
+            points.append(point)
+            group["next_point_id"] = point_id + 1
+            self._save()
+            return copy.deepcopy(point)
+
+    def update_enabled_point(self, group_id: str, point_id: int, updates: Dict[str, Any]) -> bool:
+        normalized = {}
+        for key, value in updates.items():
+            if key in ("pan", "tilt", "zoom", "dwell_time", "order"):
+                if key in ("pan", "tilt", "zoom", "dwell_time"):
+                    self._validate_point_field(key, value)
+                elif key == "order":
+                    if isinstance(value, bool) or not isinstance(value, int):
+                        raise ValueError("order must be an integer")
+                    if value < 0:
+                        raise ValueError("order must be non-negative")
+            else:
+                raise ValueError(f"Unknown field: {key}")
+
+            if key == "pan":
+                normalized[key] = round(float(value), 2)
+            elif key == "tilt":
+                normalized[key] = round(float(value), 2)
+            elif key == "zoom":
+                normalized[key] = int(value)
+            elif key == "dwell_time":
+                normalized[key] = float(value)
+            else:
+                normalized[key] = value
+
+        with self._lock:
+            points = self._data.get("camera_groups", {}).get(group_id, {}).get("enabled_points", [])
+            for p in points:
+                if p["id"] == point_id:
+                    for key in ("pan", "tilt", "zoom", "dwell_time", "order"):
+                        if key in normalized:
+                            p[key] = normalized[key]
+                    self._save()
+                    return True
+            return False
+
+    def delete_enabled_point(self, group_id: str, point_id: int) -> bool:
+        with self._lock:
+            group = self._data.get("camera_groups", {}).get(group_id)
+            if not group:
+                return False
+            points = group["enabled_points"]
+            new_points = [p for p in points if p["id"] != point_id]
+            if len(new_points) == len(points):
+                return False
+            for idx, p in enumerate(new_points):
+                p["order"] = idx
+            group["enabled_points"] = new_points
+            self._save()
+            return True
+
+    def list_enabled_points(self, group_id: str) -> List[Dict[str, Any]]:
+        with self._lock:
+            group = self._data.get("camera_groups", {}).get(group_id)
+            if not group:
+                return []
+            points = sorted(group.get("enabled_points", []), key=lambda x: x.get("order", 0))
+            return copy.deepcopy(points)
+
+    def reorder_points(self, group_id: str, point_ids: List[int]):
+        with self._lock:
+            points = self._data.get("camera_groups", {}).get(group_id, {}).get("enabled_points", [])
+            existing_ids = {p["id"] for p in points}
+            if set(point_ids) != existing_ids or len(point_ids) != len(existing_ids):
+                raise ValueError("point_ids must contain exactly the existing enabled point IDs")
+            id_map = {p["id"]: p for p in points}
+            new_points = [id_map[i] for i in point_ids]
+            for idx, p in enumerate(new_points):
+                p["order"] = idx
+            self._data["camera_groups"][group_id]["enabled_points"] = new_points
+            self._save()

+ 143 - 0
dual_camera_system/core/spatial_scanner.py

@@ -0,0 +1,143 @@
+"""球机 360° 扫描与全景图生成."""
+import logging
+import os
+import time
+from typing import Callable, Dict, List, Optional, Tuple
+import cv2
+import numpy as np
+
+logger = logging.getLogger(__name__)
+
+from core.coord_utils import compute_sample_grid
+
+
+class SpatialScanner:
+    def __init__(
+        self,
+        group_id: str,
+        ptz_camera,
+        ptz_frame_source: Callable[[], Optional[np.ndarray]],
+        data_dir: str = "data",
+        stabilize_time: float = 1.5,
+    ):
+        self.group_id = group_id
+        self.ptz = ptz_camera
+        self.get_ptz_frame = ptz_frame_source
+        self.data_dir = os.path.join(data_dir, group_id)
+        os.makedirs(os.path.join(self.data_dir, "samples"), exist_ok=True)
+        os.makedirs(os.path.join(self.data_dir, "panorama"), exist_ok=True)
+        self.stabilize_time = stabilize_time
+        self.progress = {"total": 0, "current": 0, "state": "idle"}
+        self.cancelled = False
+
+    def cancel(self):
+        self.cancelled = True
+
+    def run(
+        self,
+        pan_range: Tuple[float, float] = (0.0, 360.0),
+        tilt_layers: Tuple[float, ...] = (-20.0, 0.0, 20.0),
+        pan_step: float = 30.0,
+        zoom: int = 1,
+        progress_callback: Optional[Callable[[Dict], None]] = None,
+    ) -> Dict:
+        config = {
+            "pan_range": pan_range,
+            "tilt_layers": list(tilt_layers),
+            "pan_step": pan_step,
+            "zoom": zoom,
+        }
+
+        if self.cancelled:
+            self.progress = {"total": 0, "current": 0, "state": "cancelled"}
+            return {
+                "group_id": self.group_id,
+                "samples": [],
+                "panorama_path": None,
+                "config": config,
+            }
+
+        grid = compute_sample_grid(pan_range, tilt_layers, pan_step)
+        self.progress = {"total": len(grid), "current": 0, "state": "scanning"}
+        samples: List[Dict] = []
+
+        for idx, (pan, tilt) in enumerate(grid):
+            if self.cancelled:
+                break
+            try:
+                self.ptz.goto_exact_position(pan, tilt, zoom)
+                time.sleep(self.stabilize_time)
+                frame = self._wait_frame(timeout=5.0)
+                if frame is None:
+                    continue
+                filename = f"p{pan:.1f}_t{tilt:.1f}.jpg"
+                path = os.path.join(self.data_dir, "samples", filename)
+                cv2.imwrite(path, frame)
+                samples.append({
+                    "id": idx + 1,
+                    "pan": pan,
+                    "tilt": tilt,
+                    "zoom": zoom,
+                    "thumbnail": path,
+                })
+                self.progress["current"] = len(samples)
+                if progress_callback:
+                    progress_callback(dict(self.progress))
+            except Exception as exc:
+                logger.error("Error processing scan sample pan=%s tilt=%s: %s", pan, tilt, exc)
+                continue
+
+        panorama_path = self._build_equirectangular(samples, pan_range, tilt_layers)
+        self.progress["state"] = "cancelled" if self.cancelled else "done"
+        return {
+            "group_id": self.group_id,
+            "samples": samples,
+            "panorama_path": panorama_path,
+            "config": config,
+        }
+
+    def _wait_frame(self, timeout: float = 5.0) -> Optional[np.ndarray]:
+        deadline = time.time() + timeout
+        while time.time() < deadline:
+            frame = self.get_ptz_frame()
+            if frame is not None:
+                return frame
+            time.sleep(0.1)
+        return None
+
+    def _build_equirectangular(
+        self,
+        samples: List[Dict],
+        pan_range: Tuple[float, float],
+        tilt_layers: Tuple[float, ...],
+        width: int = 4096,
+        height: int = 2048,
+    ) -> Optional[str]:
+        if not samples:
+            return None
+        panorama = np.zeros((height, width, 3), dtype=np.uint8)
+        pan_start, pan_end = pan_range
+        for s in samples:
+            frame = cv2.imread(s["thumbnail"])
+            if frame is None:
+                continue
+            h, w = frame.shape[:2]
+            pan = s["pan"]
+            tilt = s["tilt"]
+            u_center = int(((pan - pan_start) / (pan_end - pan_start)) * width)
+            v_center = int(((90 - tilt) / 180) * height)
+            y_start = max(0, v_center - h // 2)
+            y_end = min(height, v_center + h // 2)
+            x_start = max(0, u_center - w // 2)
+            x_end = min(width, u_center + w // 2)
+            paste_h = y_end - y_start
+            paste_w = x_end - x_start
+            if paste_h <= 0 or paste_w <= 0:
+                continue
+            roi = frame[(h - paste_h) // 2:(h - paste_h) // 2 + paste_h,
+                        (w - paste_w) // 2:(w - paste_w) // 2 + paste_w]
+            panorama[y_start:y_end, x_start:x_end] = roi
+
+        path = os.path.join(self.data_dir, "panorama", f"scan_{int(time.time())}.jpg")
+        cv2.imwrite(path, panorama)
+        return path

+ 147 - 0
dual_camera_system/core/stream_manager.py

@@ -0,0 +1,147 @@
+"""多路 RTSP 取流管理 + MJPEG 输出."""
+import logging
+import threading
+import time
+from typing import Dict, Optional, Callable
+import cv2
+import numpy as np
+
+logger = logging.getLogger(__name__)
+
+
+class CameraStream:
+    """单个摄像头的 RTSP 流封装."""
+
+    def __init__(self, name: str, rtsp_url: str, reconnect_delay: float = 1.0):
+        self.name = name
+        self.rtsp_url = rtsp_url
+        self.reconnect_delay = reconnect_delay
+        self.latest_frame: Optional[np.ndarray] = None
+        self.marked_frame: Optional[np.ndarray] = None
+        self._stop_event = threading.Event()
+        self.thread: Optional[threading.Thread] = None
+        self.lock = threading.Lock()
+        self._last_error: Optional[str] = None
+
+    def start(self):
+        if self.thread and self.thread.is_alive():
+            return
+        self._stop_event.clear()
+        self.thread = threading.Thread(target=self._worker, daemon=True)
+        self.thread.start()
+
+    def stop(self):
+        self._stop_event.set()
+        if self.thread:
+            self.thread.join(timeout=5.0)
+            self.thread = None
+
+    def _worker(self):
+        # OpenCV read timeouts are best-effort and depend on the video backend;
+        # cap.set() may return False on some backends (e.g. FFmpeg) and is ignored.
+        reconnect_delay = self.reconnect_delay
+        cap = None
+        try:
+            while not self._stop_event.is_set():
+                try:
+                    if cap is not None:
+                        cap.release()
+                    cap = cv2.VideoCapture(self.rtsp_url)
+                    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
+                    if hasattr(cv2, 'CAP_PROP_OPEN_TIMEOUT_MSEC'):
+                        if not cap.set(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 10000):
+                            logger.warning("[%s] CAP_PROP_OPEN_TIMEOUT_MSEC not supported", self.name)
+                    if hasattr(cv2, 'CAP_PROP_READ_TIMEOUT_MSEC'):
+                        if not cap.set(cv2.CAP_PROP_READ_TIMEOUT_MSEC, 10000):
+                            logger.warning("[%s] CAP_PROP_READ_TIMEOUT_MSEC not supported", self.name)
+                    if not cap.isOpened():
+                        raise RuntimeError(f"Cannot open {self.rtsp_url}")
+                    while not self._stop_event.is_set():
+                        ret, frame = cap.read()
+                        if not ret:
+                            raise RuntimeError("Frame read failed")
+                        with self.lock:
+                            self.latest_frame = frame
+                        reconnect_delay = self.reconnect_delay
+                except Exception as e:
+                    with self.lock:
+                        self._last_error = str(e)
+                    time.sleep(reconnect_delay)
+                    reconnect_delay = min(reconnect_delay * 2, 30.0)
+        finally:
+            if cap is not None:
+                cap.release()
+
+    def get_frame(self) -> Optional[np.ndarray]:
+        with self.lock:
+            return self.latest_frame.copy() if self.latest_frame is not None else None
+
+    def set_marked_frame(self, frame: np.ndarray):
+        with self.lock:
+            self.marked_frame = frame.copy()
+
+    def get_marked_frame(self) -> Optional[np.ndarray]:
+        with self.lock:
+            return self.marked_frame.copy() if self.marked_frame is not None else None
+
+    @property
+    def last_error(self) -> Optional[str]:
+        with self.lock:
+            return self._last_error
+
+
+class StreamManager:
+    def __init__(self):
+        self._streams: Dict[str, CameraStream] = {}
+        self._lock = threading.Lock()
+
+    def register(self, stream_id: str, rtsp_url: str, reconnect_delay: float = 1.0) -> CameraStream:
+        with self._lock:
+            if stream_id not in self._streams:
+                stream = CameraStream(stream_id, rtsp_url, reconnect_delay=reconnect_delay)
+                self._streams[stream_id] = stream
+                stream.start()
+            return self._streams[stream_id]
+
+    def unregister(self, stream_id: str):
+        with self._lock:
+            stream = self._streams.pop(stream_id, None)
+            if stream:
+                stream.stop()
+
+    def get(self, stream_id: str) -> Optional[CameraStream]:
+        with self._lock:
+            return self._streams.get(stream_id)
+
+    def stop_all(self):
+        with self._lock:
+            streams = list(self._streams.values())
+            self._streams.clear()
+        for s in streams:
+            s.stop()
+
+
+def encode_mjpeg_frame(frame: np.ndarray, quality: int = 80) -> bytes:
+    """把 numpy 帧编码为 JPEG bytes。"""
+    encode_params = [int(cv2.IMWRITE_JPEG_QUALITY), quality]
+    ret, buf = cv2.imencode(".jpg", frame, encode_params)
+    if not ret:
+        raise RuntimeError("JPEG encode failed")
+    return buf.tobytes()
+
+
+def generate_mjpeg_stream(get_frame: Callable[[], Optional[np.ndarray]], fps: int = 15):
+    """MJPEG 流生成器,用于 FastAPI StreamingResponse。"""
+    period = 1.0 / fps
+    while True:
+        frame = get_frame()
+        if frame is None:
+            time.sleep(period)
+            continue
+        jpeg = encode_mjpeg_frame(frame)
+        yield (
+            b"--frame\r\n"
+            b"Content-Type: image/jpeg\r\n"
+            b"Content-Length: " + str(len(jpeg)).encode() + b"\r\n\r\n" + jpeg + b"\r\n"
+        )
+        time.sleep(period)

+ 12 - 0
dual_camera_system/data/scan_models.json

@@ -0,0 +1,12 @@
+{
+  "camera_groups": {
+    "group_1": {
+      "ptz_name": "group_1",
+      "panorama_name": "group_1",
+      "scan_config": {},
+      "samples": [],
+      "enabled_points": [],
+      "panorama": {}
+    }
+  }
+}

Разлика између датотеке није приказан због своје велике величине
+ 264 - 423
dual_camera_system/docs/技术实现架构.md


+ 0 - 467
dual_camera_system/dual_stream_manager.py

@@ -1,467 +0,0 @@
-"""
-双路流管理器
-
-独立管理全景和球机两路视频流,每路使用独立线程和独立锁,
-互不阻塞。提供统一帧获取接口、健康监控、自动重连。
-
-关键改进:
-- 两路流完全独立,一路卡住不影响另一路
-- 每路有自己的锁(per-camera lock),不再用全局 FFmpeg 锁
-- 超时读帧 + 自动重连 + 健康状态监控
-- 帧新鲜度追踪:确保读到的是最近帧而非缓冲区中的旧帧
-"""
-
-import os
-os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'threads;1'
-
-import cv2
-import threading
-import time
-import queue
-import logging
-from typing import Optional, Dict, Tuple, Callable
-from dataclasses import dataclass, field
-
-from video_lock import safe_read, safe_is_opened, get_stream_health, CameraLockManager
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass
-class StreamConfig:
-    """流配置"""
-    camera_id: str
-    rtsp_url: str
-    camera_type: str = "panorama"  # "panorama" 或 "ptz"
-    buffer_size: int = 2            # 帧缓冲大小(保持低延迟)
-    reconnect_interval: float = 5.0  # 重连间隔(秒)
-    max_consecutive_errors: int = 50 # 最大连续错误数
-    read_timeout: float = 2.0       # 读帧超时(秒)
-    fps_target: float = 25.0        # 目标帧率
-
-
-@dataclass
-class StreamStatus:
-    """流状态"""
-    is_running: bool = False
-    is_connected: bool = False
-    consecutive_errors: int = 0
-    total_frames: int = 0
-    total_errors: int = 0
-    last_frame_time: float = 0.0
-    avg_read_time_ms: float = 0.0
-    reconnect_count: int = 0
-    fps_actual: float = 0.0
-
-
-class StreamWorker:
-    """单路视频流工作线程"""
-    
-    def __init__(self, config: StreamConfig):
-        self.config = config
-        self._cap = None
-        self._current_frame = None
-        self._frame_lock = threading.Lock()
-        self._frame_queue = queue.Queue(maxsize=config.buffer_size)
-        self._running = False
-        self._thread = None
-        self._status = StreamStatus()
-        self._status_lock = threading.Lock()
-        self._last_fps_time = time.time()
-        self._fps_frame_count = 0
-    
-    @property
-    def status(self) -> StreamStatus:
-        with self._status_lock:
-            return StreamStatus(
-                is_running=self._status.is_running,
-                is_connected=self._status.is_connected,
-                consecutive_errors=self._status.consecutive_errors,
-                total_frames=self._status.total_frames,
-                total_errors=self._status.total_errors,
-                last_frame_time=self._status.last_frame_time,
-                avg_read_time_ms=self._status.avg_read_time_ms,
-                reconnect_count=self._status.reconnect_count,
-                fps_actual=self._status.fps_actual
-            )
-    
-    def start(self) -> bool:
-        """启动视频流"""
-        if self._running:
-            logger.warning(f"[{self.config.camera_id}] 流已在运行")
-            return True
-        
-        if not self._connect():
-            logger.error(f"[{self.config.camera_id}] 初始连接失败")
-            # 不返回False,启动重连线程
-            self._status.is_running = True
-            self._status.is_connected = False
-        else:
-            self._status.is_running = True
-            self._status.is_connected = True
-        
-        self._running = True
-        self._thread = threading.Thread(target=self._stream_loop, daemon=True)
-        self._thread.start()
-        logger.info(f"[{self.config.camera_id}] 流工作线程已启动")
-        return True
-    
-    def stop(self):
-        """停止视频流"""
-        self._running = False
-        if self._thread:
-            self._thread.join(timeout=3)
-            self._thread = None
-        
-        if self._cap is not None:
-            try:
-                self._cap.release()
-            except Exception:
-                pass
-            self._cap = None
-        
-        with self._status_lock:
-            self._status.is_running = False
-            self._status.is_connected = False
-        
-        logger.info(f"[{self.config.camera_id}] 流已停止")
-    
-    def get_frame(self) -> Optional[cv2.Mat]:
-        """获取最新帧(线程安全副本)"""
-        with self._frame_lock:
-            if self._current_frame is not None:
-                return self._current_frame.copy()
-            return None
-    
-    def get_frame_from_queue(self, timeout: float = 0.1) -> Optional[cv2.Mat]:
-        """从帧队列获取帧"""
-        try:
-            return self._frame_queue.get(timeout=timeout)
-        except queue.Empty:
-            return None
-    
-    def _connect(self) -> bool:
-        """建立RTSP连接"""
-        if self._cap is not None:
-            try:
-                self._cap.release()
-            except Exception:
-                pass
-            self._cap = None
-        
-        try:
-            self._cap = cv2.VideoCapture(self.config.rtsp_url, cv2.CAP_FFMPEG)
-            if not self._cap.isOpened():
-                # 尝试 GStreamer 后端
-                try:
-                    self._cap = cv2.VideoCapture(self.config.rtsp_url, cv2.CAP_GSTREAMER)
-                    if self._cap.isOpened():
-                        logger.info(f"[{self.config.camera_id}] 使用 GStreamer 后端连接成功")
-                        self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
-                        return True
-                except Exception:
-                    pass
-                
-                logger.error(f"[{self.config.camera_id}] 无法打开RTSP流")
-                self._cap = None
-                return False
-            
-            self._cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
-            logger.info(f"[{self.config.camera_id}] RTSP流连接成功")
-            return True
-            
-        except Exception as e:
-            logger.error(f"[{self.config.camera_id}] 连接异常: {e}")
-            self._cap = None
-            return False
-    
-    def _stream_loop(self):
-        """流工作主循环"""
-        import signal
-        if hasattr(signal, 'pthread_sigmask'):
-            try:
-                signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGINT})
-            except (AttributeError, OSError):
-                pass
-        
-        logger.info(f"[{self.config.camera_id}] 流循环开始")
-        
-        while self._running:
-            try:
-                # 如果未连接,尝试重连
-                if self._cap is None or not safe_is_opened(self._cap, self.config.camera_id):
-                    self._reconnect()
-                    continue
-                
-                # 读帧(使用独立锁+超时)
-                start_time = time.time()
-                ret, frame = safe_read(self._cap, self.config.camera_id, self.config.read_timeout)
-                
-                if not ret or frame is None:
-                    self._on_read_error()
-                    continue
-                
-                self._on_read_success(frame, start_time)
-                
-            except Exception as e:
-                err_str = str(e)
-                if 'async_lock' in err_str or 'Assertion' in err_str:
-                    logger.warning(f"[{self.config.camera_id}] FFmpeg内部错误,重建连接: {e}")
-                    self._reconnect()
-                else:
-                    logger.error(f"[{self.config.camera_id}] 流错误: {e}")
-                    time.sleep(0.5)
-    
-    def _on_read_success(self, frame, start_time: float):
-        """读帧成功处理"""
-        read_time_ms = (time.time() - start_time) * 1000
-        
-        with self._frame_lock:
-            self._current_frame = frame.copy()
-        
-        # 非阻塞写入帧队列,满则丢弃旧帧
-        try:
-            # 先清空队列确保只保留最新帧
-            while not self._frame_queue.empty():
-                try:
-                    self._frame_queue.get_nowait()
-                except queue.Empty:
-                    break
-            self._frame_queue.put(frame.copy(), block=False)
-        except queue.Full:
-            pass
-        
-        with self._status_lock:
-            self._status.consecutive_errors = 0
-            self._status.total_frames += 1
-            self._status.last_frame_time = time.time()
-            self._status.is_connected = True
-            alpha = 0.1
-            self._status.avg_read_time_ms = alpha * read_time_ms + (1 - alpha) * self._status.avg_read_time_ms
-            
-            # FPS 计算
-            self._fps_frame_count += 1
-            elapsed = time.time() - self._last_fps_time
-            if elapsed >= 1.0:
-                self._status.fps_actual = self._fps_frame_count / elapsed
-                self._fps_frame_count = 0
-                self._last_fps_time = time.time()
-    
-    def _on_read_error(self):
-        """读帧错误处理"""
-        with self._status_lock:
-            self._status.consecutive_errors += 1
-            self._status.total_errors += 1
-            
-            if self._status.consecutive_errors > self.config.max_consecutive_errors:
-                logger.warning(f"[{self.config.camera_id}] 连续{self._status.consecutive_errors}次读帧失败,触发重连")
-        
-        time.sleep(0.01)
-    
-    def _reconnect(self):
-        """重连"""
-        with self._status_lock:
-            self._status.reconnect_count += 1
-            self._status.is_connected = False
-        
-        logger.info(f"[{self.config.camera_id}] 尝试重连...")
-        
-        if self._cap is not None:
-            try:
-                self._cap.release()
-            except Exception:
-                pass
-            self._cap = None
-        
-        time.sleep(self.config.reconnect_interval)
-        
-        if self._connect():
-            logger.info(f"[{self.config.camera_id}] 重连成功")
-        else:
-            logger.warning(f"[{self.config.camera_id}] 重连失败,{self.config.reconnect_interval}秒后重试")
-    
-    @property
-    def is_healthy(self) -> bool:
-        """流是否健康"""
-        s = self.status
-        if not s.is_running:
-            return False
-        if s.consecutive_errors > 10:
-            return False
-        if s.last_frame_time > 0 and (time.time() - s.last_frame_time) > 5.0:
-            return False
-        return True
-
-
-class DualStreamManager:
-    """
-    双路流管理器
-    
-    管理全景和球机两路视频流,提供统一的帧获取接口。
-    每路流使用独立线程和独立锁,互不阻塞。
-    """
-    
-    PANORAMA_ID = "panorama"
-    PTZ_ID = "ptz"
-    
-    def __init__(self):
-        self._workers: Dict[str, StreamWorker] = {}
-        self._running = False
-    
-    def add_stream(self, config: StreamConfig) -> StreamWorker:
-        """添加一路视频流"""
-        if config.camera_id in self._workers:
-            logger.warning(f"流 {config.camera_id} 已存在,将先停止")
-            self._workers[config.camera_id].stop()
-        
-        worker = StreamWorker(config)
-        self._workers[config.camera_id] = worker
-        return worker
-    
-    def start_stream(self, camera_id: str) -> bool:
-        """启动指定流"""
-        if camera_id not in self._workers:
-            logger.error(f"流 {camera_id} 未注册")
-            return False
-        return self._workers[camera_id].start()
-    
-    def stop_stream(self, camera_id: str):
-        """停止指定流"""
-        if camera_id in self._workers:
-            self._workers[camera_id].stop()
-    
-    def start_all(self) -> bool:
-        """启动所有流"""
-        success = True
-        for camera_id, worker in self._workers.items():
-            if not worker.start():
-                logger.error(f"启动流 {camera_id} 失败")
-                success = False
-            else:
-                logger.info(f"流 {camera_id} 启动中...")
-        self._running = True
-        return success
-    
-    def stop_all(self):
-        """停止所有流"""
-        for camera_id, worker in self._workers.items():
-            worker.stop()
-        self._running = False
-    
-    def get_frame(self, camera_id: str) -> Optional[cv2.Mat]:
-        """获取指定摄像头的最新帧"""
-        if camera_id not in self._workers:
-            return None
-        return self._workers[camera_id].get_frame()
-    
-    def get_panorama_frame(self) -> Optional[cv2.Mat]:
-        """获取全景摄像头最新帧"""
-        return self.get_frame(self.PANORAMA_ID)
-    
-    def get_ptz_frame(self) -> Optional[cv2.Mat]:
-        """获取球机最新帧"""
-        return self.get_frame(self.PTZ_ID)
-    
-    def get_status(self, camera_id: str) -> Optional[StreamStatus]:
-        """获取指定流的状态"""
-        if camera_id not in self._workers:
-            return None
-        return self._workers[camera_id].status
-    
-    def get_all_status(self) -> Dict[str, StreamStatus]:
-        """获取所有流的状态"""
-        return {cid: w.status for cid, w in self._workers.items()}
-    
-    def is_healthy(self, camera_id: str) -> bool:
-        """检查指定流是否健康"""
-        if camera_id not in self._workers:
-            return False
-        return self._workers[camera_id].is_healthy
-    
-    def wait_for_frames(self, timeout: float = 15.0) -> Dict[str, bool]:
-        """
-        等待所有流就绪(获取到至少一帧)
-        
-        Returns:
-            Dict[camera_id, 是否有帧]
-        """
-        start_time = time.time()
-        ready = {cid: False for cid in self._workers}
-        
-        while time.time() - start_time < timeout:
-            all_ready = True
-            for cid in self._workers:
-                if not ready[cid]:
-                    frame = self.get_frame(cid)
-                    if frame is not None:
-                        ready[cid] = True
-                        logger.info(f"[{cid}] 帧就绪 ({frame.shape})")
-                if not ready[cid]:
-                    all_ready = False
-            
-            if all_ready:
-                break
-            
-            time.sleep(0.5)
-        
-        for cid, r in ready.items():
-            if not r:
-                logger.warning(f"[{cid}] 等待帧超时({timeout}s)")
-        
-        return ready
-    
-    def get_stream_health(self, camera_id: str):
-        """获取流健康详情(来自video_lock模块)"""
-        return get_stream_health(camera_id)
-    
-    @property
-    def panorama_worker(self) -> Optional[StreamWorker]:
-        """获取全景流工作线程"""
-        return self._workers.get(self.PANORAMA_ID)
-    
-    @property
-    def ptz_worker(self) -> Optional[StreamWorker]:
-        """获取球机流工作线程"""
-        return self._workers.get(self.PTZ_ID)
-    
-    @staticmethod
-    def build_rtsp_url(config: dict) -> str:
-        """从配置构建RTSP URL"""
-        if 'rtsp_url' in config and config['rtsp_url']:
-            return config['rtsp_url']
-        
-        channel = config.get('channel', 0)
-        return (f"rtsp://{config['username']}:{config['password']}"
-                f"@{config['ip']}:{config.get('rtsp_port', 554)}"
-                f"/cam/realmonitor?channel={channel + 1}&subtype=1")
-
-
-def create_dual_stream_manager(panorama_config: dict, ptz_config: dict) -> DualStreamManager:
-    """
-    便捷函数:根据摄像头配置创建双路流管理器
-    
-    Args:
-        panorama_config: 全景摄像头配置
-        ptz_config: 球机配置
-    
-    Returns:
-        已配置的 DualStreamManager 实例
-    """
-    manager = DualStreamManager()
-    
-    pan_rtsp = DualStreamManager.build_rtsp_url(panorama_config)
-    ptz_rtsp = DualStreamManager.build_rtsp_url(ptz_config)
-    
-    manager.add_stream(StreamConfig(
-        camera_id=DualStreamManager.PANORAMA_ID,
-        rtsp_url=pan_rtsp,
-        camera_type="panorama"
-    ))
-    
-    manager.add_stream(StreamConfig(
-        camera_id=DualStreamManager.PTZ_ID,
-        rtsp_url=ptz_rtsp,
-        camera_type="ptz"
-    ))
-    
-    return manager

+ 0 - 219
dual_camera_system/event_pusher.py

@@ -1,219 +0,0 @@
-"""
-事件推送模块
-
-当前仅保留跟踪抓拍事件的多图片上传与批量推送功能。
-OCR/LLM/安全违规相关事件推送已移除。
-"""
-
-import os
-import time
-import json
-import tempfile
-import threading
-import requests
-import mimetypes
-from typing import Optional, Dict, Any, List
-from datetime import datetime
-
-import cv2
-import numpy as np
-
-
-class EventPusher:
-    """
-    事件推送器
-    负责将跟踪抓拍事件推送到业务平台
-    """
-
-    def __init__(self, config: Optional[Dict[str, Any]] = None):
-        """
-        初始化事件推送器
-
-        Args:
-            config: 配置字典
-        """
-        self.config = config or {}
-
-        # API 配置
-        self.api_host = self.config.get('api_host', 'jtjai.device.wenhq.top')
-        self.api_port = self.config.get('api_port', 8583)
-        self.use_https = self.config.get('use_https', True)
-
-        # 基础 URL(优先使用配置中的 base_url)
-        self.base_url = self.config.get('base_url')
-        if not self.base_url:
-            protocol = 'https' if self.use_https else 'http'
-            self.base_url = f"{protocol}://{self.api_host}:{self.api_port}"
-
-        # 上传接口
-        self.upload_url = self.config.get('upload_url', '/api/resource/oss/upload')
-        self.event_url = self.config.get('event_url', '/api/system/event')
-
-        # 推送控制
-        self.enabled = self.config.get('enabled', True)
-        self.retry_count = self.config.get('retry_count', 3)
-        self.retry_delay = self.config.get('retry_delay', 1.0)
-
-        # 工作线程
-        self.running = False
-
-        # 统计
-        self.stats = {
-            'total_events': 0,
-            'pushed_events': 0,
-            'failed_events': 0,
-            'upload_success': 0,
-            'upload_failed': 0
-        }
-        self.stats_lock = threading.Lock()
-
-    def start(self):
-        """启动推送器(当前为无状态接口调用,保留方法便于系统统一生命周期管理)"""
-        self.running = True
-        print("事件推送器已启动")
-
-    def stop(self):
-        """停止推送器"""
-        self.running = False
-        print("事件推送器已停止")
-
-    def upload_numpy_image(self, image: Optional[np.ndarray]) -> Optional[str]:
-        """
-        将 numpy 图片上传到 OSS
-
-        Args:
-            image: numpy 图像数组
-
-        Returns:
-            图片URL或None
-        """
-        if image is None:
-            return None
-
-        temp_path = None
-        try:
-            with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
-                temp_path = f.name
-
-            cv2.imwrite(temp_path, image)
-            url = self._upload_image(temp_path)
-            return url
-        except Exception as e:
-            print(f"上传 numpy 图片失败: {e}")
-            return None
-        finally:
-            if temp_path:
-                try:
-                    os.remove(temp_path)
-                except Exception:
-                    pass
-
-    def push_tracking_capture(self, batch_time: float, captures: List[Dict[str, Any]]) -> Optional[requests.Response]:
-        """
-        推送一轮多目标跟踪抓拍事件
-
-        Args:
-            batch_time: 批次时间戳
-            captures: 抓拍记录列表
-
-        Returns:
-            响应对象或None
-        """
-        payload = {
-            "eventType": "TRACKING_CAPTURE",
-            "eventTime": datetime.fromtimestamp(batch_time).isoformat(),
-            "deviceId": self.config.get("device_id"),
-            "data": {
-                "captureCount": len(captures),
-                "captures": captures,
-            }
-        }
-        url = f"{self.base_url}{self.event_url}"
-
-        with self.stats_lock:
-            self.stats['total_events'] += 1
-
-        response = self._post(url, payload)
-        if response is not None:
-            with self.stats_lock:
-                self.stats['pushed_events'] += 1
-        else:
-            with self.stats_lock:
-                self.stats['failed_events'] += 1
-
-        return response
-
-    def _post(self, url: str, json_data: Dict[str, Any]) -> Optional[requests.Response]:
-        """
-        发送 POST 请求
-
-        Args:
-            url: 请求URL
-            json_data: JSON数据
-
-        Returns:
-            响应对象或None
-        """
-        for attempt in range(self.retry_count):
-            try:
-                response = requests.post(url, json=json_data, verify=False, timeout=10)
-                return response
-            except Exception as e:
-                print(f"POST 请求异常 (尝试 {attempt + 1}/{self.retry_count}): {e}")
-                if attempt < self.retry_count - 1:
-                    time.sleep(self.retry_delay)
-
-        return None
-
-    def _upload_image(self, image_path: str) -> Optional[str]:
-        """
-        上传图片到 OSS
-
-        Args:
-            image_path: 图片路径
-
-        Returns:
-            图片URL或None
-        """
-        if not os.path.exists(image_path):
-            return None
-
-        filename = os.path.basename(image_path)
-        content_type = mimetypes.guess_type(image_path)[0] or 'image/jpeg'
-        url = f"{self.base_url}{self.upload_url}"
-
-        for attempt in range(self.retry_count):
-            try:
-                with open(image_path, 'rb') as f:
-                    files = {'file': (filename, f, content_type)}
-                    response = requests.post(
-                        url,
-                        files=files,
-                        headers={'User-Agent': 'SafetySystem/1.0'},
-                        verify=False,
-                        timeout=10
-                    )
-
-                if response.status_code == 200:
-                    result = response.json()
-                    if result.get('code') == 200:
-                        with self.stats_lock:
-                            self.stats['upload_success'] += 1
-                        return result.get('data', {}).get('purl')
-                    else:
-                        print(f"上传失败: {result.get('msg', '未知错误')}")
-                else:
-                    print(f"上传失败: HTTP {response.status_code}")
-            except Exception as e:
-                print(f"上传异常 (尝试 {attempt + 1}/{self.retry_count}): {e}")
-                if attempt < self.retry_count - 1:
-                    time.sleep(self.retry_delay)
-
-        with self.stats_lock:
-            self.stats['upload_failed'] += 1
-        return None
-
-    def get_stats(self) -> Dict[str, int]:
-        """获取统计信息"""
-        with self.stats_lock:
-            return self.stats.copy()

+ 27 - 269
dual_camera_system/main.py

@@ -1,279 +1,37 @@
-"""
-双摄像头联动抓拍系统 - 主程序
-
-系统功能:
-1. 多组全景摄像头实时监控和人体检测
-2. 检测到人体后,球机自动变焦定位并抓拍
-3. 配对图片保存与上传
-
-注意:本版本仅保留多组摄像头联动模式,OCR/LLM/安全帽/反光衣识别已移除。
-"""
-
-# 必须在import cv2之前设置,否则FFmpeg多线程解码会导致
-# "Assertion fctx->async_lock failed at pthread_frame.c:167" 崩溃
-import os
-os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'rtsp_transport;tcp|threads;1'
-
-import sys
-import time
-import glob
+"""FastAPI 入口."""
 import argparse
-import logging
-import threading
-import signal
-
-# 添加项目路径
-sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
-
-from config import LOG_CONFIG, get_enabled_groups
-
-
-# 配置日志 - 使用LOG_CONFIG
-def _cleanup_old_logs(log_file: str, retention_days: int):
-    """清理超过保留天数的日志文件"""
-    if not log_file:
-        return
-
-    log_dir = os.path.dirname(log_file) or '.'
-    log_basename = os.path.basename(log_file)
-
-    # 匹配所有轮转的日志文件:app.log, app.log.1, app.log.2, ...
-    patterns = [
-        log_basename,
-        f"{log_basename}.*",
-        f"{os.path.splitext(log_basename)[0]}.*",  # 处理没有扩展名的情况
-    ]
-
-    now = time.time()
-    cutoff = now - (retention_days * 86400)
-
-    for pattern in patterns:
-        full_pattern = os.path.join(log_dir, pattern)
-        for log_path in glob.glob(full_pattern):
-            try:
-                if os.path.isfile(log_path):
-                    mtime = os.path.getmtime(log_path)
-                    if mtime < cutoff:
-                        os.remove(log_path)
-                        print(f"[日志清理] 已删除过期日志: {log_path}")
-            except Exception:
-                pass  # 忽略删除失败的日志文件
-
-
-def _log_cleanup_worker(retention_days: int, interval_hours: int = 6):
-    """日志清理后台线程"""
-    log_file = LOG_CONFIG.get('file')
-    if not log_file:
-        return
-
-    while True:
-        _cleanup_old_logs(log_file, retention_days)
-        time.sleep(interval_hours * 3600)
-
-
-def setup_logging():
-    """设置日志配置"""
-    log_level = getattr(logging, LOG_CONFIG.get('level', 'INFO'), logging.INFO)
-    log_format = LOG_CONFIG.get('format', '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
-    log_file = LOG_CONFIG.get('file')
-    retention_days = LOG_CONFIG.get('retention_days', 7)
-
-    handlers = [logging.StreamHandler()]
-
-    if log_file:
-        # 确保日志目录存在
-        log_dir = os.path.dirname(log_file)
-        if log_dir:
-            os.makedirs(log_dir, exist_ok=True)
-
-        from logging.handlers import RotatingFileHandler
-        file_handler = RotatingFileHandler(
-            log_file,
-            maxBytes=LOG_CONFIG.get('max_bytes', 10*1024*1024),
-            backupCount=LOG_CONFIG.get('backup_count', 5)
-        )
-        file_handler.setFormatter(logging.Formatter(log_format))
-        handlers.append(file_handler)
-
-        # 启动日志清理后台线程
-        cleanup_thread = threading.Thread(
-            target=_log_cleanup_worker,
-            args=(retention_days, 6),
-            daemon=True
-        )
-        cleanup_thread.start()
-
-    logging.basicConfig(
-        level=log_level,
-        format=log_format,
-        handlers=handlers
-    )
-
-
-setup_logging()
-logger = logging.getLogger(__name__)
-
-# 全局停止标志(用于信号处理)
-_shutdown_requested = False
-
-
-def _signal_handler(signum, frame):
-    """信号处理函数"""
-    global _shutdown_requested
-    sig_name = signal.Signals(signum).name
-    logger.info(f"接收到信号 {sig_name},准备优雅退出...")
-    print(f"\n[信号] 接收到 {sig_name},准备停止...")
-    _shutdown_requested = True
-
-
-def run_multi_group_mode(args):
-    """多组摄像头模式(当前唯一支持的模式)"""
-    global _shutdown_requested
-    from multi_group_system import MultiGroupSystem
-
-    # 注册信号处理
-    signal.signal(signal.SIGINT, _signal_handler)
-    signal.signal(signal.SIGTERM, _signal_handler)
-    _shutdown_requested = False
-
-    print("\n" + "=" * 60)
-    print("多组摄像头联动抓拍系统")
-    print("=" * 60)
-
-    enabled_groups = get_enabled_groups()
-    print(f"启用的摄像头组: {len(enabled_groups)} 个")
-    for g in enabled_groups:
-        print(f"  - {g.get('name', g.get('group_id'))}")
-    print()
-
-    # 构建配置
-    config = {
-        'model_size': args.model_size,
-        'use_gpu': not args.no_gpu,
-    }
-
-    if args.model:
-        config['model_path'] = args.model
-
-    # 命令行覆盖第一个启用组的摄像头 IP/凭证
-    if args.panorama_ip or args.ptz_ip:
-        if enabled_groups:
-            first_group = enabled_groups[0]
-            if args.panorama_ip and 'panorama' in first_group:
-                first_group['panorama']['ip'] = args.panorama_ip
-                first_group['panorama']['username'] = args.username
-                first_group['panorama']['password'] = args.password
-            if args.ptz_ip and 'ptz' in first_group:
-                first_group['ptz']['ip'] = args.ptz_ip
-                first_group['ptz']['username'] = args.username
-                first_group['ptz']['password'] = args.password
-
-    # 创建多组系统
-    system = MultiGroupSystem(config)
-
-    try:
-        # 初始化
-        if not system.initialize(skip_calibration=args.skip_calibration,
-                                 force_calibration=args.force_calibration):
-            print("\n系统初始化失败!")
-            return 1
-
-        # 启动
-        print("\n启动多组联动系统...")
-        if not system.start():
-            print("启动失败")
-            return 1
-
-        print(f"\n多组摄像头系统运行中 ({len(system.groups)} 个组)")
-        print("按 Ctrl+C 停止\n")
-
-        # 等待(检查停止标志)
-        while system.running and not _shutdown_requested:
-            time.sleep(1)
-
-    except KeyboardInterrupt:
-        print("\n接收到停止信号")
-    finally:
-        print("正在停止系统...")
-        system.stop()
-
-    return 0
-
-
-def run_demo():
-    """演示模式"""
-    print("\n演示模式 - 双摄像头联动系统")
-    print("=" * 60)
-    print("""
-系统架构:
-┌─────────────────────────────────────────────────────────────┐
-│                    全景摄像头 (Panorama)                     │
-│  ┌─────────┐    ┌─────────┐    ┌─────────┐                 │
-│  │ 视频流  │ -> │ 人体检测 │ -> │ 位置计算 │                 │
-│  └─────────┘    └─────────┘    └─────────┘                 │
-└─────────────────────────────────────────────────────────────┘
-                          │
-                          ▼ 检测到人体位置 (x_ratio, y_ratio)
-┌─────────────────────────────────────────────────────────────┐
-│                      球机 (PTZ Camera)                       │
-│  ┌─────────┐    ┌─────────┐    ┌─────────┐                 │
-│  │ PTZ控制 │ -> │ 精确定位 │ -> │ 变焦放大 │                 │
-│  └─────────┘    └─────────┘    └─────────┘                 │
-└─────────────────────────────────────────────────────────────┘
-
-工作流程:
-1. 全景摄像头实时获取视频流
-2. 使用YOLO11检测画面中的人体
-3. 计算人体在画面中的相对位置
-4. 控制球机PTZ移动到对应位置
-5. 球机变焦放大人体区域并保存配对图片
-
-主要组件:
-- multi_group_system.py: 多组系统管理器
-- camera_group.py: 摄像头组封装
-- dahua_sdk.py: 大华SDK封装
-- panorama_camera.py: 全景摄像头和人体检测
-- ptz_camera.py: 球机PTZ控制
-- coordinator.py: 联动控制逻辑
-""")
+import os
 
-    print("=" * 60)
-    print("\n使用方法:")
-    print("  python main.py")
-    print("  python main.py --skip-calibration")
-    print("  python main.py --demo")
+import uvicorn
+from app import create_app
 
 
 def main():
-    """主函数"""
-    parser = argparse.ArgumentParser(description='双摄像头联动抓拍系统(多组模式)')
-
-    # 摄像头覆盖参数(应用于 config/camera.py 中第一个启用的组)
-    parser.add_argument('--panorama-ip', type=str, help='全景摄像头IP')
-    parser.add_argument('--ptz-ip', type=str, help='球机IP')
-    parser.add_argument('--username', type=str, default='admin', help='用户名')
-    parser.add_argument('--password', type=str, default='admin123', help='密码')
-
-    # 模型与运行参数
-    parser.add_argument('--model', type=str, help='检测模型路径')
-    parser.add_argument('--model-size', type=str, default='n',
-                        choices=['n', 's', 'm', 'l', 'x'],
-                        help='YOLO11模型尺寸 (n/s/m/l/x)')
-    parser.add_argument('--no-gpu', action='store_true', help='不使用GPU')
-    parser.add_argument('--demo', action='store_true', help='演示模式(不连接实际摄像头)')
-    parser.add_argument('--skip-calibration', action='store_true', help='跳过自动校准')
-    parser.add_argument('--force-calibration', action='store_true', help='强制重新校准')
-
+    parser = argparse.ArgumentParser(description="PTZ 360 scan polling system")
+    parser.add_argument("--demo", action="store_true", help="Demo mode (no-op currently)")
+    parser.add_argument("--host", default=os.environ.get("HOST", "0.0.0.0"))
+    parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", 8000)))
+    parser.add_argument("--model", default=None, help="Path to detection model")
+    parser.add_argument(
+        "--model-size", default=None, choices=["n", "s", "m", "l", "x"],
+        help="YOLO11 model size",
+    )
+    parser.add_argument("--no-gpu", action="store_true", help="Disable GPU/NPU")
     args = parser.parse_args()
 
-    # 演示模式
-    if args.demo:
-        print("演示模式: 使用模拟数据")
-        run_demo()
-        return
+    # Forward model args to detection config when provided
+    if args.model or args.model_size or args.no_gpu:
+        from config import detection
+        if args.model:
+            detection.DETECTION_CONFIG["model_path"] = args.model
+        if args.model_size:
+            detection.DETECTION_CONFIG["model_size"] = args.model_size
+        if args.no_gpu:
+            detection.DETECTION_CONFIG["use_gpu"] = False
 
-    return run_multi_group_mode(args)
+    app = create_app()
+    uvicorn.run(app, host=args.host, port=args.port)
 
 
-if __name__ == '__main__':
-    sys.exit(main() or 0)
+if __name__ == "__main__":
+    main()

+ 0 - 297
dual_camera_system/multi_group_system.py

@@ -1,297 +0,0 @@
-"""
-多组摄像头系统管理器
-管理多组(全景+球机)摄像头的并行独立运行
-"""
-
-import os
-import sys
-import time
-import logging
-import threading
-from typing import List, Optional, Dict, Any
-from concurrent.futures import ThreadPoolExecutor
-
-# 添加项目路径
-sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
-
-from config import (
-    CAMERA_GROUPS, get_enabled_groups,
-    SDK_PATH, DETECTION_CONFIG, COORDINATOR_CONFIG, 
-    CALIBRATION_CONFIG, SYSTEM_CONFIG
-)
-from dahua_sdk import DahuaSDK
-from panorama_camera import ObjectDetector
-from camera_group import CameraGroup
-
-logger = logging.getLogger(__name__)
-
-
-class MultiGroupSystem:
-    """
-    多组摄像头系统管理器
-    
-    管理多组(全景+球机)摄像头:
-    - 共享 SDK 实例(大华SDK只需初始化一次)
-    - 共享检测器(YOLO模型只加载一次)
-    - 各组并行独立运行检测和抓拍
-    """
-    
-    def __init__(self, config_override: dict = None):
-        """
-        初始化多组系统
-        
-        Args:
-            config_override: 配置覆盖
-        """
-        self.config = config_override or {}
-        
-        # 共享组件
-        self.sdk: Optional[DahuaSDK] = None
-        self.detector: Optional[ObjectDetector] = None
-        
-        # 摄像头组列表
-        self.groups: List[CameraGroup] = []
-        
-        # 运行状态
-        self.running = False
-        self.initialized = False
-        
-        logger.info("[MultiGroupSystem] 多组摄像头系统实例创建")
-    
-    def initialize(self, skip_calibration: bool = False,
-                   force_calibration: bool = False) -> bool:
-        """
-        初始化系统(SDK + 检测器 + 所有组)
-        
-        Args:
-            skip_calibration: 是否跳过校准
-            force_calibration: 是否强制重新校准
-            
-        Returns:
-            是否成功
-        """
-        logger.info("=" * 60)
-        logger.info("[MultiGroupSystem] 开始初始化多组摄像头系统...")
-        logger.info("=" * 60)
-        
-        # 1. 初始化检测器(先于SDK,避免内存冲突)
-        try:
-            self.detector = ObjectDetector(
-                model_path=self.config.get('model_path', DETECTION_CONFIG.get('model_path')),
-                use_gpu=self.config.get('use_gpu', DETECTION_CONFIG.get('use_gpu', True)),
-                model_size=self.config.get('model_size', 'n'),
-                model_type=self.config.get('model_type', DETECTION_CONFIG.get('model_type', 'auto'))
-            )
-            logger.info("[MultiGroupSystem] 检测器初始化成功")
-        except Exception as e:
-            logger.warning(f"[MultiGroupSystem] 检测器初始化失败: {e}")
-        
-        # 2. 初始化SDK
-        sdk_path = os.path.join(
-            self.config.get('sdk_path', SDK_PATH['lib_path']),
-            self.config.get('netsdk', SDK_PATH['netsdk'])
-        )
-        
-        try:
-            self.sdk = DahuaSDK(sdk_path)
-            if not self.sdk.init():
-                logger.error("[MultiGroupSystem] SDK初始化失败")
-                return False
-            logger.info("[MultiGroupSystem] SDK初始化成功")
-        except Exception as e:
-            logger.error(f"[MultiGroupSystem] SDK加载失败: {e}")
-            return False
-        
-        # 4. 获取启用的摄像头组配置
-        enabled_groups = get_enabled_groups()
-        if not enabled_groups:
-            logger.error("[MultiGroupSystem] 没有启用的摄像头组!请在 config/camera.py 中配置 CAMERA_GROUPS")
-            return False
-        
-        logger.info(f"[MultiGroupSystem] 检测到 {len(enabled_groups)} 个启用的摄像头组")
-        
-        # 5. 初始化各组
-        shared_config = {
-            'coordinator_config': COORDINATOR_CONFIG,
-            'calibration_config': CALIBRATION_CONFIG,
-        }
-        
-        success_count = 0
-        for group_config in enabled_groups:
-            group_id = group_config.get('group_id', 'unknown')
-            logger.info(f"[MultiGroupSystem] 初始化组: {group_id}")
-            
-            group = CameraGroup(
-                group_config=group_config,
-                sdk=self.sdk,
-                detector=self.detector,
-                shared_config=shared_config
-            )
-            
-            if group.initialize(skip_calibration=skip_calibration,
-                                force_calibration=force_calibration):
-                self.groups.append(group)
-                success_count += 1
-                logger.info(f"[MultiGroupSystem] 组 {group_id} 初始化成功")
-            else:
-                logger.error(f"[MultiGroupSystem] 组 {group_id} 初始化失败")
-        
-        if success_count == 0:
-            logger.error("[MultiGroupSystem] 所有组初始化失败!")
-            return False
-        
-        logger.info(f"[MultiGroupSystem] 成功初始化 {success_count}/{len(enabled_groups)} 个组")
-        self.initialized = True
-        
-        return True
-    
-    def start(self) -> bool:
-        """
-        并行启动所有组
-        
-        Returns:
-            是否成功
-        """
-        if not self.initialized:
-            logger.error("[MultiGroupSystem] 系统未初始化,无法启动")
-            return False
-        
-        logger.info(f"[MultiGroupSystem] 启动 {len(self.groups)} 个摄像头组...")
-        
-        success_count = 0
-        for group in self.groups:
-            if group.start():
-                success_count += 1
-            else:
-                logger.error(f"[MultiGroupSystem] 组 {group.group_id} 启动失败")
-        
-        if success_count == 0:
-            logger.error("[MultiGroupSystem] 所有组启动失败!")
-            return False
-        
-        self.running = True
-        logger.info(f"[MultiGroupSystem] 成功启动 {success_count}/{len(self.groups)} 个组")
-        
-        # 启动定时校准
-        if CALIBRATION_CONFIG.get('enable_scheduled_calibration', False):
-            interval_hours = CALIBRATION_CONFIG.get('interval', 24 * 60 * 60) // 3600
-            daily_time = CALIBRATION_CONFIG.get('daily_calibration_time', '08:00')
-            for group in self.groups:
-                group.start_scheduled_calibration(
-                    interval_hours=interval_hours,
-                    daily_time=daily_time
-                )
-        else:
-            logger.info("[MultiGroupSystem] 定时校准已禁用")
-        
-        return True
-    
-    def stop(self):
-        """停止所有组"""
-        logger.info("[MultiGroupSystem] 停止所有摄像头组...")
-        
-        self.running = False
-        
-        for group in self.groups:
-            try:
-                group.stop()
-            except Exception as e:
-                logger.error(f"[MultiGroupSystem] 停止组 {group.group_id} 失败: {e}")
-        
-        # 清理SDK
-        if self.sdk:
-            try:
-                self.sdk.cleanup()
-            except Exception as e:
-                logger.error(f"[MultiGroupSystem] SDK清理失败: {e}")
-        
-        logger.info("[MultiGroupSystem] 系统已停止")
-    
-    def get_status(self) -> Dict[str, Any]:
-        """获取系统状态"""
-        return {
-            'running': self.running,
-            'initialized': self.initialized,
-            'total_groups': len(self.groups),
-            'groups': [group.get_status() for group in self.groups],
-        }
-    
-    def calibrate_all(self, force: bool = False) -> bool:
-        """
-        校准所有组
-        
-        Args:
-            force: 是否强制重新校准
-            
-        Returns:
-            是否全部成功
-        """
-        logger.info(f"[MultiGroupSystem] 开始校准所有组...")
-        
-        success_count = 0
-        for group in self.groups:
-            logger.info(f"[MultiGroupSystem] 校准组: {group.group_id}")
-            if group._auto_calibrate(force=force):
-                success_count += 1
-        
-        logger.info(f"[MultiGroupSystem] 校准完成: {success_count}/{len(self.groups)} 组成功")
-        return success_count == len(self.groups)
-    
-    def wait(self):
-        """等待系统运行(阻塞)"""
-        try:
-            while self.running:
-                time.sleep(1)
-        except KeyboardInterrupt:
-            logger.info("[MultiGroupSystem] 收到中断信号")
-            self.stop()
-
-
-def main():
-    """多组系统主入口"""
-    import argparse
-    
-    parser = argparse.ArgumentParser(description='多组摄像头联动抓拍系统')
-    parser.add_argument('--skip-calibration', action='store_true', help='跳过自动校准')
-    parser.add_argument('--calibrate-only', action='store_true', help='仅执行校准后退出')
-    args = parser.parse_args()
-    
-    # 设置日志
-    logging.basicConfig(
-        level=logging.INFO,
-        format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
-    )
-    
-    # 创建系统
-    system = MultiGroupSystem()
-    
-    # 初始化
-    if not system.initialize(skip_calibration=args.skip_calibration):
-        print("系统初始化失败!")
-        return 1
-    
-    # 仅校准模式
-    if args.calibrate_only:
-        print("校准完成,退出。")
-        system.stop()
-        return 0
-    
-    # 启动
-    if not system.start():
-        print("系统启动失败!")
-        return 1
-    
-    print(f"\n多组摄像头系统已启动 ({len(system.groups)} 个组)")
-    print("按 Ctrl+C 停止\n")
-    
-    # 等待
-    try:
-        system.wait()
-    except KeyboardInterrupt:
-        pass
-    
-    return 0
-
-
-if __name__ == '__main__':
-    sys.exit(main())

+ 0 - 428
dual_camera_system/oss_uploader.py

@@ -1,428 +0,0 @@
-"""
-OSS 上传模块
-支持兼容 S3 的对象存储(MinIO、AWS S3、阿里云 OSS 等)
-"""
-
-import os
-import time
-import json
-import logging
-import threading
-import queue
-from typing import Optional, Dict, Any, Tuple, List
-from pathlib import Path
-from datetime import datetime
-from dataclasses import dataclass, field
-
-import cv2
-import numpy as np
-
-logger = logging.getLogger(__name__)
-
-# 尝试导入 boto3
-try:
-    import boto3
-    from botocore.exceptions import ClientError
-    BOTO3_AVAILABLE = True
-except ImportError:
-    BOTO3_AVAILABLE = False
-    logger.warning("boto3 未安装,S3 兼容存储功能不可用")
-
-
-@dataclass
-class UploadTask:
-    """上传任务"""
-    local_path: str
-    oss_key: str
-    batch_id: str
-    image_type: str  # 'panorama' 或 'ptz'
-    person_index: Optional[int] = None  # 仅用于 PTZ 图片
-    retry_count: int = 0
-    callback: Optional[callable] = None
-
-
-@dataclass
-class UploadResult:
-    """上传结果"""
-    success: bool
-    local_path: str
-    oss_key: str
-    oss_url: str
-    error: Optional[str] = None
-
-
-class OSSUploader:
-    """
-    OSS 上传器
-    支持兼容 S3 的对象存储(MinIO、AWS S3、阿里云 OSS 等)
-    """
-    
-    def __init__(self, config: Dict[str, Any] = None):
-        """
-        初始化 OSS 上传器
-        
-        Args:
-            config: OSS 配置字典
-        """
-        from config import S3_COMPATIBLE_CONFIG
-        
-        self.config = config or S3_COMPATIBLE_CONFIG
-        
-        self.enabled = self.config.get('enabled', False)
-        logger.info(f"[OSS] OSSUploader 初始化: enabled={self.enabled}, config={self.config}")
-        
-        # S3 客户端
-        self.s3_client = None
-        
-        # 上传队列
-        self.upload_queue = queue.Queue()
-        self.result_queue = queue.Queue()
-        
-        # 工作线程
-        self.running = False
-        self.worker_thread = None
-        
-        # 统计
-        self.stats = {
-            'total_uploads': 0,
-            'success_uploads': 0,
-            'failed_uploads': 0,
-        }
-        self.stats_lock = threading.Lock()
-        
-        # 初始化客户端
-        if self.enabled:
-            self._init_client()
-    
-    def _init_client(self):
-        """初始化 OSS 客户端"""
-        logger.info("[OSS] _init_client 开始...")
-        try:
-            self._init_s3_client()
-            logger.info(f"[OSS] _init_client 完成: enabled={self.enabled}")
-        except Exception as e:
-            logger.error(f"[OSS] 初始化客户端失败: {e}")
-            self.enabled = False
-    
-    def _init_s3_client(self):
-        """初始化 S3 兼容客户端"""
-        logger.info(f"[OSS] _init_s3_client 开始: boto3={BOTO3_AVAILABLE}, enabled={self.config.get('enabled', False)}")
-
-        if not BOTO3_AVAILABLE:
-            logger.error("[OSS] boto3 未安装")
-            self.enabled = False
-            return
-
-        if not self.config.get('enabled', False):
-            logger.error("[OSS] S3 兼容配置未启用")
-            self.enabled = False
-            return
-
-        endpoint_url = self.config.get('endpoint_url', '')
-        region_name = self.config.get('region_name', 'us-east-1')
-        access_key_id = self.config.get('access_key_id', '')
-        secret_access_key = self.config.get('secret_access_key', '')
-
-        logger.info(f"[OSS] 配置检查: endpoint={endpoint_url}, key={access_key_id[:4] if access_key_id else 'None'}..., bucket={self.config.get('bucket_name', '')}")
-
-        if not all([endpoint_url, access_key_id, secret_access_key]):
-            logger.error("[OSS] S3 配置不完整")
-            self.enabled = False
-            return
-
-        logger.info("[OSS] 尝试创建 S3 客户端...")
-        self.s3_client = boto3.client(
-            's3',
-            endpoint_url=endpoint_url,
-            region_name=region_name,
-            aws_access_key_id=access_key_id,
-            aws_secret_access_key=secret_access_key,
-            use_ssl=self.config.get('use_ssl', True),
-            verify=False
-        )
-        
-        logger.info(f"[OSS] S3 客户端初始化成功: {endpoint_url}")
-    
-    def start(self):
-        """启动上传器"""
-        logger.info(f"[OSS] start() 被调用: enabled={self.enabled}, running={self.running}")
-
-        if not self.enabled:
-            logger.info("[OSS] 上传器未启用,直接返回")
-            return
-
-        if self.running:
-            logger.info("[OSS] 上传器已经在运行")
-            return
-
-        self.running = True
-        self.worker_thread = threading.Thread(target=self._worker_loop, daemon=True)
-        self.worker_thread.start()
-        logger.info("[OSS] 上传器已启动")
-    
-    def stop(self):
-        """停止上传器"""
-        self.running = False
-        if self.worker_thread:
-            self.worker_thread.join(timeout=5)
-        logger.info("[OSS] 上传器已停止")
-    
-    def _worker_loop(self):
-        """工作线程循环"""
-        while self.running:
-            try:
-                task = self.upload_queue.get(timeout=1.0)
-                self._process_upload(task)
-            except queue.Empty:
-                continue
-            except Exception as e:
-                logger.error(f"[OSS] 上传处理错误: {e}")
-    
-    def _process_upload(self, task: UploadTask):
-        """处理单个上传任务"""
-        result = self._upload_file(task)
-        
-        with self.stats_lock:
-            self.stats['total_uploads'] += 1
-            if result.success:
-                self.stats['success_uploads'] += 1
-            else:
-                self.stats['failed_uploads'] += 1
-        
-        # 调用回调
-        if task.callback:
-            try:
-                task.callback(result)
-            except Exception as e:
-                logger.error(f"[OSS] 回调执行错误: {e}")
-        
-        # 放入结果队列
-        self.result_queue.put(result)
-    
-    def _upload_file(self, task: UploadTask) -> UploadResult:
-        """
-        上传文件到 OSS
-        
-        Args:
-            task: 上传任务
-            
-        Returns:
-            UploadResult: 上传结果
-        """
-        local_path = task.local_path
-        oss_key = task.oss_key
-        
-        if not os.path.exists(local_path):
-            return UploadResult(
-                success=False,
-                local_path=local_path,
-                oss_key=oss_key,
-                oss_url='',
-                error='文件不存在'
-            )
-        
-        max_retries = self.config.get('retry_times', 3)
-        
-        for attempt in range(max_retries):
-            try:
-                if self.s3_client:
-                    # S3 兼容上传
-                    bucket_name = self.config.get('bucket_name', '')
-                    self.s3_client.upload_file(local_path, bucket_name, oss_key)
-                    
-                    # 生成 URL
-                    custom_domain = self.config.get('custom_domain', '')
-                    if custom_domain:
-                        oss_url = f"{custom_domain}/{oss_key}"
-                    else:
-                        endpoint_url = self.config.get('endpoint_url', '')
-                        oss_url = f"{endpoint_url}/{bucket_name}/{oss_key}"
-                else:
-                    return UploadResult(
-                        success=False,
-                        local_path=local_path,
-                        oss_key=oss_key,
-                        oss_url='',
-                        error='OSS 客户端未初始化'
-                    )
-                
-                logger.info(f"[OSS] 上传成功: {local_path} -> {oss_key}")
-                
-                return UploadResult(
-                    success=True,
-                    local_path=local_path,
-                    oss_key=oss_key,
-                    oss_url=oss_url
-                )
-                
-            except Exception as e:
-                error_msg = str(e)
-                logger.warning(f"[OSS] 上传失败 (尝试 {attempt + 1}/{max_retries}): {error_msg}")
-                
-                if attempt < max_retries - 1:
-                    time.sleep(1)
-                else:
-                    return UploadResult(
-                        success=False,
-                        local_path=local_path,
-                        oss_key=oss_key,
-                        oss_url='',
-                        error=error_msg
-                    )
-        
-        return UploadResult(
-            success=False,
-            local_path=local_path,
-            oss_key=oss_key,
-            oss_url='',
-            error='达到最大重试次数'
-        )
-    
-    def upload_image(self, local_path: str, batch_id: str, image_type: str,
-                     person_index: Optional[int] = None,
-                     callback: Optional[callable] = None) -> str:
-        """
-        上传图片(异步)
-        
-        Args:
-            local_path: 本地图片路径
-            batch_id: 批次ID
-            image_type: 图片类型 ('panorama' 或 'ptz')
-            person_index: 人员序号(仅用于 PTZ 图片)
-            callback: 上传完成回调函数
-            
-        Returns:
-            oss_key: OSS 对象键(用于后续查询结果)
-        """
-        if not self.enabled:
-            logger.warning(f"[OSS] upload_image 返回空: enabled={self.enabled}")
-            return ''
-
-        logger.info(f"[OSS] upload_image 开始: path={local_path}, type={image_type}, batch={batch_id}")
-
-        # 生成 OSS 路径
-        timestamp = datetime.now().strftime("%Y%m%d")
-        filename = os.path.basename(local_path)
-        path_prefix = self.config.get('path_prefix', 'safety-system')
-        
-        if image_type == 'panorama':
-            oss_key = f"{path_prefix}/{timestamp}/{batch_id}/panorama/{filename}"
-        else:
-            oss_key = f"{path_prefix}/{timestamp}/{batch_id}/ptz/{filename}"
-        
-        # 创建上传任务
-        task = UploadTask(
-            local_path=local_path,
-            oss_key=oss_key,
-            batch_id=batch_id,
-            image_type=image_type,
-            person_index=person_index,
-            callback=callback
-        )
-        
-        # 加入队列
-        self.upload_queue.put(task)
-        
-        return oss_key
-    
-    def upload_image_sync(self, local_path: str, batch_id: str, 
-                          image_type: str,
-                          person_index: Optional[int] = None) -> UploadResult:
-        """
-        同步上传图片
-        
-        Args:
-            local_path: 本地图片路径
-            batch_id: 批次ID
-            image_type: 图片类型
-            person_index: 人员序号
-            
-        Returns:
-            UploadResult: 上传结果
-        """
-        if not self.enabled:
-            return UploadResult(
-                success=False,
-                local_path=local_path,
-                oss_key='',
-                oss_url='',
-                error='OSS 未启用'
-            )
-        
-        # 生成 OSS 路径
-        timestamp = datetime.now().strftime("%Y%m%d")
-        filename = os.path.basename(local_path)
-        path_prefix = self.config.get('path_prefix', 'safety-system')
-        
-        if image_type == 'panorama':
-            oss_key = f"{path_prefix}/{timestamp}/{batch_id}/panorama/{filename}"
-        else:
-            oss_key = f"{path_prefix}/{timestamp}/{batch_id}/ptz/{filename}"
-        
-        task = UploadTask(
-            local_path=local_path,
-            oss_key=oss_key,
-            batch_id=batch_id,
-            image_type=image_type,
-            person_index=person_index
-        )
-        
-        return self._upload_file(task)
-    
-    def get_upload_result(self, timeout: float = 0.1) -> Optional[UploadResult]:
-        """
-        获取上传结果(非阻塞)
-        
-        Args:
-            timeout: 等待超时时间
-            
-        Returns:
-            UploadResult 或 None
-        """
-        try:
-            return self.result_queue.get(timeout=timeout)
-        except queue.Empty:
-            return None
-    
-    def get_stats(self) -> Dict[str, int]:
-        """获取统计信息"""
-        with self.stats_lock:
-            return self.stats.copy()
-    
-    def is_enabled(self) -> bool:
-        """检查是否启用"""
-        return self.enabled
-
-
-# 全局单例
-_oss_uploader_instance: Optional[OSSUploader] = None
-_oss_uploader_lock = threading.Lock()
-
-
-def get_oss_uploader(config: Dict[str, Any] = None) -> OSSUploader:
-    """
-    获取 OSS 上传器实例(单例模式,线程安全)
-
-    Args:
-        config: OSS 配置
-
-    Returns:
-        OSSUploader 实例
-    """
-    global _oss_uploader_instance
-
-    if _oss_uploader_instance is None:
-        with _oss_uploader_lock:
-            if _oss_uploader_instance is None:
-                _oss_uploader_instance = OSSUploader(config)
-
-    return _oss_uploader_instance
-
-
-def reset_oss_uploader():
-    """重置 OSS 上传器实例"""
-    global _oss_uploader_instance
-    with _oss_uploader_lock:
-        if _oss_uploader_instance is not None:
-            _oss_uploader_instance.stop()
-        _oss_uploader_instance = None

+ 0 - 1150
dual_camera_system/paired_image_saver.py

@@ -1,1150 +0,0 @@
-"""
-配对图片保存管理器
-将全景检测图片和对应的球机聚焦图片保存到同一目录
-支持 OSS 上传和 batch_info.json 格式
-"""
-
-import os
-import cv2
-import time
-import json
-import logging
-import threading
-from pathlib import Path
-from datetime import datetime
-from typing import Optional, List, Dict, Tuple, Callable
-from dataclasses import dataclass, field, asdict
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass
-class PersonInfo:
-    """人员信息"""
-    person_index: int  # 人员序号(0-based)
-    position: Tuple[float, float]  # (x_ratio, y_ratio)
-    bbox: Tuple[int, int, int, int]  # (x1, y1, x2, y2)
-    confidence: float
-    ptz_position: Optional[Tuple[float, float, int]] = None  # (pan, tilt, zoom)
-    ptz_bbox: Optional[Tuple[int, int, int, int]] = None  # 球机图中检测到的bbox (x1, y1, x2, y2)
-    ptz_image_saved: bool = False
-    ptz_image_path: Optional[str] = None  # 标记后的球机图路径
-    ptz_image_original_path: Optional[str] = None  # 未标记的球机图路径
-    ptz_oss_url: Optional[str] = None  # 标记后球机图 OSS URL
-    ptz_oss_url_original: Optional[str] = None  # 未标记球机图 OSS URL
-
-
-@dataclass
-class DetectionBatch:
-    """一批检测记录"""
-    batch_id: str
-    timestamp: float
-    panorama_image: Optional[object] = None  # numpy array
-    panorama_path: Optional[str] = None  # 标记后的全景图路径
-    panorama_original_path: Optional[str] = None  # 未标记的全景图路径
-    panorama_oss_url: Optional[str] = None  # 标记后全景图 OSS URL
-    panorama_oss_url_original: Optional[str] = None  # 未标记全景图 OSS URL
-    persons: List[PersonInfo] = field(default_factory=list)
-    total_persons: int = 0
-    ptz_images_count: int = 0
-    completed: bool = False
-    device_id: str = ''  # 设备编号
-    project_id: str = ''  # 项目编号
-
-
-class PairedImageSaver:
-    """
-    配对图片保存管理器
-    
-    功能:
-    1. 为每次全景检测创建批次目录
-    2. 保存全景标记图到批次目录
-    3. 为每个人员保存对应的球机聚焦图到同一目录
-    4. 支持时间窗口内的批量保存
-    5. 支持 OSS 上传
-    6. 生成 batch_info.json
-    """
-    
-    def __init__(self, base_dir: str = None,
-                 time_window: float = None,
-                 max_batches: int = None,
-                 cleanup_enabled: bool = None,
-                 retention_days: int = None,
-                 enable_oss: bool = False,
-                 oss_uploader = None,
-                 device_config: Dict = None):
-        """
-        初始化
-
-        Args:
-            base_dir: 基础保存目录(默认从配置读取)
-            time_window: 批次时间窗口(秒),同一窗口内的检测归为一批
-            max_batches: 最大保留批次数量
-            cleanup_enabled: 是否启用自动清理
-            retention_days: 保留天数
-            enable_oss: 是否启用 OSS 上传
-            oss_uploader: OSS 上传器实例
-            device_config: 设备配置字典
-        """
-        # 从配置模块读取配对图片保存配置
-        try:
-            from config import PAIRED_IMAGE_CONFIG
-            config = PAIRED_IMAGE_CONFIG
-        except ImportError:
-            config = {}
-
-        # 使用传入参数或配置默认值
-        self.base_dir = Path(base_dir or config.get('base_dir', '/home/admin/dsh/paired_images'))
-        self.time_window = time_window or config.get('time_window', 5.0)
-        self.max_batches = max_batches if max_batches is not None else config.get('max_batches', 100)
-        self.cleanup_enabled = cleanup_enabled if cleanup_enabled is not None else config.get('cleanup_enabled', True)
-        self.retention_days = retention_days if retention_days is not None else config.get('retention_days', 7)
-        
-        # 从配置模块读取 OSS 和设备配置(确保即使外部不传也能正确配置)
-        try:
-            from config import S3_COMPATIBLE_CONFIG, DEVICE_CONFIG
-
-            # OSS 配置:优先使用传入参数,否则从配置模块读取
-            oss_enabled_in_config = S3_COMPATIBLE_CONFIG.get('enabled', False)
-            if enable_oss or (not enable_oss and oss_enabled_in_config):
-                self.enable_oss = True
-            else:
-                self.enable_oss = enable_oss
-
-            logger.info(f"[配对保存] OSS配置: enable_oss={enable_oss}, config_enabled={oss_enabled_in_config}, 最终启用={self.enable_oss}")
-
-            # OSS 上传器:优先使用传入的实例,否则从全局获取
-            if oss_uploader is not None:
-                self.oss_uploader = oss_uploader
-                logger.info("[配对保存] 使用传入的 OSS 上传器")
-            elif self.enable_oss:
-                try:
-                    from oss_uploader import get_oss_uploader
-                    self.oss_uploader = get_oss_uploader()
-                    logger.info(f"[配对保存] 获取 OSS 上传器: enabled={self.oss_uploader.enabled}, running={getattr(self.oss_uploader, 'running', False)}")
-                    if not self.oss_uploader.running:
-                        self.oss_uploader.start()
-                        logger.info("[配对保存] OSS 上传器已启动")
-                except Exception as e:
-                    logger.warning(f"[配对保存] OSS 上传器初始化失败: {e}")
-                    self.oss_uploader = None
-                    self.enable_oss = False
-            else:
-                self.oss_uploader = None
-
-            # 设备配置:合并传入参数和配置模块
-            self.device_config = DEVICE_CONFIG.copy()
-            if device_config:
-                self.device_config.update(device_config)
-
-        except ImportError as e:
-            # 配置模块不可用时使用传入参数
-            logger.warning(f"[配对保存] 配置模块导入失败: {e}")
-            self.enable_oss = enable_oss
-            self.oss_uploader = oss_uploader
-            self.device_config = device_config or {}
-        
-        self._current_batch: Optional[DetectionBatch] = None
-        self._batch_lock = threading.Lock()
-        self._last_batch_time = 0.0
-
-        # 保存任务队列(必须在线程启动前初始化)
-        self._save_queue = []
-        self._save_queue_lock = threading.Lock()
-
-        # 后台线程池(用于异步保存图片和上传OSS,不阻塞主识别线程)
-        self._save_thread_pool = threading.Thread(target=self._save_worker, daemon=True)
-        self._save_thread_pool.start()
-
-        # 上传状态追踪
-        self._upload_status: Dict[str, Dict] = {}  # batch_id -> {panorama: bool, ptz: Dict}
-        self._upload_callback: Optional[Callable] = None
-
-        # 统计信息
-        self._stats = {
-            'total_batches': 0,
-            'total_persons': 0,
-            'total_ptz_images': 0,
-            'oss_upload_success': 0,
-            'oss_upload_failed': 0,
-        }
-        self._stats_lock = threading.Lock()
-        
-        # 确保目录存在
-        self._ensure_base_dir()
-        
-        logger.info(f"[配对保存] 初始化完成: 目录={self.base_dir}, 时间窗口={self.time_window}s, "
-                   f"最大批次={self.max_batches}, 清理={self.cleanup_enabled}, 保留天数={self.retention_days}, OSS={enable_oss}")
-    
-    def _ensure_base_dir(self):
-        """确保基础目录存在"""
-        try:
-            self.base_dir.mkdir(parents=True, exist_ok=True)
-        except Exception as e:
-            logger.error(f"[配对保存] 创建目录失败: {e}")
-
-    def _save_worker(self):
-        """后台工作线程:异步保存图片和上传OSS"""
-        logger.info("[配对保存] 后台保存线程已启动")
-        while True:
-            task = None
-            with self._save_queue_lock:
-                if self._save_queue:
-                    task = self._save_queue.pop(0)
-
-            if task is not None:
-                task_type = task.get('type')
-                try:
-                    if task_type == 'panorama':
-                        self._async_save_panorama(task)
-                    elif task_type == 'ptz':
-                        self._async_save_ptz(task)
-                    elif task_type == 'finalize':
-                        self._async_finalize_batch(task)
-                except Exception as e:
-                    logger.error(f"[配对保存] 后台任务执行失败: {e}")
-            else:
-                time.sleep(0.1)  # 无任务时短暂休眠
-
-    def _async_save_panorama(self, task: Dict):
-        """异步保存全景图并上传OSS"""
-        batch_id = task['batch_id']
-        batch_dir = task['batch_dir']
-        frame = task['frame']
-        persons = task['persons']
-
-        try:
-            # 保存原图 - JPEG 压缩 (质量 85%)
-            original_filename = f"00_panorama_original_n{len(persons)}.jpg"
-            original_filepath = batch_dir / original_filename
-            encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 85]
-            result, encoded = cv2.imencode('.jpg', frame, encode_param)
-            if result:
-                with open(str(original_filepath), 'wb') as f:
-                    f.write(encoded)
-
-            # 保存标记图
-            marked_frame = frame.copy()
-            for i, person in enumerate(persons):
-                bbox = person.get('bbox', (0, 0, 0, 0))
-                x1, y1, x2, y2 = bbox
-                conf = person.get('confidence', 0.0)
-                cv2.rectangle(marked_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
-                label = f"person_{i}({conf:.2f})"
-                (label_w, label_h), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.8, 2)
-                cv2.rectangle(marked_frame, (x1, y1 - label_h - 8), (x1 + label_w, y1), (0, 255, 0), -1)
-                cv2.putText(marked_frame, label, (x1, y1 - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 0), 2)
-
-            marked_filename = f"00_panorama_marked_n{len(persons)}.jpg"
-            marked_filepath = batch_dir / marked_filename
-            encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 85]
-            result, encoded = cv2.imencode('.jpg', marked_frame, encode_param)
-            if result:
-                with open(str(marked_filepath), 'wb') as f:
-                    f.write(encoded)
-
-            logger.info(f"[配对保存] 全景图已保存: batch={batch_id}")
-
-            # 异步上传OSS
-            if self.enable_oss and self.oss_uploader:
-                if original_filepath.exists():
-                    self._upload_panorama_to_oss(batch_id, str(original_filepath), image_type='panorama_original')
-                if marked_filepath.exists():
-                    self._upload_panorama_to_oss(batch_id, str(marked_filepath), image_type='panorama')
-
-        except Exception as e:
-            logger.error(f"[配对保存] 异步保存全景图失败: {e}")
-
-    def _async_save_ptz(self, task: Dict):
-        """异步保存球机图并上传OSS"""
-        batch_id = task['batch_id']
-        batch_dir = task['batch_dir']
-        person_index = task['person_index']
-        ptz_frame = task['ptz_frame']
-        ptz_position = task['ptz_position']
-        ptz_frame_marked = task.get('ptz_frame_marked')
-        ptz_bbox = task.get('ptz_bbox')
-
-        try:
-            pan, tilt, zoom = ptz_position
-
-            # 保存原图(未标记)- JPEG 压缩
-            original_filename = f"01_ptz_person{person_index}_p{int(pan)}_t{int(tilt)}_z{int(zoom)}_original.jpg"
-            original_filepath = batch_dir / original_filename
-            if ptz_frame is not None:
-                encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 85]
-                result, encoded = cv2.imencode('.jpg', ptz_frame, encode_param)
-                if result:
-                    with open(str(original_filepath), 'wb') as f:
-                        f.write(encoded)
-
-            # 保存标记图 - JPEG 压缩
-            marked_frame = ptz_frame_marked if ptz_frame_marked is not None else None
-            if marked_frame is None and ptz_frame is not None:
-                marked_frame = ptz_frame.copy()
-                h, w = marked_frame.shape[:2]
-                info_text = f"PTZ: P={pan:.1f} T={tilt:.1f} Z={zoom}"
-                cv2.putText(marked_frame, info_text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
-                person_text = f"person_{person_index}"
-                cv2.putText(marked_frame, person_text, (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
-                if ptz_bbox is not None:
-                    x1, y1, x2, y2 = ptz_bbox
-                    cv2.rectangle(marked_frame, (x1, y1), (x2, y2), (0, 0, 255), 2)
-                    bbox_text = f"PTZ_BBox: ({x1},{y1},{x2},{y2})"
-                    cv2.putText(marked_frame, bbox_text, (10, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
-
-            marked_filename = f"01_ptz_person{person_index}_p{int(pan)}_t{int(tilt)}_z{int(zoom)}_marked.jpg"
-            marked_filepath = batch_dir / marked_filename
-            if marked_frame is not None:
-                encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 85]
-                result, encoded = cv2.imencode('.jpg', marked_frame, encode_param)
-                if result:
-                    with open(str(marked_filepath), 'wb') as f:
-                        f.write(encoded)
-
-            logger.info(f"[配对保存] 球机图已保存: batch={batch_id}, person={person_index}")
-
-            # 异步上传OSS
-            if self.enable_oss and self.oss_uploader:
-                if original_filepath.exists():
-                    self._upload_ptz_to_oss(batch_id, person_index, str(original_filepath), str(marked_filepath))
-
-        except Exception as e:
-            logger.error(f"[配对保存] 异步保存球机图失败: {e}")
-
-    def _async_finalize_batch(self, task: Dict):
-        """异步完成批次(生成batch_info.json + 上报第三方)"""
-        batch = task['batch']
-        try:
-            batch.completed = True
-            batch_dir = self.base_dir / f"batch_{batch.batch_id}"
-
-            # 等待 OSS 上传完成(最多等待30秒)
-            if self.enable_oss:
-                wait_start = time.time()
-                max_wait = 30.0
-                # 统计需要等待的 PTZ 上传数量
-                expected_ptz_count = sum(1 for p in batch.persons if p.ptz_image_saved)
-                while time.time() - wait_start < max_wait:
-                    status = self._upload_status.get(batch.batch_id, {})
-                    panorama_done = status.get('panorama_url') is not None or not batch.panorama_path
-                    ptz_marked_status = status.get('ptz_marked', {})
-                    ptz_original_status = status.get('ptz_original', {})
-                    # 只有当 PTZ 上传状态已提交后才判断完成,避免空字典误判
-                    if expected_ptz_count == 0:
-                        ptz_done = True
-                    else:
-                        ptz_done = (
-                            len(ptz_marked_status) >= expected_ptz_count
-                            and len(ptz_original_status) >= expected_ptz_count
-                            and all(
-                                ptz_marked_status.get(idx) is not None and ptz_original_status.get(idx) is not None
-                                for idx, person in enumerate(batch.persons)
-                                if person.ptz_image_saved
-                            )
-                        )
-                    if panorama_done and ptz_done:
-                        break
-                    time.sleep(0.5)
-
-            # 构建并保存 batch_info.json
-            batch_info = self._build_batch_info_json(batch)
-            json_path = batch_dir / "batch_info.json"
-            with open(json_path, 'w', encoding='utf-8') as f:
-                json.dump(batch_info, f, ensure_ascii=False, indent=2)
-
-            txt_path = batch_dir / "batch_info.txt"
-            self._save_batch_info_txt(batch, txt_path)
-
-            # 校验 OSS URL 不能为 null,上报前确保所有图片已上传
-            upload_status = self._upload_status.get(batch.batch_id, {})
-            missing_urls = []
-
-            # 检查全景图
-            if not upload_status.get('panorama_url') and not batch.panorama_oss_url:
-                missing_urls.append('panorama')
-            if not upload_status.get('panorama_original_url') and not batch.panorama_oss_url_original:
-                missing_urls.append('panorama_original')
-
-            # 检查球机图
-            for idx, person in enumerate(batch.persons):
-                if person.ptz_image_saved:
-                    ptz_marked = upload_status.get('ptz_marked', {}).get(idx) or person.ptz_oss_url
-                    ptz_original = upload_status.get('ptz_original', {}).get(idx) or person.ptz_oss_url_original
-                    if not ptz_marked:
-                        missing_urls.append(f'ptz_{idx}_marked')
-                    if not ptz_original:
-                        missing_urls.append(f'ptz_{idx}_original')
-
-            if missing_urls:
-                logger.warning(f"[配对保存] OSS 上传未完成,跳过上报: batch_id={batch.batch_id}, 缺失: {missing_urls}")
-
-            # 标记上传完成
-            if batch.batch_id in self._upload_status:
-                self._upload_status[batch.batch_id]['completed'] = True
-
-            # 触发回调
-            if self._upload_callback:
-                try:
-                    self._upload_callback(batch_info)
-                except Exception as e:
-                    logger.error(f"[配对保存] 回调执行错误: {e}")
-
-            logger.info(f"[配对保存] 批次完成: {batch.batch_id}")
-
-        except Exception as e:
-            logger.error(f"[配对保存] 异步完成批次失败: {e}")
-        finally:
-            # 确保清理逻辑始终执行
-            self._cleanup_old_batches()
-
-    def _queue_save_task(self, task: Dict):
-        """添加保存任务到队列"""
-        with self._save_queue_lock:
-            self._save_queue.append(task)
-    
-    def _generate_batch_id(self) -> str:
-        """生成批次ID"""
-        return datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
-    
-    def _create_batch_dir(self, batch_id: str) -> Path:
-        """创建批次目录"""
-        batch_dir = self.base_dir / f"batch_{batch_id}"
-        try:
-            batch_dir.mkdir(parents=True, exist_ok=True)
-            return batch_dir
-        except Exception as e:
-            logger.error(f"[配对保存] 创建批次目录失败: {e}")
-            return self.base_dir
-    
-    def start_new_batch(self, panorama_frame, persons: List[Dict]) -> Optional[str]:
-        """
-        开始新批次
-        
-        Args:
-            panorama_frame: 全景帧图像
-            persons: 人员列表,每项包含 track_id, position, bbox, confidence
-            
-        Returns:
-            batch_id: 批次ID,失败返回 None
-        """
-        with self._batch_lock:
-            current_time = time.time()
-            
-            # 完成上一批次(如果有)- 异步执行,不阻塞主线程
-            if self._current_batch is not None:
-                self._queue_save_task({
-                    'type': 'finalize',
-                    'batch': self._current_batch
-                })
-            
-            # 创建新批次
-            batch_id = self._generate_batch_id()
-            batch_dir = self._create_batch_dir(batch_id)
-
-            # 异步保存全景图片(原图和标记图),不阻塞主线程
-            panorama_original_path = None
-            panorama_marked_path = None
-            if panorama_frame is not None:
-                # 异步保存,路径在保存完成后会更新
-                panorama_original_path, panorama_marked_path = self._save_panorama_image(
-                    batch_dir, batch_id, panorama_frame, persons
-                )
-                # 异步上传OSS(不阻塞主线程)
-                if panorama_original_path and panorama_marked_path:
-                    self._queue_save_task({
-                        'type': 'panorama',
-                        'batch_id': batch_id,
-                        'batch_dir': batch_dir,
-                        'frame': panorama_frame,
-                        'persons': persons
-                    })
-
-            # 创建人员信息
-            person_infos = []
-            for i, p in enumerate(persons):
-                info = PersonInfo(
-                    person_index=i,
-                    position=p.get('position', (0, 0)),
-                    bbox=p.get('bbox', (0, 0, 0, 0)),
-                    confidence=p.get('confidence', 0.0)
-                )
-                person_infos.append(info)
-
-            # 获取设备信息
-            device_id = self.device_config.get('device_id', 'UNKNOWN')
-            project_id = self.device_config.get('project_id', 'UNKNOWN')
-
-            # 创建批次记录
-            self._current_batch = DetectionBatch(
-                batch_id=batch_id,
-                timestamp=current_time,
-                panorama_image=panorama_frame,
-                panorama_path=panorama_marked_path,  # 标记图作为主路径
-                panorama_original_path=panorama_original_path,  # 原图路径
-                persons=person_infos,
-                total_persons=len(persons),
-                device_id=device_id,
-                project_id=project_id
-            )
-            
-            # 初始化上传状态
-            self._upload_status[batch_id] = {
-                'panorama': False,
-                'panorama_url': None,
-                'ptz': {},
-                'completed': False
-            }
-            
-            self._last_batch_time = current_time
-            
-            with self._stats_lock:
-                self._stats['total_batches'] += 1
-                self._stats['total_persons'] += len(persons)
-            
-            logger.info(
-                f"[配对保存] 新批次创建: {batch_id}, "
-                f"人员={len(persons)}, 目录={batch_dir}"
-            )
-
-            return batch_id
-    
-    def _save_panorama_image(self, batch_dir: Path, batch_id: str,
-                              frame, persons: List[Dict]) -> Tuple[Optional[str], Optional[str]]:
-        """
-        保存全景原图和标记图片
-
-        Args:
-            batch_dir: 批次目录
-            batch_id: 批次ID
-            frame: 全景帧
-            persons: 人员列表(已由调用方过滤,此处不再过滤)
-
-        Returns:
-            (原图路径, 标记图路径) 或 (None, None)
-        """
-        try:
-            # 保存原图(未标记)- JPEG 压缩
-            original_filename = f"00_panorama_original_n{len(persons)}.jpg"
-            original_filepath = batch_dir / original_filename
-            encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 85]
-            result, encoded = cv2.imencode('.jpg', frame, encode_param)
-            if result:
-                with open(str(original_filepath), 'wb') as f:
-                    f.write(encoded)
-
-            # 复制图像避免修改原图
-            marked_frame = frame.copy()
-
-            # 绘制每个人员的标记(使用连续的序号)
-            for i, person in enumerate(persons):
-                bbox = person.get('bbox', (0, 0, 0, 0))
-                x1, y1, x2, y2 = bbox
-                conf = person.get('confidence', 0.0)
-
-                # 绘制边界框(绿色)
-                cv2.rectangle(marked_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
-
-                # 绘制序号标签(带置信度)
-                label = f"person_{i}({conf:.2f})"
-                (label_w, label_h), baseline = cv2.getTextSize(
-                    label, cv2.FONT_HERSHEY_SIMPLEX, 0.8, 2
-                )
-
-                # 标签背景
-                cv2.rectangle(
-                    marked_frame,
-                    (x1, y1 - label_h - 8),
-                    (x1 + label_w, y1),
-                    (0, 255, 0),
-                    -1
-                )
-
-                # 标签文字(黑色)
-                cv2.putText(
-                    marked_frame, label,
-                    (x1, y1 - 4),
-                    cv2.FONT_HERSHEY_SIMPLEX, 0.8,
-                    (0, 0, 0), 2
-                )
-
-            # 保存标记图
-            marked_filename = f"00_panorama_marked_n{len(persons)}.jpg"
-            marked_filepath = batch_dir / marked_filename
-            encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 85]
-            result, encoded = cv2.imencode('.jpg', marked_frame, encode_param)
-            if result:
-                with open(str(marked_filepath), 'wb') as f:
-                    f.write(encoded)
-
-            logger.info(f"[配对保存] 全景图已保存: 原图={original_filepath}, 标记图={marked_filepath}, 人员数量 {len(persons)}")
-            return str(original_filepath), str(marked_filepath)
-
-        except Exception as e:
-            logger.error(f"[配对保存] 保存全景图失败: {e}")
-            return None, None
-    
-    def save_ptz_image(self, batch_id: str, person_index: int,
-                       ptz_frame, ptz_position: Tuple[float, float, int],
-                       ptz_bbox: Tuple[int, int, int, int] = None,
-                       person_info: Dict = None,
-                       ptz_frame_marked: object = None) -> Tuple[Optional[str], Optional[str]]:
-        """
-        保存球机聚焦图片(原图和标记图)
-
-        Args:
-            batch_id: 批次ID
-            person_index: 人员序号(0-based)
-            ptz_frame: 球机原始帧(未标记)
-            ptz_position: PTZ位置 (pan, tilt, zoom)
-            ptz_bbox: 球机图中检测到的bbox (x1, y1, x2, y2)
-            person_info: 额外人员信息
-            ptz_frame_marked: 外部传入的已标记帧(可选,不传则在内部生成标记图)
-
-        Returns:
-            (原图路径, 标记图路径) 或 (None, None)
-        """
-        logger.info(f"[配对保存] save_ptz_image: batch={batch_id}, person={person_index}, "
-                   f"PTZ=({ptz_position[0]:.1f}°, {ptz_position[1]:.1f}°, zoom={ptz_position[2]})")
-
-        with self._batch_lock:
-            if self._current_batch is None or self._current_batch.batch_id != batch_id:
-                logger.warning(f"[配对保存] 批次不存在或已过期: {batch_id}")
-                return None, None
-
-            batch_dir = self.base_dir / f"batch_{batch_id}"
-
-            try:
-                # 保存原图(未标记)- JPEG 压缩
-                original_filename = f"01_ptz_person{person_index}_p{int(ptz_position[0])}_t{int(ptz_position[1])}_z{int(ptz_position[2])}_original.jpg"
-                original_filepath = batch_dir / original_filename
-
-                if ptz_frame is not None:
-                    encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 85]
-                    result, encoded = cv2.imencode('.jpg', ptz_frame, encode_param)
-                    if result:
-                        with open(str(original_filepath), 'wb') as f:
-                            f.write(encoded)
-
-                # 优先使用外部传入的标记帧,否则内部生成
-                if ptz_frame_marked is not None:
-                    marked_frame = ptz_frame_marked
-                else:
-                    # 复制图像用于标记
-                    marked_frame = ptz_frame.copy() if ptz_frame is not None else None
-
-                if marked_frame is not None:
-                    # 在球机图上添加标记
-                    h, w = marked_frame.shape[:2]
-
-                    # 添加PTZ位置信息
-                    pan, tilt, zoom = ptz_position
-                    info_text = f"PTZ: P={pan:.1f} T={tilt:.1f} Z={zoom}"
-                    cv2.putText(marked_frame, info_text, (10, 30),
-                                cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
-
-                    # 添加人员序号
-                    person_text = f"person_{person_index}"
-                    cv2.putText(marked_frame, person_text, (10, 60),
-                                cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
-
-                    # 绘制PTZ检测到的bbox
-                    if ptz_bbox is not None:
-                        x1, y1, x2, y2 = ptz_bbox
-                        cv2.rectangle(marked_frame, (x1, y1), (x2, y2), (0, 0, 255), 2)
-                        bbox_text = f"PTZ_BBox: ({x1},{y1},{x2},{y2})"
-                        cv2.putText(marked_frame, bbox_text, (10, 90),
-                                    cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
-
-                # 保存标记图 - JPEG 压缩
-                marked_filename = f"01_ptz_person{person_index}_p{int(ptz_position[0])}_t{int(ptz_position[1])}_z{int(ptz_position[2])}_marked.jpg"
-                marked_filepath = batch_dir / marked_filename
-
-                if marked_frame is not None:
-                    encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 85]
-                    result, encoded = cv2.imencode('.jpg', marked_frame, encode_param)
-                    if result:
-                        with open(str(marked_filepath), 'wb') as f:
-                            f.write(encoded)
-
-                logger.info(f"[配对保存] 球机图已保存: 原图={original_filepath}, 标记图={marked_filepath}")
-
-                # 更新批次信息
-                if person_index < len(self._current_batch.persons):
-                    self._current_batch.persons[person_index].ptz_position = ptz_position
-                    self._current_batch.persons[person_index].ptz_bbox = ptz_bbox
-                    self._current_batch.persons[person_index].ptz_image_saved = True
-                    self._current_batch.persons[person_index].ptz_image_path = str(marked_filepath)
-                    self._current_batch.persons[person_index].ptz_image_original_path = str(original_filepath)
-
-                    self._current_batch.ptz_images_count += 1
-
-                    with self._stats_lock:
-                        self._stats['total_ptz_images'] += 1
-
-                    # 异步上传OSS(不阻塞主线程)
-                    if self.enable_oss and self.oss_uploader:
-                        self._queue_save_task({
-                            'type': 'ptz',
-                            'batch_id': batch_id,
-                            'batch_dir': batch_dir,
-                            'person_index': person_index,
-                            'ptz_frame': ptz_frame,
-                            'ptz_position': ptz_position,
-                            'ptz_frame_marked': ptz_frame_marked,
-                            'ptz_bbox': ptz_bbox
-                        })
-
-                return str(original_filepath), str(marked_filepath)
-
-            except Exception as e:
-                logger.error(f"[配对保存] 保存球机图失败: {e}")
-                return None, None
-    
-    def _upload_panorama_to_oss(self, batch_id: str, panorama_path: str, image_type: str = 'panorama'):
-        """上传全景图到 OSS"""
-        logger.info(f"[OSS] _upload_panorama_to_oss: batch_id={batch_id}, path={panorama_path}, type={image_type}")
-
-        # 确定是原图还是标记图
-        is_original = image_type == 'panorama_original'
-
-        def on_upload_complete(result):
-            logger.info(f"[OSS] 全景图上传完成回调: type={image_type}, url={result.oss_url}")
-            if is_original:
-                self._upload_status[batch_id]['panorama_original'] = True
-                self._upload_status[batch_id]['panorama_original_url'] = result.oss_url
-                if self._current_batch and self._current_batch.batch_id == batch_id:
-                    self._current_batch.panorama_oss_url_original = result.oss_url
-            else:
-                self._upload_status[batch_id]['panorama'] = True
-                self._upload_status[batch_id]['panorama_url'] = result.oss_url
-                if self._current_batch and self._current_batch.batch_id == batch_id:
-                    self._current_batch.panorama_oss_url = result.oss_url
-            with self._stats_lock:
-                self._stats['oss_upload_success'] += 1
-
-        def on_upload_error(result):
-            logger.error(f"[OSS] 全景图上传失败回调: type={image_type}, error={result.error}")
-            with self._stats_lock:
-                self._stats['oss_upload_failed'] += 1
-
-        def on_upload_done(result):
-            if result.success:
-                on_upload_complete(result)
-            else:
-                on_upload_error(result)
-
-        try:
-            oss_key = self.oss_uploader.upload_image(
-                local_path=panorama_path,
-                batch_id=batch_id,
-                image_type=image_type,
-                callback=on_upload_done
-            )
-            logger.info(f"[OSS] 全景图已加入上传队列: type={image_type}, oss_key={oss_key}")
-        except Exception as e:
-            logger.error(f"[OSS] 全景图上传异常: {e}")
-    
-    def _upload_ptz_to_oss(self, batch_id: str, person_index: int, original_path: str = None, marked_path: str = None):
-        """上传球机图到 OSS(原图和标记图)"""
-        logger.info(f"[OSS] _upload_ptz_to_oss: batch={batch_id}, person={person_index}, original={original_path}, marked={marked_path}")
-
-        # 初始化上传状态
-        if batch_id not in self._upload_status:
-            self._upload_status[batch_id] = {'ptz': {}}
-        if 'ptz_original' not in self._upload_status[batch_id]:
-            self._upload_status[batch_id]['ptz_original'] = {}
-        if 'ptz_marked' not in self._upload_status[batch_id]:
-            self._upload_status[batch_id]['ptz_marked'] = {}
-
-        # 上传原图
-        if original_path:
-            def on_original_complete(result):
-                self._upload_status[batch_id]['ptz_original'][person_index] = result.oss_url
-                if self._current_batch and self._current_batch.batch_id == batch_id:
-                    if person_index < len(self._current_batch.persons):
-                        self._current_batch.persons[person_index].ptz_oss_url_original = result.oss_url
-                with self._stats_lock:
-                    self._stats['oss_upload_success'] += 1
-                logger.info(f"[OSS] 球机原图上传成功: {result.oss_url}")
-
-            def on_original_done(result):
-                if result.success:
-                    on_original_complete(result)
-                else:
-                    logger.error(f"[OSS] 球机原图上传失败: {result.error}")
-
-            try:
-                self.oss_uploader.upload_image(
-                    local_path=original_path,
-                    batch_id=batch_id,
-                    image_type='ptz_original',
-                    person_index=person_index,
-                    callback=on_original_done
-                )
-            except Exception as e:
-                logger.error(f"[OSS] 球机原图上传异常: {e}")
-
-        # 上传标记图
-        if marked_path:
-            def on_marked_complete(result):
-                self._upload_status[batch_id]['ptz_marked'][person_index] = result.oss_url
-                if self._current_batch and self._current_batch.batch_id == batch_id:
-                    if person_index < len(self._current_batch.persons):
-                        self._current_batch.persons[person_index].ptz_oss_url = result.oss_url
-                with self._stats_lock:
-                    self._stats['oss_upload_success'] += 1
-                logger.info(f"[OSS] 球机标记图上传成功: {result.oss_url}")
-
-            def on_marked_done(result):
-                if result.success:
-                    on_marked_complete(result)
-                else:
-                    logger.error(f"[OSS] 球机标记图上传失败: {result.error}")
-
-            try:
-                self.oss_uploader.upload_image(
-                    local_path=marked_path,
-                    batch_id=batch_id,
-                    image_type='ptz',
-                    person_index=person_index,
-                    callback=on_marked_done
-                )
-            except Exception as e:
-                logger.error(f"[OSS] 球机标记图上传异常: {e}")
-    
-    def _finalize_batch(self, batch: DetectionBatch):
-        """完成批次处理"""
-        batch.completed = True
-
-        # 等待 OSS 上传完成(最多等待10秒)
-        if self.enable_oss and batch.batch_id in self._upload_status:
-            wait_start = time.time()
-            max_wait = 10.0  # 增加等待时间
-            while time.time() - wait_start < max_wait:
-                status = self._upload_status[batch.batch_id]
-                # 检查全景图是否上传完成(优先检查 _upload_status 中的状态)
-                panorama_url = status.get('panorama_url')
-                panorama_done = panorama_url is not None or not batch.panorama_path
-                # 检查所有球机图是否上传完成
-                ptz_status = status.get('ptz', {})
-                ptz_done = all(
-                    ptz_status.get(idx) is not None
-                    for idx, person in enumerate(batch.persons)
-                    if person.ptz_image_saved
-                )
-                if panorama_done and ptz_done:
-                    break
-                time.sleep(0.2)
-        
-        # 创建 batch_info.json 文件
-        try:
-            batch_dir = self.base_dir / f"batch_{batch.batch_id}"
-            
-            # 构建 JSON 数据
-            batch_info = self._build_batch_info_json(batch)
-            
-            # 保存为 JSON 文件
-            json_path = batch_dir / "batch_info.json"
-            with open(json_path, 'w', encoding='utf-8') as f:
-                json.dump(batch_info, f, ensure_ascii=False, indent=2)
-            
-            logger.info(f"[配对保存] batch_info.json 已保存: {json_path}")
-            
-            # 同时保留 txt 格式用于兼容(可选)
-            txt_path = batch_dir / "batch_info.txt"
-            self._save_batch_info_txt(batch, txt_path)
-            
-            # 标记上传完成
-            if batch.batch_id in self._upload_status:
-                self._upload_status[batch.batch_id]['completed'] = True
-            
-            # 触发回调
-            if self._upload_callback:
-                try:
-                    self._upload_callback(batch_info)
-                except Exception as e:
-                    logger.error(f"[配对保存] 回调执行错误: {e}")
-            
-            logger.info(f"[配对保存] 批次完成: {batch.batch_id}, "
-                       f"人员={batch.total_persons}, 球机图={batch.ptz_images_count}")
-            
-        except Exception as e:
-            logger.error(f"[配对保存] 保存批次信息失败: {e}")
-        
-        # 清理旧批次
-        self._cleanup_old_batches()
-
-    def _build_batch_info_json(self, batch: DetectionBatch) -> Dict:
-        """
-        构建 batch_info.json 数据结构
-
-        Returns:
-            Dict: 批次信息字典
-        """
-        # 从上传状态获取最新的 OSS URL
-        upload_status = self._upload_status.get(batch.batch_id, {})
-
-        # 获取全景图 OSS URL
-        panorama_oss_url = upload_status.get('panorama_url', batch.panorama_oss_url)
-        panorama_oss_url_original = upload_status.get('panorama_original_url', batch.panorama_oss_url_original)
-
-        # 人员信息列表
-        persons_list = []
-        for person in batch.persons:
-            # 获取球机图 OSS URL
-            ptz_oss_url = upload_status.get('ptz_marked', {}).get(person.person_index, person.ptz_oss_url)
-            ptz_oss_url_original = upload_status.get('ptz_original', {}).get(person.person_index, person.ptz_oss_url_original)
-
-            person_data = {
-                'person_index': person.person_index,
-                'position': {
-                    'x': round(person.position[0], 4),
-                    'y': round(person.position[1], 4)
-                },
-                'bbox': {
-                    'x1': person.bbox[0],
-                    'y1': person.bbox[1],
-                    'x2': person.bbox[2],
-                    'y2': person.bbox[3]
-                },
-                'confidence': round(person.confidence, 4),
-                'ptz_position': {
-                    'pan': round(person.ptz_position[0], 2) if person.ptz_position else None,
-                    'tilt': round(person.ptz_position[1], 2) if person.ptz_position else None,
-                    'zoom': person.ptz_position[2] if person.ptz_position else None
-                } if person.ptz_position else None,
-                'ptz_bbox': {
-                    'x1': person.ptz_bbox[0],
-                    'y1': person.ptz_bbox[1],
-                    'x2': person.ptz_bbox[2],
-                    'y2': person.ptz_bbox[3]
-                } if person.ptz_bbox else None,
-                'ptz_image_saved': person.ptz_image_saved,
-                'ptz_image_path': person.ptz_image_path,  # 标记图
-                'ptz_image_original_path': person.ptz_image_original_path,  # 原图
-                'ptz_oss_url': ptz_oss_url,  # 标记图 OSS URL
-                'ptz_oss_url_original': ptz_oss_url_original  # 原图 OSS URL
-            }
-            persons_list.append(person_data)
-
-        # 构建完整批次信息
-        batch_info = {
-            'batch_id': batch.batch_id,
-            'device_id': batch.device_id,
-            'project_id': batch.project_id,
-            'timestamp': batch.timestamp,
-            'datetime': datetime.fromtimestamp(batch.timestamp).isoformat(),
-            'total_persons': len(persons_list),
-            'ptz_images_count': len(persons_list),
-            'panorama': {
-                'local_path': batch.panorama_path,  # 标记图
-                'local_path_original': batch.panorama_original_path,  # 原图
-                'oss_url': panorama_oss_url,  # 标记图 OSS URL
-                'oss_url_original': panorama_oss_url_original  # 原图 OSS URL
-            },
-            'persons': persons_list,
-            'upload_status': {
-                'panorama_uploaded': panorama_oss_url is not None,
-                'panorama_original_uploaded': panorama_oss_url_original is not None,
-                'all_ptz_uploaded': all(
-                    upload_status.get('ptz', {}).get(p.person_index) is not None
-                    for p in batch.persons if p.ptz_image_saved
-                )
-            }
-        }
-
-        return batch_info
-    
-    def _save_batch_info_txt(self, batch: DetectionBatch, txt_path: Path):
-        """保存批次信息为 TXT 格式(兼容旧版本)"""
-        try:
-            with open(txt_path, 'w', encoding='utf-8') as f:
-                f.write(f"批次ID: {batch.batch_id}\n")
-                f.write(f"设备ID: {batch.device_id}\n")
-                f.write(f"项目ID: {batch.project_id}\n")
-                f.write(f"时间戳: {datetime.fromtimestamp(batch.timestamp)}\n")
-                f.write(f"总人数: {batch.total_persons}\n")
-                f.write(f"球机图数量: {batch.ptz_images_count}\n")
-                f.write(f"全景图: {batch.panorama_path}\n")
-                f.write(f"全景图OSS: {batch.panorama_oss_url}\n")
-                f.write("\n人员详情:\n")
-                
-                for i, person in enumerate(batch.persons):
-                    f.write(f"\n  Person {i}:\n")
-                    f.write(f"    Person Index: {person.person_index}\n")
-                    f.write(f"    Position: ({person.position[0]:.3f}, {person.position[1]:.3f})\n")
-                    f.write(f"    BBox: ({person.bbox[0]}, {person.bbox[1]}, {person.bbox[2]}, {person.bbox[3]})\n")
-                    f.write(f"    Confidence: {person.confidence:.2f}\n")
-                    f.write(f"    PTZ Position: {person.ptz_position}\n")
-                    if person.ptz_bbox:
-                        f.write(f"    PTZ BBox: ({person.ptz_bbox[0]}, {person.ptz_bbox[1]}, {person.ptz_bbox[2]}, {person.ptz_bbox[3]})\n")
-                    else:
-                        f.write(f"    PTZ BBox: None\n")
-                    f.write(f"    PTZ Image: {person.ptz_image_path}\n")
-                    f.write(f"    PTZ OSS URL: {person.ptz_oss_url}\n")
-        except Exception as e:
-            logger.error(f"[配对保存] 保存 TXT 批次信息失败: {e}")
-    
-    def _cleanup_old_batches(self):
-        """清理旧批次目录"""
-        if not self.cleanup_enabled:
-            logger.debug("[配对保存] 自动清理已禁用")
-            return
-
-        try:
-            batch_dirs = sorted(
-                [d for d in self.base_dir.iterdir() if d.is_dir() and d.name.startswith('batch_')],
-                key=lambda x: x.stat().st_mtime
-            )
-
-            if not batch_dirs:
-                return
-
-            now = time.time()
-            to_delete = []
-
-            for d in batch_dirs:
-                # 按数量清理:超出最大保留数量时删除最旧的
-                if len(batch_dirs) - len(to_delete) > self.max_batches:
-                    to_delete.append(d)
-                    continue
-
-                # 按天数清理:超过保留天数时删除
-                if self.retention_days > 0:
-                    age_days = (now - d.stat().st_mtime) / 86400
-                    if age_days > self.retention_days:
-                        to_delete.append(d)
-
-            # 执行删除
-            deleted_count = 0
-            for d in to_delete:
-                import shutil
-                shutil.rmtree(d)
-                deleted_count += 1
-                logger.info(f"[配对保存] 清理旧批次: {d.name}")
-
-            if deleted_count > 0:
-                logger.info(f"[配对保存] 共清理 {deleted_count} 个旧批次")
-
-        except Exception as e:
-            logger.error(f"[配对保存] 清理旧批次失败: {e}")
-    
-    def get_current_batch_id(self) -> Optional[str]:
-        """获取当前批次ID"""
-        with self._batch_lock:
-            return self._current_batch.batch_id if self._current_batch else None
-    
-    def get_stats(self) -> Dict:
-        """获取统计信息"""
-        with self._stats_lock:
-            return self._stats.copy()
-    
-    def set_upload_callback(self, callback: Callable):
-        """
-        设置批次完成回调函数
-        
-        Args:
-            callback: 回调函数,接收 batch_info_dict 参数
-        """
-        self._upload_callback = callback
-    
-    def get_batch_info(self, batch_id: str) -> Optional[Dict]:
-        """
-        获取指定批次的 batch_info.json 数据
-        
-        Args:
-            batch_id: 批次ID
-            
-        Returns:
-            Dict 或 None
-        """
-        try:
-            batch_dir = self.base_dir / f"batch_{batch_id}"
-            json_path = batch_dir / "batch_info.json"
-            
-            if json_path.exists():
-                with open(json_path, 'r', encoding='utf-8') as f:
-                    return json.load(f)
-        except Exception as e:
-            logger.error(f"[配对保存] 读取 batch_info.json 失败: {e}")
-        
-        return None
-    
-    def close(self):
-        """关闭管理器,完成当前批次(异步)"""
-        with self._batch_lock:
-            if self._current_batch is not None:
-                # 异步完成最后批次
-                self._queue_save_task({
-                    'type': 'finalize',
-                    'batch': self._current_batch
-                })
-                # 短暂等待让后台线程处理
-                time.sleep(0.5)
-                self._current_batch = None
-
-        logger.info("[配对保存] 管理器已关闭")
-
-
-# 全局单例实例
-_paired_saver_instance: Optional[PairedImageSaver] = None
-
-
-def get_paired_saver(base_dir: str = None, time_window: float = None,
-                     max_batches: int = None, cleanup_enabled: bool = None,
-                     retention_days: int = None,
-                     enable_oss: bool = False, oss_uploader = None,
-                     device_config: Dict = None) -> PairedImageSaver:
-    """
-    获取配对保存管理器实例(单例模式)
-
-    如果实例已存在但缺少 OSS/设备配置,会自动从配置模块更新
-
-    Args:
-        base_dir: 基础保存目录(默认从配置读取)
-        time_window: 时间窗口(默认从配置读取)
-        max_batches: 最大保留批次数量(默认从配置读取)
-        cleanup_enabled: 是否启用自动清理(默认从配置读取)
-        retention_days: 保留天数(默认从配置读取)
-        enable_oss: 是否启用 OSS 上传
-        oss_uploader: OSS 上传器实例
-        device_config: 设备配置字典
-
-    Returns:
-        PairedImageSaver 实例
-    """
-    global _paired_saver_instance
-
-    if _paired_saver_instance is None:
-        _paired_saver_instance = PairedImageSaver(
-            base_dir=base_dir,
-            time_window=time_window,
-            max_batches=max_batches,
-            cleanup_enabled=cleanup_enabled,
-            retention_days=retention_days,
-            enable_oss=enable_oss,
-            oss_uploader=oss_uploader,
-            device_config=device_config
-        )
-    else:
-        # 单例已存在,检查是否需要更新配置
-        # PairedImageSaver.__init__ 已从配置模块自动读取,
-        # 这里只处理外部显式传入的参数覆盖
-        if oss_uploader is not None and _paired_saver_instance.oss_uploader is None:
-            _paired_saver_instance.oss_uploader = oss_uploader
-            _paired_saver_instance.enable_oss = True
-            logger.info("[配对保存] 更新 OSS 上传器配置")
-        if device_config is not None:
-            _paired_saver_instance.device_config.update(device_config)
-            logger.info(f"[配对保存] 更新设备配置: {device_config}")
-
-    return _paired_saver_instance
-
-
-def reset_paired_saver():
-    """重置单例实例(用于测试)"""
-    global _paired_saver_instance
-    if _paired_saver_instance is not None:
-        _paired_saver_instance.close()
-    _paired_saver_instance = None

+ 0 - 472
dual_camera_system/polling_tracker.py

@@ -1,472 +0,0 @@
-"""
-轮询跟踪 + PTZ 抓拍协调器
-"""
-
-import os
-import time
-import threading
-import logging
-from typing import Dict, List, Tuple, Optional
-from dataclasses import dataclass
-
-import cv2
-import numpy as np
-
-from config import TRACKING_CONFIG
-from tracker import UltralyticsTracker, TrackedPerson
-from coordinator import TargetSelector, TrackingTarget
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass
-class CaptureRecord:
-    """单次抓拍记录"""
-    track_id: int
-    timestamp: float
-    position: Tuple[float, float]
-    ptz_position: Tuple[float, float, int]
-    ptz_image: np.ndarray
-    panorama_image: Optional[np.ndarray]
-    confidence: float
-
-
-class PollingTrackingCoordinator:
-    """多目标轮询跟踪 + PTZ 抓拍协调器"""
-
-    def __init__(
-        self,
-        panorama_camera,
-        ptz_camera,
-        tracker: UltralyticsTracker,
-        config: Optional[Dict] = None,
-        calibrator=None,
-    ):
-        self.panorama = panorama_camera
-        self.ptz = ptz_camera
-        self.tracker = tracker
-        self.config = config or TRACKING_CONFIG
-        self.calibrator = calibrator
-
-        self.active_targets: Dict[int, TrackedPerson] = {}
-        self.target_order: List[int] = []
-        self.current_index: int = 0
-        self.batch_captures: List[CaptureRecord] = []
-        self._capture_counts: Dict[int, int] = {}
-        self._last_seen_time: Dict[int, float] = {}
-
-        self.targets_lock = threading.Lock()
-        self.batch_lock = threading.Lock()
-        self._capture_counts_lock = threading.Lock()
-        self.running = False
-        self._detection_thread = None
-        self._ptz_thread = None
-        self._paused = False
-        self._paused_event = threading.Event()
-        self._paused_event.set()
-
-        self._last_ptz_command_time = 0.0
-        self._last_ptz_command_time_lock = threading.Lock()
-
-        self.target_selector = TargetSelector(self.config.get("target_selection", {}))
-        self.event_pusher = None
-
-        self.stats = {
-            "frames_processed": 0,
-            "persons_detected": 0,
-            "captures": 0,
-            "uploads": 0,
-            "start_time": None,
-        }
-        self.stats_lock = threading.Lock()
-
-        self._ensure_capture_dir()
-
-    def set_event_pusher(self, event_pusher):
-        self.event_pusher = event_pusher
-
-    def _ensure_capture_dir(self):
-        capture_dir = self.config.get("capture_dir", "/home/admin/dsh/tracking_captures")
-        try:
-            os.makedirs(capture_dir, exist_ok=True)
-        except OSError as e:
-            logger.warning(f"无法创建抓拍目录 {capture_dir}: {e}")
-
-    def start(self) -> bool:
-        if not self.panorama.connect():
-            logger.error("连接全景摄像头失败")
-            return False
-        if not self.ptz.connect():
-            logger.error("连接球机失败")
-            self.panorama.disconnect()
-            return False
-        if not self.panorama.start_stream_rtsp():
-            logger.error("启动全景视频流失败")
-            self.panorama.disconnect()
-            self.ptz.disconnect()
-            return False
-
-        self.running = True
-        self._detection_thread = threading.Thread(target=self._detection_worker, daemon=True)
-        self._detection_thread.start()
-        self._ptz_thread = threading.Thread(target=self._ptz_worker, daemon=True)
-        self._ptz_thread.start()
-
-        with self.stats_lock:
-            self.stats["start_time"] = time.time()
-
-        logger.info("轮询跟踪抓拍协调器已启动")
-        return True
-
-    def stop(self):
-        self.running = False
-        self._paused_event.set()
-        if self._detection_thread:
-            self._detection_thread.join(timeout=3)
-        if self._ptz_thread:
-            self._ptz_thread.join(timeout=3)
-
-        # 刷新待上传批次
-        self._flush_batch_if_needed()
-
-        # 停止视频流后再断开连接
-        if hasattr(self.panorama, "stop_stream_rtsp"):
-            try:
-                self.panorama.stop_stream_rtsp()
-            except Exception as e:
-                logger.warning(f"停止全景视频流失败: {e}")
-
-        self.panorama.disconnect()
-        self.ptz.disconnect()
-
-        # 释放跟踪器资源
-        if self.tracker is not None and hasattr(self.tracker, "release"):
-            try:
-                self.tracker.release()
-            except Exception as e:
-                logger.warning(f"释放跟踪器失败: {e}")
-
-        logger.info("轮询跟踪抓拍协调器已停止")
-
-    def pause(self):
-        self._paused = True
-        self._paused_event.clear()
-
-    def resume(self):
-        self._paused = False
-        self._paused_event.set()
-
-    def pause_detection(self):
-        """暂停检测(兼容既有协调器接口)"""
-        self.pause()
-
-    def resume_detection(self):
-        """恢复检测(兼容既有协调器接口)"""
-        self.resume()
-
-    def _detection_worker(self):
-        self._paused_event.wait()
-        detection_fps = self.config.get("detection_fps", 2)
-        detection_interval = 1.0 / detection_fps
-        last_detection_time = 0
-
-        while self.running:
-            try:
-                # 暂停时阻塞等待,避免忙等
-                if self._paused:
-                    self._paused_event.wait()
-                    continue
-
-                frame = self.panorama.get_frame()
-                if frame is None:
-                    time.sleep(0.01)
-                    continue
-
-                self._update_stats("frames_processed")
-
-                current_time = time.time()
-                if current_time - last_detection_time >= detection_interval:
-                    last_detection_time = current_time
-                    tracked = self.tracker.update(frame)
-                    self._update_active_targets(tracked, frame.shape)
-                    if tracked:
-                        self._update_stats("persons_detected", len(tracked))
-
-                time.sleep(0.01)
-            except Exception as e:
-                logger.error(f"检测线程错误: {e}")
-                time.sleep(0.1)
-
-    def _update_active_targets(self, tracked: List[TrackedPerson], frame_shape):
-        current_time = time.time()
-        frame_h, frame_w = frame_shape[:2]
-        timeout = self.config.get("tracking_timeout", 3.0)
-        max_targets = self.config.get("max_tracking_targets", 4)
-
-        with self.targets_lock:
-            # 更新或新增
-            updated_ids = set()
-            for p in tracked:
-                if p.track_id < 0:
-                    continue
-                updated_ids.add(p.track_id)
-                p.lost = False
-                self.active_targets[p.track_id] = p
-                self._last_seen_time[p.track_id] = current_time
-                if p.track_id not in self.target_order:
-                    self.target_order.append(p.track_id)
-
-            # 标记丢失
-            for tid in self.target_order:
-                if tid not in updated_ids:
-                    t = self.active_targets.get(tid)
-                    if t is not None:
-                        t.lost = True
-
-            # 移除长期丢失(超过 tracking_timeout)
-            remove_ids = []
-            for tid in self.target_order:
-                t = self.active_targets.get(tid)
-                if t is None:
-                    remove_ids.append(tid)
-                    continue
-                if t.lost:
-                    last_seen = self._last_seen_time.get(tid, current_time)
-                    if current_time - last_seen >= timeout:
-                        remove_ids.append(tid)
-
-            for tid in remove_ids:
-                if tid in self.target_order:
-                    self.target_order.remove(tid)
-                self.active_targets.pop(tid, None)
-                self._last_seen_time.pop(tid, None)
-                with self._capture_counts_lock:
-                    self._capture_counts.pop(tid, None)
-
-            # 人数上限淘汰
-            if len(self.active_targets) > max_targets:
-                self._prune_targets(frame_w, frame_h, max_targets)
-
-    def _prune_targets(self, frame_w: int, frame_h: int, max_targets: int):
-        targets = list(self.active_targets.values())
-        frame_size = (frame_w, frame_h)
-        scored = []
-        for t in targets:
-            area = (t.bbox[2] - t.bbox[0]) * (t.bbox[3] - t.bbox[1])
-            center_distance = self._center_distance(t.center, frame_size)
-            target = TrackingTarget(
-                track_id=t.track_id,
-                position=(t.center[0] / frame_w, t.center[1] / frame_h),
-                last_update=time.time(),
-                area=area,
-                confidence=t.confidence,
-                center_distance=center_distance,
-            )
-            target.score = self.target_selector.calculate_score(target, frame_size)
-            scored.append(target)
-        scored.sort(key=lambda x: x.score, reverse=True)
-        keep_ids = {t.track_id for t in scored[:max_targets]}
-        remove_ids = [tid for tid in self.active_targets if tid not in keep_ids]
-        for tid in remove_ids:
-            self.active_targets.pop(tid, None)
-            self._last_seen_time.pop(tid, None)
-            if tid in self.target_order:
-                self.target_order.remove(tid)
-            with self._capture_counts_lock:
-                self._capture_counts.pop(tid, None)
-
-    def _center_distance(self, center: Tuple[int, int], frame_size: Tuple[int, int]) -> float:
-        cx, cy = frame_size[0] / 2, frame_size[1] / 2
-        dx = abs(center[0] - cx) / cx
-        dy = abs(center[1] - cy) / cy
-        return (dx + dy) / 2
-
-    def _ptz_worker(self):
-        while self.running:
-            try:
-                # 暂停时阻塞等待恢复
-                if self._paused:
-                    self._paused_event.wait()
-                    continue
-
-                # 原子性获取目标快照和当前目标
-                with self.targets_lock:
-                    target_order_snapshot = self.target_order.copy()
-                    has_targets = bool(self.active_targets)
-                    if target_order_snapshot:
-                        if self.current_index >= len(target_order_snapshot):
-                            self.current_index = 0
-                        target_id = target_order_snapshot[self.current_index]
-                        target = self.active_targets.get(target_id)
-                    else:
-                        target_id = None
-                        target = None
-
-                if not has_targets or not target_order_snapshot:
-                    self._flush_batch_if_needed()
-                    time.sleep(0.1)
-                    continue
-
-                if target is None or target.lost:
-                    # 目标丢失时跳过但保留在队列中,并短暂休眠避免忙等
-                    time.sleep(0.01)
-                    with self.targets_lock:
-                        self._advance(len(target_order_snapshot))
-                    continue
-
-                record = self._capture_one(target)
-                if record:
-                    with self.batch_lock:
-                        self.batch_captures.append(record)
-                    self._update_stats("captures")
-
-                with self.targets_lock:
-                    self._advance(len(target_order_snapshot))
-
-                # 一轮完成
-                with self.batch_lock:
-                    if self.current_index == 0 and self.batch_captures:
-                        self._upload_batch(self.batch_captures)
-                        self.batch_captures.clear()
-
-            except Exception as e:
-                logger.error(f"PTZ 线程错误: {e}")
-                time.sleep(0.1)
-
-    def _advance(self, order_len: int = None):
-        if order_len is None:
-            order_len = len(self.target_order) or 1
-        self.current_index = (self.current_index + 1) % order_len
-
-    def _capture_one(self, target: TrackedPerson) -> Optional[CaptureRecord]:
-        frame = self.panorama.get_frame()
-        if frame is None:
-            return None
-        frame_h, frame_w = frame.shape[:2]
-
-        x_ratio = target.center[0] / frame_w
-        y_ratio = target.center[1] / frame_h
-
-        if self.calibrator and self.calibrator.is_calibrated():
-            # 校准器已通过真实扫描建立全景坐标到球机 PTZ 角度的映射,
-            # 返回的角度可直接发送给球机,不应再应用 pan_flip。
-            pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
-            ptz_config = getattr(self.ptz, "ptz_config", {})
-            zoom = ptz_config.get("default_zoom", 8)
-        else:
-            pan, tilt, zoom = self.ptz.calculate_ptz_position(x_ratio, y_ratio)
-
-        #  enforce PTZ command cooldown
-        ptz_command_cooldown = self.config.get("ptz_command_cooldown", 0.2)
-        with self._last_ptz_command_time_lock:
-            elapsed = time.time() - self._last_ptz_command_time
-            if elapsed < ptz_command_cooldown:
-                time.sleep(ptz_command_cooldown - elapsed)
-
-        success = self.ptz.goto_exact_position(pan, tilt, zoom)
-        if not success:
-            return None
-
-        with self._last_ptz_command_time_lock:
-            self._last_ptz_command_time = time.time()
-
-        ptz_stabilize_time = self.config.get("ptz_stabilize_time", 2.0)
-        time.sleep(max(ptz_stabilize_time, ptz_command_cooldown))
-
-        ptz_frame = self._get_clear_ptz_frame()
-        if ptz_frame is None:
-            return None
-
-        max_cap = self.config.get("max_capture_per_target", 0)
-        with self._capture_counts_lock:
-            if max_cap > 0 and self._capture_counts.get(target.track_id, 0) >= max_cap:
-                return None
-            self._capture_counts[target.track_id] = self._capture_counts.get(target.track_id, 0) + 1
-
-        panorama_image = frame.copy() if self.config.get("save_panorama_pair", True) else None
-
-        # 本地保存
-        self._save_local(ptz_frame, panorama_image, target, pan, tilt, zoom)
-
-        return CaptureRecord(
-            track_id=target.track_id,
-            timestamp=time.time(),
-            position=(x_ratio, y_ratio),
-            ptz_position=(pan, tilt, zoom),
-            ptz_image=ptz_frame,
-            panorama_image=panorama_image,
-            confidence=target.confidence,
-        )
-
-    def _get_clear_ptz_frame(self, max_attempts: int = 5, wait_interval: float = 0.2) -> Optional[np.ndarray]:
-        best_frame = None
-        best_score = -1
-        for _ in range(max_attempts):
-            frame = self.ptz.get_frame()
-            if frame is not None:
-                frame_copy = frame.copy()
-                gray = cv2.cvtColor(frame_copy, cv2.COLOR_BGR2GRAY)
-                score = cv2.Laplacian(gray, cv2.CV_64F).var()
-                if score > best_score:
-                    best_score = score
-                    best_frame = frame_copy
-            time.sleep(wait_interval)
-        return best_frame
-
-    def _save_local(self, ptz_frame, panorama_image, target, pan, tilt, zoom):
-        capture_dir = self.config.get("capture_dir", "/home/admin/dsh/tracking_captures")
-        try:
-            os.makedirs(capture_dir, exist_ok=True)
-        except OSError as e:
-            logger.warning(f"无法创建抓拍目录 {capture_dir}: {e}")
-            return
-        timestamp = int(time.time() * 1000)
-        base = f"{capture_dir}/ptz_{target.track_id}_{timestamp}_{pan:.0f}_{tilt:.0f}_z{zoom}.jpg"
-        cv2.imwrite(base, ptz_frame)
-        if panorama_image is not None:
-            pan_base = f"{capture_dir}/panorama_{target.track_id}_{timestamp}.jpg"
-            cv2.imwrite(pan_base, panorama_image)
-
-    def _upload_batch(self, records: List[CaptureRecord]):
-        if not self.event_pusher or not self.config.get("enable_upload", True):
-            return
-        try:
-            uploads = []
-            for r in records:
-                ptz_url = self.event_pusher.upload_numpy_image(r.ptz_image)
-                pan_url = None
-                if r.panorama_image is not None:
-                    pan_url = self.event_pusher.upload_numpy_image(r.panorama_image)
-                uploads.append({
-                    "track_id": r.track_id,
-                    "ptz_image_url": ptz_url,
-                    "panorama_image_url": pan_url,
-                    "position": r.position,
-                    "ptz_position": r.ptz_position,
-                    "confidence": r.confidence,
-                    "timestamp": r.timestamp,
-                })
-            self.event_pusher.push_tracking_capture(batch_time=time.time(), captures=uploads)
-            self._update_stats("uploads")
-        except Exception as e:
-            logger.error(f"批量上传失败: {e}")
-
-    def _flush_batch_if_needed(self):
-        with self.batch_lock:
-            if self.batch_captures:
-                self._upload_batch(self.batch_captures)
-                self.batch_captures.clear()
-
-    def _update_stats(self, key: str, value: int = 1):
-        with self.stats_lock:
-            if key in self.stats:
-                self.stats[key] += value
-
-    def get_results(self) -> List[CaptureRecord]:
-        """获取当前抓拍结果(兼容既有协调器接口)"""
-        with self.batch_lock:
-            return self.batch_captures.copy()
-
-    def get_stats(self) -> Dict:
-        with self.stats_lock:
-            return self.stats.copy()

+ 0 - 543
dual_camera_system/ptz_person_tracker.py

@@ -1,543 +0,0 @@
-"""
-球机端人体检测与自动对焦模块
-在球机移动到位后,检测人体并自动调整焦距,使人体居中并占据合适比例
-"""
-
-import time
-import cv2
-import numpy as np
-import logging
-from typing import Optional, List, Tuple, Dict
-from dataclasses import dataclass
-from pathlib import Path
-from datetime import datetime
-
-logger = logging.getLogger(__name__)
-
-try:
-    from ultralytics import YOLO
-    HAS_YOLO = True
-except ImportError:
-    HAS_YOLO = False
-
-try:
-    from rknnlite.api import RKNNLite
-    HAS_RKNN = True
-except ImportError:
-    HAS_RKNN = False
-
-
-@dataclass
-class DetectedPerson:
-    """检测到的人体"""
-    bbox: Tuple[int, int, int, int]  # (x1, y1, x2, y2)
-    center: Tuple[float, float]       # 中心点 (x, y)
-    width: int
-    height: int
-    confidence: float
-    
-    @property
-    def area(self) -> int:
-        return self.width * self.height
-    
-    @property
-    def size_ratio(self) -> float:
-        """人体宽高比"""
-        return self.width / self.height if self.height > 0 else 0
-
-
-@dataclass
-class ZoomAdjustResult:
-    """变焦调整结果"""
-    success: bool
-    new_zoom: int
-    pan_adjust: float      # pan调整量(度)
-    tilt_adjust: float     # tilt调整量(度)
-    person_detected: bool
-    person_centered: bool
-    message: str
-
-
-class PTZPersonDetector:
-    """
-    球机端人体检测器
-    支持YOLO(PT)和RKNN两种模型格式
-    """
-    
-    def __init__(self, model_path: str = None, model_type: str = 'auto',
-                 confidence_threshold: float = 0.5, use_gpu: bool = False,
-                 save_image: bool = False, image_dir: str = '/home/admin/dsh/ptz_detection_images'):
-        """
-        初始化检测器
-        Args:
-            model_path: 模型路径
-            model_type: 模型类型 ('yolo', 'rknn', 'auto')
-            confidence_threshold: 置信度阈值
-            use_gpu: 是否使用GPU
-            save_image: 是否保存检测图片
-            image_dir: 图片保存目录
-        """
-        self.model_path = model_path
-        self.model_type = model_type
-        self.confidence_threshold = confidence_threshold
-        self.use_gpu = use_gpu
-        self.model = None
-        self.person_class_id = 0  # YOLO默认person类别ID
-        
-        # 图片保存配置
-        self._save_image_enabled = save_image
-        self._image_save_dir = Path(image_dir)
-        self._last_save_time = 0
-        self._save_interval = 0.5  # 最小保存间隔(秒)
-        
-        if self._save_image_enabled:
-            self._ensure_save_dir()
-        
-        if model_path:
-            self._load_model(model_path, model_type)
-    
-    def _ensure_save_dir(self):
-        """确保保存目录存在"""
-        try:
-            self._image_save_dir.mkdir(parents=True, exist_ok=True)
-            logger.info(f"[球机] 检测图片保存目录: {self._image_save_dir}")
-        except Exception as e:
-            logger.error(f"[球机] 创建检测图片目录失败: {e}")
-            self._save_image_enabled = False
-    
-    def _load_model(self, model_path: str, model_type: str = 'auto'):
-        """加载模型"""
-        if model_type == 'auto':
-            if model_path.endswith('.rknn'):
-                model_type = 'rknn'
-            elif model_path.endswith('.onnx'):
-                model_type = 'onnx'
-            else:
-                model_type = 'yolo'
-        
-        self.model_type = model_type
-        
-        if model_type == 'rknn':
-            self._load_rknn_model(model_path)
-        elif model_type == 'yolo':
-            self._load_yolo_model(model_path)
-        else:
-            print(f"[PTZPersonDetector] 不支持的模型类型: {model_type}")
-    
-    def _load_yolo_model(self, model_path: str):
-        """加载YOLO模型"""
-        if not HAS_YOLO:
-            print("[PTZPersonDetector] ultralytics未安装,无法使用YOLO模型")
-            return
-        
-        try:
-            self.model = YOLO(model_path)
-            print(f"[PTZPersonDetector] YOLO模型加载成功: {model_path}")
-        except Exception as e:
-            print(f"[PTZPersonDetector] YOLO模型加载失败: {e}")
-    
-    def _load_rknn_model(self, model_path: str):
-        """加载RKNN模型"""
-        if not HAS_RKNN:
-            print("[PTZPersonDetector] rknnlite未安装,无法使用RKNN模型")
-            return
-        
-        try:
-            self.model = RKNNLite()
-            ret = self.model.load_rknn(model_path)
-            if ret != 0:
-                raise RuntimeError(f"加载RKNN模型失败: ret={ret}")
-            
-            ret = self.model.init_runtime(target=None)  # 自动选择NPU核心
-            if ret != 0:
-                raise RuntimeError(f"初始化RKNN运行时失败: ret={ret}")
-            
-            # RKNN安全模型的person类别ID
-            self.person_class_id = 3
-            print(f"[PTZPersonDetector] RKNN模型加载成功: {model_path}")
-        except Exception as e:
-            print(f"[PTZPersonDetector] RKNN模型加载失败: {e}")
-            self.model = None
-    
-    def detect(self, frame: np.ndarray) -> List[DetectedPerson]:
-        """
-        检测人体
-        Args:
-            frame: BGR图像
-        Returns:
-            检测到的人体列表
-        """
-        if self.model is None:
-            return []
-        
-        if self.model_type == 'rknn':
-            return self._detect_rknn(frame)
-        elif self.model_type == 'yolo':
-            return self._detect_yolo(frame)
-        
-        return []
-    
-    def _detect_yolo(self, frame: np.ndarray) -> List[DetectedPerson]:
-        """YOLO检测"""
-        persons = []
-        
-        try:
-            results = self.model(frame, verbose=False)
-            
-            for r in results:
-                if r.boxes is None:
-                    continue
-                
-                for box in r.boxes:
-                    cls_id = int(box.cls[0])
-                    if cls_id != self.person_class_id:
-                        continue
-                    
-                    conf = float(box.conf[0])
-                    if conf < self.confidence_threshold:
-                        continue
-                    
-                    x1, y1, x2, y2 = map(int, box.xyxy[0])
-                    center_x = (x1 + x2) / 2
-                    center_y = (y1 + y2) / 2
-                    width = x2 - x1
-                    height = y2 - y1
-                    
-                    persons.append(DetectedPerson(
-                        bbox=(x1, y1, x2, y2),
-                        center=(center_x, center_y),
-                        width=width,
-                        height=height,
-                        confidence=conf
-                    ))
-        except Exception as e:
-            print(f"[PTZPersonDetector] YOLO检测错误: {e}")
-        
-        return persons
-    
-    def _detect_rknn(self, frame: np.ndarray) -> List[DetectedPerson]:
-        """RKNN检测(使用 letterbox 预处理 + BGR→RGB 转换)"""
-        persons = []
-
-        try:
-            h, w = frame.shape[:2]
-
-            # Letterbox 预处理:保持宽高比,灰边填充
-            input_size = 640
-            scale = min(input_size / w, input_size / h)
-            new_w = int(w * scale)
-            new_h = int(h * scale)
-            pad_w = (input_size - new_w) / 2
-            pad_h = (input_size - new_h) / 2
-
-            img = cv2.resize(frame, (new_w, new_h))
-            img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
-
-            # 填充灰边 (114,114,114)
-            letterboxed = np.full((input_size, input_size, 3), 114, dtype=np.uint8)
-            letterboxed[int(pad_h):int(pad_h + new_h), int(pad_w):int(pad_w + new_w)] = img
-
-            img_input = letterboxed.astype(np.float32) / 255.0
-            img_input = np.expand_dims(img_input, 0)
-
-            # 推理
-            outputs = self.model.inference(inputs=[img_input])
-
-            if outputs is None or len(outputs) == 0:
-                return []
-
-            output = outputs[0]
-
-            for i in range(output.shape[-1]):
-                data = output[0, :, i]
-
-                class_scores = data[4:]
-                class_id = np.argmax(class_scores)
-                confidence = class_scores[class_id]
-
-                if confidence < self.confidence_threshold:
-                    continue
-
-                if class_id != self.person_class_id:
-                    continue
-
-                cx, cy, bw, bh = data[:4]
-
-                # letterbox 坐标 → 原图坐标
-                x1 = int((cx - bw / 2 - pad_w) / scale)
-                y1 = int((cy - bh / 2 - pad_h) / scale)
-                x2 = int((cx + bw / 2 - pad_w) / scale)
-                y2 = int((cy + bh / 2 - pad_h) / scale)
-
-                # 裁剪到画面范围
-                x1 = max(0, min(x1, w))
-                y1 = max(0, min(y1, h))
-                x2 = max(0, min(x2, w))
-                y2 = max(0, min(y2, h))
-
-                persons.append(DetectedPerson(
-                    bbox=(x1, y1, x2, y2),
-                    center=((x1 + x2) / 2, (y1 + y2) / 2),
-                    width=x2 - x1,
-                    height=y2 - y1,
-                    confidence=float(confidence)
-                ))
-        except Exception as e:
-            print(f"[PTZPersonDetector] RKNN检测错误: {e}")
-
-        return persons
-    
-    def detect_largest_person(self, frame: np.ndarray) -> Optional[DetectedPerson]:
-        """检测最大的人体并保存图片"""
-        persons = self.detect(frame)
-        if persons:
-            self._save_detection_image(frame, persons)
-            return max(persons, key=lambda p: p.area)
-        return None
-    
-    def _save_detection_image(self, frame: np.ndarray, persons: List[DetectedPerson]):
-        """
-        保存带有检测标记的图片(只标记达到置信度阈值的人)
-        Args:
-            frame: 原始图像
-            persons: 检测到的人体列表
-        """
-        if not self._save_image_enabled or not persons:
-            return
-        
-        # 检查保存间隔
-        current_time = time.time()
-        if current_time - self._last_save_time < self._save_interval:
-            return
-        
-        try:
-            # 复制图像避免修改原图
-            marked_frame = frame.copy()
-            
-            # 只标记达到阈值的人
-            person_count = 0
-            
-            for person in persons:
-                # 未达阈值的不标记
-                if person.confidence < self.confidence_threshold:
-                    continue
-                
-                
-                x1, y1, x2, y2 = person.bbox
-                
-                # 绘制边界框(绿色)
-                cv2.rectangle(marked_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
-                
-                # 绘制序号标签
-                label = f"person_{person_count}"
-                person_count += 1
-                
-                (label_w, label_h), baseline = cv2.getTextSize(
-                    label, cv2.FONT_HERSHEY_SIMPLEX, 0.8, 2
-                )
-                cv2.rectangle(
-                    marked_frame,
-                    (x1, y1 - label_h - 8),
-                    (x1 + label_w, y1),
-                    (0, 255, 0),
-                    -1
-                )
-                
-                # 绘制标签文字(黑色)
-                cv2.putText(
-                    marked_frame, label,
-                    (x1, y1 - 4),
-                    cv2.FONT_HERSHEY_SIMPLEX, 0.8,
-                    (0, 0, 0), 2
-                )
-            
-            
-            # 无有效目标则不保存
-            if person_count == 0:
-                return
-            
-            
-            # 生成文件名(时间戳+有效人数)
-            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
-            filename = f"ptz_{timestamp}_n{person_count}.jpg"
-            filepath = self._image_save_dir / filename
-            
-            # 保存图片
-            cv2.imwrite(str(filepath), marked_frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
-            self._last_save_time = current_time
-            
-            logger.info(f"[球机] 已保存检测图片: {filepath},有效人数 {person_count} (阈值={self.confidence_threshold})")
-            
-        except Exception as e:
-            logger.error(f"[球机] 保存检测图片失败: {e}")
-
-
-class PTZAutoZoomController:
-    """
-    球机自动变焦控制器
-    根据检测到的人体大小和位置,自动调整PTZ角度和变焦
-    """
-    
-    def __init__(self, ptz_camera, detector: PTZPersonDetector = None, config: dict = None):
-        """
-        初始化控制器
-        Args:
-            ptz_camera: PTZCamera实例
-            detector: 人体检测器
-            config: 自动变焦配置
-        """
-        self.ptz = ptz_camera
-        self.detector = detector
-        self.config = config or {}
-        
-        # 默认配置
-        self.target_size_ratio = self.config.get('target_size_ratio', 0.4)
-        self.min_zoom = self.config.get('min_zoom', 3)
-        self.max_zoom = self.config.get('max_zoom', 20)
-        self.zoom_step = self.config.get('zoom_step', 2)
-        self.center_threshold = self.config.get('center_threshold', 0.1)
-        self.max_adjust_attempts = self.config.get('max_adjust_attempts', 3)
-    
-    def adjust_to_person(self, frame: np.ndarray, current_pan: float, 
-                         current_tilt: float, current_zoom: int) -> ZoomAdjustResult:
-        """
-        调整PTZ使检测到的人体居中并占据合适比例
-        
-        Args:
-            frame: 球机画面
-            current_pan: 当前pan角度
-            current_tilt: 当前tilt角度
-            current_zoom: 当前变倍
-            
-        Returns:
-            ZoomAdjustResult: 调整结果
-        """
-        if frame is None:
-            return ZoomAdjustResult(
-                success=False, new_zoom=current_zoom,
-                pan_adjust=0, tilt_adjust=0,
-                person_detected=False, person_centered=False,
-                message="无法获取球机画面"
-            )
-        
-        # 检测人体
-        person = self.detector.detect_largest_person(frame) if self.detector else None
-        
-        if person is None:
-            return ZoomAdjustResult(
-                success=False, new_zoom=current_zoom,
-                pan_adjust=0, tilt_adjust=0,
-                person_detected=False, person_centered=False,
-                message="球机画面中未检测到人体"
-            )
-        
-        h, w = frame.shape[:2]
-        
-        # 计算人体中心偏离画面中心的程度
-        frame_center_x = w / 2
-        frame_center_y = h / 2
-        
-        offset_x = (person.center[0] - frame_center_x) / w  # -0.5 ~ 0.5
-        offset_y = (person.center[1] - frame_center_y) / h  # -0.5 ~ 0.5
-        
-        # 计算人体占画面比例
-        person_size_ratio = max(person.width / w, person.height / h)
-        
-        print(f"[AutoZoom] 检测到人体: 中心=({person.center[0]:.0f}, {person.center[1]:.0f}), "
-              f"尺寸={person.width}x{person.height}, 占比={person.size_ratio:.2f}")
-        print(f"[AutoZoom] 偏移: x={offset_x:.3f}, y={offset_y:.3f}")
-        
-        # 判断是否居中
-        is_centered = abs(offset_x) < self.center_threshold and abs(offset_y) < self.center_threshold
-        
-        # 计算PTZ角度调整量 (根据视场角估算)
-        # 假设当前zoom下的水平视场角约 60/zoom 度
-        fov_per_pixel = (60.0 / current_zoom) / w
-        pan_adjust = -offset_x * 60.0 / current_zoom  # 简化计算
-        tilt_adjust = -offset_y * 45.0 / current_zoom
-        
-        # 计算新的zoom
-        if person_size_ratio < self.target_size_ratio * 0.8:
-            # 人体太小,放大
-            zoom_factor = self.target_size_ratio / person_size_ratio
-            new_zoom = min(int(current_zoom * zoom_factor), self.max_zoom)
-        elif person_size_ratio > self.target_size_ratio * 1.2:
-            # 人体太大,缩小
-            zoom_factor = self.target_size_ratio / person_size_ratio
-            new_zoom = max(int(current_zoom * zoom_factor), self.min_zoom)
-        else:
-            # 大小合适
-            new_zoom = current_zoom
-        
-        # 限制调整量
-        if is_centered:
-            pan_adjust = 0
-            tilt_adjust = 0
-        
-        return ZoomAdjustResult(
-            success=True,
-            new_zoom=new_zoom,
-            pan_adjust=pan_adjust,
-            tilt_adjust=tilt_adjust,
-            person_detected=True,
-            person_centered=is_centered,
-            message=f"人体居中={is_centered}, zoom调整={current_zoom}→{new_zoom}"
-        )
-    
-    def auto_focus_loop(self, get_frame_func, max_attempts: int = None) -> Tuple[bool, int]:
-        """
-        自动对焦循环
-        持续调整PTZ直到人体居中且大小合适
-        
-        Args:
-            get_frame_func: 获取球机画面的函数
-            max_attempts: 最大调整次数
-            
-        Returns:
-            (是否成功, 最终zoom)
-        """
-        max_attempts = max_attempts or self.max_adjust_attempts
-        
-        current_pos = self.ptz.get_current_position()
-        current_pan = current_pos.pan
-        current_tilt = current_pos.tilt
-        current_zoom = current_pos.zoom
-        
-        for attempt in range(max_attempts):
-            print(f"[AutoZoom] 调整轮次 {attempt + 1}/{max_attempts}")
-            
-            # 等待画面稳定
-            time.sleep(0.3)
-            
-            # 获取球机画面
-            frame = get_frame_func()
-            
-            # 分析并计算调整
-            result = self.adjust_to_person(frame, current_pan, current_tilt, current_zoom)
-            
-            if not result.person_detected:
-                print(f"[AutoZoom] 未检测到人体,停止调整")
-                return False, current_zoom
-            
-            if result.person_centered and result.new_zoom == current_zoom:
-                print(f"[AutoZoom] 人体已居中且大小合适,调整完成")
-                return True, current_zoom
-            
-            # 执行调整
-            if result.pan_adjust != 0 or result.tilt_adjust != 0:
-                new_pan = current_pan + result.pan_adjust
-                new_tilt = current_tilt + result.tilt_adjust
-                print(f"[AutoZoom] 调整角度: pan {current_pan:.1f}→{new_pan:.1f}, "
-                      f"tilt {current_tilt:.1f}→{new_tilt:.1f}")
-                self.ptz.goto_exact_position(new_pan, new_tilt, result.new_zoom)
-                current_pan = new_pan
-                current_tilt = new_tilt
-            elif result.new_zoom != current_zoom:
-                print(f"[AutoZoom] 调整变焦: {current_zoom}→{result.new_zoom}")
-                self.ptz.goto_exact_position(current_pan, current_tilt, result.new_zoom)
-            
-            current_zoom = result.new_zoom
-        
-        print(f"[AutoZoom] 达到最大调整次数,当前zoom={current_zoom}")
-        return True, current_zoom
-

+ 2 - 0
dual_camera_system/pytest.ini

@@ -0,0 +1,2 @@
+[pytest]
+testpaths = tests

+ 6 - 5
dual_camera_system/requirements.txt

@@ -19,14 +19,15 @@ urllib3>=2.0.0
 # 日志
 colorlog>=6.7.0
 
+# FastAPI web framework
+fastapi>=0.110.0
+uvicorn[standard]>=0.27.0
+python-multipart>=0.0.9
+httpx>=0.27.0
+
 # ============================================
 # 可选依赖
 # ============================================
 
 # RKNN 加速 (仅 Orange Pi 等 ARM 设备需要)
 # rknn-toolkit2>=1.5.0
-
-# 语音播报 (Linux)
-# gst-python>=1.0.0
-# pycairo>=1.20.0
-# pygobject>=3.44.0

BIN
dual_camera_system/scripts/__pycache__/calibration_scanner.cpython-310.pyc


BIN
dual_camera_system/scripts/__pycache__/local_test.cpython-310.pyc


BIN
dual_camera_system/scripts/__pycache__/re_match_orb.cpython-310.pyc


+ 1 - 1
dual_camera_system/test_ptz.py → dual_camera_system/scripts/manual_ptz_test.py

@@ -8,7 +8,7 @@ import os
 import sys
 import time
 
-sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 
 from config import PTZ_CAMERA, SDK_PATH, PTZ_CONFIG
 from dahua_sdk import DahuaSDK, PTZCommand

BIN
dual_camera_system/tests/__pycache__/test_event_pusher_upload.cpython-310-pytest-9.0.2.pyc


BIN
dual_camera_system/tests/__pycache__/test_event_pusher_upload.cpython-310.pyc


BIN
dual_camera_system/tests/__pycache__/test_integration_polling.cpython-310-pytest-9.0.2.pyc


BIN
dual_camera_system/tests/__pycache__/test_polling_tracker.cpython-310-pytest-9.0.2.pyc


BIN
dual_camera_system/tests/__pycache__/test_polling_tracker.cpython-310.pyc


BIN
dual_camera_system/tests/__pycache__/test_tracker.cpython-310-pytest-9.0.2.pyc


+ 136 - 0
dual_camera_system/tests/test_capture_uploader.py

@@ -0,0 +1,136 @@
+import sys
+import os
+import threading
+import logging
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import numpy as np
+import pytest
+from core.capture_uploader import CaptureUploader
+
+
+def test_handle_detection_saves_and_uploads(tmp_path):
+    uploads = []
+    uploader = CaptureUploader("g1", str(tmp_path), upload_callback=uploads.append, dedup_seconds=5.0)
+    frame = np.zeros((100, 100, 3), dtype=np.uint8)
+    dets = [{"bbox": [10, 10, 30, 30], "confidence": 0.9}]
+    results = uploader.handle_detection("panorama", frame, dets)
+    assert len(results) == 1
+    assert len(uploads) == 1
+    batch_info = uploads[0]
+    assert "batch_id" in batch_info
+    assert "device_id" in batch_info
+    assert batch_info["image_paths"] == [results[0]["original"], results[0]["marked"]]
+    assert len(batch_info["detections"]) == 1
+    assert os.path.exists(results[0]["original"])
+    assert os.path.exists(results[0]["marked"])
+
+
+def test_dedup(tmp_path):
+    uploads = []
+    uploader = CaptureUploader("g1", str(tmp_path), upload_callback=uploads.append, dedup_seconds=5.0)
+    frame = np.zeros((100, 100, 3), dtype=np.uint8)
+    dets = [{"bbox": [10, 10, 30, 30], "confidence": 0.9}]
+    uploader.handle_detection("panorama", frame, dets)
+    uploader.handle_detection("panorama", frame, dets)
+    assert len(uploads) == 1
+    assert len(uploads[0]["detections"]) == 1
+
+
+def test_thread_safety(tmp_path):
+    uploads = []
+    errors = []
+    uploader = CaptureUploader("g1", str(tmp_path), upload_callback=uploads.append, dedup_seconds=5.0)
+    frame = np.zeros((100, 100, 3), dtype=np.uint8)
+    dets = [{"bbox": [10, 10, 30, 30], "confidence": 0.9}]
+
+    def worker():
+        try:
+            uploader.handle_detection("panorama", frame, dets)
+        except Exception as exc:
+            errors.append(exc)
+
+    threads = [threading.Thread(target=worker) for _ in range(10)]
+    for t in threads:
+        t.start()
+    for t in threads:
+        t.join()
+
+    assert not errors
+    assert len(uploads) == 1
+    assert len(uploads[0]["detections"]) == 1
+
+
+@pytest.mark.parametrize("frame", [
+    None,
+    np.zeros((100, 100), dtype=np.uint8),
+    np.zeros((100, 100, 4), dtype=np.uint8),
+    np.zeros((100, 100, 3), dtype=np.float32),
+])
+def test_invalid_frame_raises(tmp_path, frame):
+    uploader = CaptureUploader("g1", str(tmp_path), dedup_seconds=5.0)
+    dets = [{"bbox": [10, 10, 30, 30], "confidence": 0.9}]
+    with pytest.raises(ValueError):
+        uploader.handle_detection("panorama", frame, dets)
+
+
+@pytest.mark.parametrize("dets", [
+    [{"confidence": 0.9}],
+    [{"bbox": [10, 10, 30], "confidence": 0.9}],
+    [{"bbox": [10, 10, 30, "x"], "confidence": 0.9}],
+    [{"bbox": [10, 10, 30, 30]}],
+    [{"bbox": [10, 10, 30, 30], "confidence": "high"}],
+])
+def test_invalid_detections_raise(tmp_path, dets):
+    uploader = CaptureUploader("g1", str(tmp_path), dedup_seconds=5.0)
+    frame = np.zeros((100, 100, 3), dtype=np.uint8)
+    with pytest.raises(ValueError):
+        uploader.handle_detection("panorama", frame, dets)
+
+
+def test_all_dedup_writes_no_files(tmp_path):
+    uploads = []
+    uploader = CaptureUploader("g1", str(tmp_path), upload_callback=uploads.append, dedup_seconds=5.0)
+    frame = np.zeros((100, 100, 3), dtype=np.uint8)
+    dets = [{"bbox": [10, 10, 30, 30], "confidence": 0.9}]
+    uploader.handle_detection("panorama", frame, dets)
+
+    before = set(os.listdir(uploader.save_dir))
+    results = uploader.handle_detection("panorama", frame, dets)
+    after = set(os.listdir(uploader.save_dir))
+
+    assert results == []
+    assert before == after
+
+
+def test_upload_callback_exception_logged_not_propagated(tmp_path, caplog):
+    def failing_callback(payload):
+        raise RuntimeError("upload failed")
+
+    uploader = CaptureUploader("g1", str(tmp_path), upload_callback=failing_callback, dedup_seconds=5.0)
+    frame = np.zeros((100, 100, 3), dtype=np.uint8)
+    dets = [{"bbox": [10, 10, 30, 30], "confidence": 0.9}]
+
+    with caplog.at_level(logging.WARNING):
+        results = uploader.handle_detection("panorama", frame, dets)
+
+    assert len(results) == 1
+    assert "upload failed" in caplog.text
+    assert len(uploader._last_uploads) == 1
+
+
+def test_float_bbox_cast_to_int(tmp_path):
+    """Float bboxes should be cast to ints for cv2 drawing and stored as ints in payload."""
+    uploads = []
+    uploader = CaptureUploader("g1", str(tmp_path), upload_callback=uploads.append, dedup_seconds=5.0)
+    frame = np.zeros((100, 100, 3), dtype=np.uint8)
+    dets = [{"bbox": [10.7, 15.2, 30.9, 35.1], "confidence": 0.85}]
+
+    results = uploader.handle_detection("panorama", frame, dets)
+
+    assert len(results) == 1
+    assert results[0]["bbox"] == [10, 15, 30, 35]
+    assert len(uploads) == 1
+    assert uploads[0]["detections"][0]["bbox"] == [10.7, 15.2, 30.9, 35.1]
+    assert os.path.exists(results[0]["original"])
+    assert os.path.exists(results[0]["marked"])

+ 71 - 0
dual_camera_system/tests/test_coord_utils.py

@@ -0,0 +1,71 @@
+import sys
+import os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import math
+from core.coord_utils import spherical_to_pan_tilt, pan_tilt_to_vector, compute_sample_grid
+
+
+def test_spherical_front():
+    pan, tilt = spherical_to_pan_tilt(0.0, 0.0, 1.0)
+    assert abs(pan - 0.0) < 1e-6
+    assert abs(tilt - 0.0) < 1e-6
+
+
+def test_spherical_right():
+    pan, tilt = spherical_to_pan_tilt(1.0, 0.0, 0.0)
+    assert abs(pan - 90.0) < 1e-6
+    assert abs(tilt - 0.0) < 1e-6
+
+
+def test_spherical_up():
+    # pan is undefined at zenith (gimbal lock), so only tilt is asserted.
+    pan, tilt = spherical_to_pan_tilt(0.0, 1.0, 0.0)
+    assert abs(tilt - 90.0) < 1e-6
+
+
+def test_pan_tilt_to_vector_roundtrip():
+    for pan in [0, 45, 90, 180, 270]:
+        for tilt in [-20, 0, 20]:
+            x, y, z = pan_tilt_to_vector(pan, tilt)
+            pan2, tilt2 = spherical_to_pan_tilt(x, y, z)
+            assert abs(pan - pan2) < 1e-6 or abs(abs(pan - pan2) - 360) < 1e-6
+            assert abs(tilt - tilt2) < 1e-6
+
+
+def test_spherical_zero_vector():
+    pan, tilt = spherical_to_pan_tilt(0.0, 0.0, 0.0)
+    assert abs(pan - 0.0) < 1e-6
+    assert abs(tilt - 0.0) < 1e-6
+
+
+def test_spherical_down():
+    pan, tilt = spherical_to_pan_tilt(0.0, -1.0, 0.0)
+    assert abs(tilt - (-90.0)) < 1e-6
+
+
+def test_spherical_pan_360_boundary():
+    pan_in, tilt_in = 359.0, 0.0
+    x, y, z = pan_tilt_to_vector(pan_in, tilt_in)
+    pan_out, tilt_out = spherical_to_pan_tilt(x, y, z)
+    assert abs(pan_out - pan_in) < 1e-6
+    assert abs(tilt_out - tilt_in) < 1e-6
+
+
+def test_compute_sample_grid_default():
+    points = compute_sample_grid()
+    assert len(points) == 36
+    for point in points:
+        assert isinstance(point, tuple)
+        assert len(point) == 2
+
+
+def test_compute_sample_grid_custom():
+    points = compute_sample_grid(pan_range=(0.0, 90.0), tilt_layers=(-10.0, 0.0), pan_step=30.0)
+    assert len(points) == 6
+    for point in points:
+        assert isinstance(point, tuple)
+        assert len(point) == 2
+        pan, tilt = point
+        assert 0.0 <= pan < 90.0
+        assert tilt in (-10.0, 0.0)

+ 0 - 93
dual_camera_system/tests/test_event_pusher_upload.py

@@ -1,93 +0,0 @@
-import sys
-import os
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-import numpy as np
-import pytest
-from event_pusher import EventPusher
-
-
-def test_upload_numpy_image(monkeypatch):
-    config = {"device_id": "test-device", "base_url": "http://localhost"}
-    pusher = EventPusher(config)
-
-    called = {"path": None, "existed": False}
-
-    def fake_upload(path):
-        called["path"] = path
-        called["existed"] = os.path.exists(path)
-        return "http://example.com/image.jpg"
-
-    monkeypatch.setattr(pusher, "_upload_image", fake_upload)
-
-    img = np.zeros((100, 100, 3), dtype=np.uint8)
-    url = pusher.upload_numpy_image(img)
-
-    assert url == "http://example.com/image.jpg"
-    assert called["path"] is not None
-    assert called["existed"]
-
-
-def test_push_tracking_capture(monkeypatch):
-    config = {"device_id": "test-device", "base_url": "http://localhost"}
-    pusher = EventPusher(config)
-
-    captured = {}
-
-    def fake_post(url, json):
-        captured["url"] = url
-        captured["json"] = json
-        class Resp:
-            status_code = 200
-        return Resp()
-
-    monkeypatch.setattr(pusher, "_post", fake_post)
-
-    pusher.push_tracking_capture(
-        batch_time=1234567890.0,
-        captures=[{"track_id": 1, "ptz_image_url": "url1"}]
-    )
-
-    assert captured["url"] == "http://localhost/api/system/event"
-    assert captured["json"]["eventType"] == "TRACKING_CAPTURE"
-    assert captured["json"]["data"]["captureCount"] == 1
-
-
-def test_upload_numpy_image_cleans_temp_file_on_success(monkeypatch):
-    config = {"device_id": "test-device", "base_url": "http://localhost"}
-    pusher = EventPusher(config)
-    temp_paths = []
-
-    def fake_upload(path):
-        temp_paths.append(path)
-        assert os.path.exists(path)
-        return "http://example.com/image.jpg"
-
-    monkeypatch.setattr(pusher, "_upload_image", fake_upload)
-
-    img = np.zeros((100, 100, 3), dtype=np.uint8)
-    url = pusher.upload_numpy_image(img)
-
-    assert url == "http://example.com/image.jpg"
-    assert len(temp_paths) == 1
-    assert not os.path.exists(temp_paths[0])
-
-
-def test_upload_numpy_image_cleans_temp_file_on_upload_failure(monkeypatch):
-    config = {"device_id": "test-device", "base_url": "http://localhost"}
-    pusher = EventPusher(config)
-    temp_paths = []
-
-    def fake_upload(path):
-        temp_paths.append(path)
-        assert os.path.exists(path)
-        raise RuntimeError("upload failed")
-
-    monkeypatch.setattr(pusher, "_upload_image", fake_upload)
-
-    img = np.zeros((100, 100, 3), dtype=np.uint8)
-    url = pusher.upload_numpy_image(img)
-
-    assert url is None
-    assert len(temp_paths) == 1
-    assert not os.path.exists(temp_paths[0])

+ 149 - 0
dual_camera_system/tests/test_group_state.py

@@ -0,0 +1,149 @@
+import sys
+import os
+import threading
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import pytest
+from core.group_state import GroupState
+
+
+def test_init_and_get():
+    gs = GroupState()
+    gs.reset()
+    gs.init_group("g1", "rtsp://pano", "rtsp://ptz", {})
+    state = gs.get("g1")
+    assert state["panorama_rtsp"] == "rtsp://pano"
+    assert state["polling_state"] == "idle"
+
+
+def test_update_and_log():
+    gs = GroupState()
+    gs.reset()
+    gs.init_group("g1", "rtsp://pano", "rtsp://ptz", {})
+    gs.update("g1", "polling_state", "polling")
+    gs.append_log("g1", "started")
+    state = gs.get("g1")
+    assert state["polling_state"] == "polling"
+    assert state["logs"] == ["started"]
+
+
+def test_get_returns_deep_copy():
+    gs = GroupState()
+    gs.reset()
+    gs.init_group("g1", "rtsp://pano", "rtsp://ptz", {})
+
+    state1 = gs.get("g1")
+    state1["ptz_position"]["pan"] = 45.0
+    state1["logs"].append("mutated")
+    state1["polling_state"] = "polling"
+
+    state2 = gs.get("g1")
+    assert state2["ptz_position"]["pan"] == 0.0
+    assert state2["logs"] == []
+    assert state2["polling_state"] == "idle"
+
+
+def test_init_group_raises_on_duplicate():
+    gs = GroupState()
+    gs.reset()
+    gs.init_group("g1", "rtsp://pano", "rtsp://ptz", {})
+    with pytest.raises(ValueError, match="Group g1 already initialized"):
+        gs.init_group("g1", "rtsp://pano", "rtsp://ptz", {})
+
+
+def test_update_nested():
+    gs = GroupState()
+    gs.reset()
+    gs.init_group("g1", "rtsp://pano", "rtsp://ptz", {})
+
+    gs.update_nested("g1", "ptz_position", "pan", 30.0)
+    gs.update_nested("g1", "scan_progress", "current", 5)
+
+    state = gs.get("g1")
+    assert state["ptz_position"]["pan"] == 30.0
+    assert state["scan_progress"]["current"] == 5
+
+
+def test_update_nested_unknown_group():
+    gs = GroupState()
+    gs.reset()
+    with pytest.raises(KeyError, match="Group unknown not initialized"):
+        gs.update_nested("unknown", "ptz_position", "pan", 10.0)
+
+
+def test_update_nested_non_dict_key():
+    gs = GroupState()
+    gs.reset()
+    gs.init_group("g1", "rtsp://pano", "rtsp://ptz", {})
+    with pytest.raises(KeyError, match="Key polling_state is not a dict"):
+        gs.update_nested("g1", "polling_state", "x", "y")
+
+
+def test_append_log_caps_at_200():
+    gs = GroupState()
+    gs.reset()
+    gs.init_group("g1", "rtsp://pano", "rtsp://ptz", {})
+    for i in range(250):
+        gs.append_log("g1", f"log-{i}")
+    state = gs.get("g1")
+    assert len(state["logs"]) == 200
+    assert state["logs"][0] == "log-50"
+    assert state["logs"][-1] == "log-249"
+
+
+def test_unknown_group_warns_on_update_and_append_log(caplog):
+    import logging
+
+    gs = GroupState()
+    gs.reset()
+    with caplog.at_level(logging.WARNING):
+        gs.update("unknown", "polling_state", "polling")
+        gs.append_log("unknown", "message")
+
+    assert "Cannot update unknown group: unknown" in caplog.text
+    assert "Cannot append log to unknown group: unknown" in caplog.text
+
+
+def test_compare_and_update():
+    gs = GroupState()
+    gs.reset()
+    gs.init_group("g1", "rtsp://pano", "rtsp://ptz", {})
+
+    assert gs.compare_and_update("g1", "polling_state", "idle", "scanning") is True
+    assert gs.get("g1")["polling_state"] == "scanning"
+
+    assert gs.compare_and_update("g1", "polling_state", "idle", "polling") is False
+    assert gs.get("g1")["polling_state"] == "scanning"
+
+    assert gs.compare_and_update("unknown", "polling_state", "idle", "scanning") is False
+
+
+def test_thread_safety():
+    gs = GroupState()
+    gs.reset()
+    gs.init_group("g1", "rtsp://pano", "rtsp://ptz", {})
+
+    errors = []
+
+    def worker(idx):
+        try:
+            for i in range(100):
+                gs.update("g1", "last_detection", i)
+                gs.append_log("g1", f"worker-{idx}-{i}")
+                _ = gs.get("g1")
+                time.sleep(0.0001)
+        except Exception as e:
+            errors.append(e)
+
+    threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)]
+    for t in threads:
+        t.start()
+    for t in threads:
+        t.join()
+
+    assert not errors
+    state = gs.get("g1")
+    assert len(state["logs"]) == 200
+    assert state["last_detection"] is not None

+ 0 - 91
dual_camera_system/tests/test_integration_polling.py

@@ -1,91 +0,0 @@
-import sys
-import os
-import tempfile
-
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-import numpy as np
-from polling_tracker import PollingTrackingCoordinator
-from tracker import TrackedPerson, UltralyticsTracker
-
-
-class FakePanorama:
-    def __init__(self):
-        self.connected = False
-        self.streaming = False
-
-    def connect(self):
-        self.connected = True
-        return True
-
-    def start_stream_rtsp(self):
-        self.streaming = True
-        return True
-
-    def get_frame(self):
-        return np.zeros((480, 640, 3), dtype=np.uint8)
-
-    def disconnect(self):
-        self.connected = False
-        self.streaming = False
-
-
-class FakePTZ:
-    def __init__(self):
-        self.connected = False
-        self.commands = []
-        self.ptz_config = {"default_zoom": 8}
-
-    def connect(self):
-        self.connected = True
-        return True
-
-    def goto_exact_position(self, pan, tilt, zoom):
-        self.commands.append((pan, tilt, zoom))
-        return True
-
-    def get_frame(self):
-        return np.zeros((480, 640, 3), dtype=np.uint8)
-
-    def get_current_position(self):
-        return type("P", (), {"pan": 0, "tilt": 0, "zoom": 1})()
-
-    def calculate_ptz_position(self, x, y, zoom=None):
-        return x * 180, y * 90, zoom or 8
-
-    def disconnect(self):
-        self.connected = False
-
-
-class FakeTracker:
-    def __init__(self):
-        self.frame_count = 0
-
-    def update(self, frame):
-        self.frame_count += 1
-        return [
-            TrackedPerson(track_id=1, bbox=(100, 100, 150, 200), center=(125, 150), confidence=0.9),
-        ]
-
-
-def test_integration_polling_start_stop():
-    pan = FakePanorama()
-    ptz = FakePTZ()
-    tracker = FakeTracker()
-    capture_dir = tempfile.mkdtemp(prefix="tracking_captures_")
-    coord = PollingTrackingCoordinator(pan, ptz, tracker, config={
-        "max_tracking_targets": 4,
-        "ptz_stabilize_time": 0.05,
-        "tracking_timeout": 1.0,
-        "enable_upload": False,
-        "capture_dir": capture_dir,
-    })
-
-    assert coord.start() is True
-    import time
-    time.sleep(0.3)
-    coord.stop()
-
-    assert pan.connected is False
-    assert ptz.connected is False
-    assert len(ptz.commands) > 0

+ 125 - 0
dual_camera_system/tests/test_polling_scheduler.py

@@ -0,0 +1,125 @@
+import sys
+import os
+import time
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from core.polling_scheduler import PollingScheduler
+
+
+class FakePTZ:
+    def __init__(self):
+        self.positions = []
+    def goto_exact_position(self, pan, tilt, zoom):
+        self.positions.append((pan, tilt, zoom))
+
+
+class FailingPTZ:
+    def goto_exact_position(self, pan, tilt, zoom):
+        raise RuntimeError("PTZ failure")
+
+
+def test_polling_scheduler_basic():
+    ptz = FakePTZ()
+    points = [{"pan": 0, "tilt": 0, "zoom": 1, "dwell_time": 0.1}]
+    scheduler = PollingScheduler("g1", ptz, lambda: points, stabilize_time=0.0)
+    scheduler.start()
+    time.sleep(0.5)
+    scheduler.stop()
+    assert len(ptz.positions) >= 1
+
+
+def test_polling_scheduler_empty_points():
+    ptz = FakePTZ()
+    scheduler = PollingScheduler("g1", ptz, lambda: [], stabilize_time=0.0)
+    scheduler.start()
+    time.sleep(0.3)
+    scheduler.stop()
+    assert len(ptz.positions) == 0
+
+
+def test_polling_scheduler_multiple_points():
+    ptz = FakePTZ()
+    points = [
+        {"pan": 0, "tilt": 0, "zoom": 1, "dwell_time": 0.05},
+        {"pan": 90, "tilt": 10, "zoom": 2, "dwell_time": 0.05},
+        {"pan": 180, "tilt": 20, "zoom": 3, "dwell_time": 0.05},
+    ]
+    scheduler = PollingScheduler("g1", ptz, lambda: points, stabilize_time=0.0)
+    scheduler.start()
+    time.sleep(1.0)
+    scheduler.stop()
+    assert len(ptz.positions) >= 3
+    assert ptz.positions[0] == (0, 0, 1)
+    assert ptz.positions[1] == (90, 10, 2)
+    assert ptz.positions[2] == (180, 20, 3)
+
+
+def test_polling_scheduler_malformed_point_skipped():
+    ptz = FakePTZ()
+    points = [
+        {"pan": 0, "tilt": 0, "zoom": 1, "dwell_time": 0.05},
+        {"tilt": 10, "zoom": 2},
+        {"pan": 90},
+        "not-a-dict",
+        {"pan": 180, "tilt": 20, "zoom": 3, "dwell_time": 0.05},
+    ]
+    scheduler = PollingScheduler("g1", ptz, lambda: points, stabilize_time=0.0)
+    scheduler.start()
+    time.sleep(0.5)
+    scheduler.stop()
+    valid_positions = {(0, 0, 1), (180, 20, 3)}
+    assert set(ptz.positions) == valid_positions
+    assert (0, 0, 1) in ptz.positions
+    assert (180, 20, 3) in ptz.positions
+
+
+def test_polling_scheduler_on_arrived_invoked():
+    ptz = FakePTZ()
+    arrived = []
+    points = [
+        {"pan": 0, "tilt": 0, "zoom": 1, "dwell_time": 0.05},
+        {"pan": 90, "tilt": 10, "zoom": 2, "dwell_time": 0.05},
+    ]
+    scheduler = PollingScheduler(
+        "g1",
+        ptz,
+        lambda: points,
+        on_arrived=lambda pt: arrived.append(pt),
+        stabilize_time=0.0,
+    )
+    scheduler.start()
+    time.sleep(0.6)
+    scheduler.stop()
+    assert len(arrived) >= 2
+    assert arrived[0] == points[0]
+    assert arrived[1] == points[1]
+
+
+def test_polling_scheduler_pause_resume():
+    ptz = FakePTZ()
+    points = [{"pan": 0, "tilt": 0, "zoom": 1, "dwell_time": 0.5}]
+    scheduler = PollingScheduler("g1", ptz, lambda: points, stabilize_time=0.0)
+    scheduler.start()
+    time.sleep(0.2)
+    scheduler.pause()
+    time.sleep(0.5)
+    positions_while_paused = list(ptz.positions)
+    scheduler.resume()
+    time.sleep(0.7)
+    scheduler.stop()
+    assert len(positions_while_paused) >= 1
+    assert len(ptz.positions) >= len(positions_while_paused)
+
+
+def test_polling_scheduler_ptz_error_continues():
+    ptz = FailingPTZ()
+    points = [
+        {"pan": 0, "tilt": 0, "zoom": 1, "dwell_time": 0.05},
+        {"pan": 90, "tilt": 10, "zoom": 2, "dwell_time": 0.05},
+    ]
+    scheduler = PollingScheduler("g1", ptz, lambda: points, stabilize_time=0.0)
+    scheduler.start()
+    time.sleep(0.6)
+    scheduler.stop()
+    # The scheduler should not crash; thread exits cleanly.
+    assert scheduler.thread is None

+ 0 - 338
dual_camera_system/tests/test_polling_tracker.py

@@ -1,338 +0,0 @@
-import sys
-import os
-import time
-import threading
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-import numpy as np
-import pytest
-from polling_tracker import PollingTrackingCoordinator, CaptureRecord
-from tracker import TrackedPerson
-
-
-class _FakePosition:
-    def __init__(self):
-        self.pan = 0
-        self.tilt = 0
-        self.zoom = 1
-
-
-class FakePanorama:
-    def __init__(self):
-        self.frame = np.zeros((480, 640, 3), dtype=np.uint8)
-        self.connected = False
-        self.streaming = False
-        self.stopped = False
-
-    def connect(self):
-        self.connected = True
-        return True
-
-    def start_stream_rtsp(self):
-        self.streaming = True
-        return True
-
-    def stop_stream_rtsp(self):
-        self.stopped = True
-        return True
-
-    def disconnect(self):
-        self.connected = False
-        self.streaming = False
-        return True
-
-    def get_frame(self):
-        return self.frame.copy()
-
-
-class FakePTZ:
-    def __init__(self):
-        self.commands = []
-        self.connected = False
-        self.ptz_frame = np.zeros((100, 100, 3), dtype=np.uint8)
-        self.current_position = _FakePosition()
-        self.ptz_config = {"default_zoom": 8}
-
-    def connect(self):
-        self.connected = True
-        return True
-
-    def disconnect(self):
-        self.connected = False
-        return True
-
-    def goto_exact_position(self, pan, tilt, zoom):
-        self.commands.append((pan, tilt, zoom))
-        return True
-
-    def get_current_position(self):
-        return self.current_position
-
-    def calculate_ptz_position(self, x, y, zoom=None):
-        return x * 180, y * 90, zoom or 8
-
-    def get_frame(self):
-        return self.ptz_frame.copy()
-
-
-class FakeTracker:
-    def __init__(self, persons):
-        self.persons = persons
-        self.released = False
-
-    def update(self, frame):
-        return self.persons
-
-    def release(self):
-        self.released = True
-
-
-class FakeCalibrator:
-    """模拟已校准的标定器,返回固定的 PTZ 角度。"""
-
-    def __init__(self, pan=180.0, tilt=45.0):
-        self._pan = pan
-        self._tilt = tilt
-
-    def transform(self, x_ratio, y_ratio):
-        return (self._pan, self._tilt)
-
-    def is_calibrated(self):
-        return True
-
-
-class FakeEventPusher:
-    def __init__(self):
-        self.uploads = []
-        self.pushes = []
-
-    def upload_numpy_image(self, image):
-        url = f"url_{id(image)}"
-        self.uploads.append(url)
-        return url
-
-    def push_tracking_capture(self, batch_time, captures):
-        self.pushes.append({"batch_time": batch_time, "captures": captures})
-
-
-def test_update_active_targets():
-    pan = FakePanorama()
-    ptz = FakePTZ()
-    tracker = FakeTracker([
-        TrackedPerson(track_id=1, bbox=(10, 20, 30, 40), center=(20, 30), confidence=0.9),
-        TrackedPerson(track_id=2, bbox=(50, 60, 70, 80), center=(60, 70), confidence=0.8),
-    ])
-
-    coord = PollingTrackingCoordinator(pan, ptz, tracker, config={"max_tracking_targets": 4})
-    frame = pan.get_frame()
-    coord._update_active_targets(tracker.update(frame), frame.shape)
-
-    assert len(coord.active_targets) == 2
-    assert 1 in coord.target_order
-    assert 2 in coord.target_order
-
-
-def test_advance_loop():
-    coord = PollingTrackingCoordinator.__new__(PollingTrackingCoordinator)
-    coord.target_order = [1, 2, 3]
-    coord.current_index = 0
-
-    coord._advance()
-    assert coord.current_index == 1
-
-    coord.current_index = 2
-    coord._advance()
-    assert coord.current_index == 0
-
-
-def test_capture_record_creation():
-    record = CaptureRecord(
-        track_id=1,
-        timestamp=1.0,
-        position=(0.5, 0.5),
-        ptz_position=(90.0, 45.0, 8),
-        ptz_image=np.zeros((100, 100, 3), dtype=np.uint8),
-        panorama_image=None,
-        confidence=0.9,
-    )
-    assert record.track_id == 1
-
-
-def test_target_lost_and_timeout_removal():
-    pan = FakePanorama()
-    ptz = FakePTZ()
-    tracker = FakeTracker([
-        TrackedPerson(track_id=1, bbox=(10, 20, 30, 40), center=(20, 30), confidence=0.9),
-    ])
-
-    coord = PollingTrackingCoordinator(
-        pan, ptz, tracker, config={"tracking_timeout": 0.2, "max_tracking_targets": 4}
-    )
-    frame = pan.get_frame()
-    coord._update_active_targets(tracker.update(frame), frame.shape)
-    assert 1 in coord.target_order
-    assert not coord.active_targets[1].lost
-
-    # 当前帧无目标:标记为丢失,但仍在 target_order 中
-    tracker.persons = []
-    coord._update_active_targets(tracker.update(frame), frame.shape)
-    assert 1 in coord.target_order
-    assert coord.active_targets[1].lost
-
-    # 超时后应被移除
-    time.sleep(0.25)
-    coord._update_active_targets(tracker.update(frame), frame.shape)
-    assert 1 not in coord.target_order
-    assert 1 not in coord.active_targets
-
-
-def test_target_lost_not_removed_before_timeout():
-    pan = FakePanorama()
-    ptz = FakePTZ()
-    tracker = FakeTracker([
-        TrackedPerson(track_id=1, bbox=(10, 20, 30, 40), center=(20, 30), confidence=0.9),
-    ])
-
-    coord = PollingTrackingCoordinator(
-        pan, ptz, tracker, config={"tracking_timeout": 1.0, "max_tracking_targets": 4}
-    )
-    frame = pan.get_frame()
-    coord._update_active_targets(tracker.update(frame), frame.shape)
-
-    tracker.persons = []
-    coord._update_active_targets(tracker.update(frame), frame.shape)
-    assert 1 in coord.target_order
-    assert coord.active_targets[1].lost
-
-
-def test_batch_upload_flush():
-    pan = FakePanorama()
-    ptz = FakePTZ()
-    tracker = FakeTracker([
-        TrackedPerson(track_id=1, bbox=(10, 20, 30, 40), center=(320, 240), confidence=0.9),
-    ])
-
-    coord = PollingTrackingCoordinator(
-        pan, ptz, tracker,
-        config={"ptz_stabilize_time": 0.01, "ptz_command_cooldown": 0.0, "enable_upload": True}
-    )
-    pusher = FakeEventPusher()
-    coord.set_event_pusher(pusher)
-
-    frame = pan.get_frame()
-    coord._update_active_targets(tracker.update(frame), frame.shape)
-
-    record = coord._capture_one(list(coord.active_targets.values())[0])
-    assert record is not None
-    coord.batch_captures.append(record)
-
-    coord._flush_batch_if_needed()
-    assert len(coord.batch_captures) == 0
-    assert len(pusher.pushes) == 1
-    assert len(pusher.uploads) == 2  # PTZ + panorama
-
-
-def test_pause_resume():
-    pan = FakePanorama()
-    ptz = FakePTZ()
-    tracker = FakeTracker([])
-
-    coord = PollingTrackingCoordinator(pan, ptz, tracker, config={})
-    coord.pause()
-    assert coord._paused is True
-    assert coord._paused_event.is_set() is False
-
-    coord.resume()
-    assert coord._paused is False
-    assert coord._paused_event.is_set() is True
-
-
-def test_thread_start_stop_lifecycle():
-    pan = FakePanorama()
-    ptz = FakePTZ()
-    tracker = FakeTracker([])
-
-    coord = PollingTrackingCoordinator(
-        pan, ptz, tracker,
-        config={"ptz_stabilize_time": 0.01, "ptz_command_cooldown": 0.0}
-    )
-    assert coord.start() is True
-    assert coord.running is True
-    assert pan.streaming is True
-    assert ptz.connected is True
-
-    time.sleep(0.05)
-    coord.stop()
-    assert coord.running is False
-    assert pan.stopped is True
-    assert tracker.released is True
-
-
-def test_ptz_worker_capture_flow():
-    pan = FakePanorama()
-    ptz = FakePTZ()
-    tracker = FakeTracker([
-        TrackedPerson(track_id=1, bbox=(10, 20, 30, 40), center=(320, 240), confidence=0.9),
-    ])
-
-    coord = PollingTrackingCoordinator(
-        pan, ptz, tracker,
-        config={
-            "ptz_stabilize_time": 0.01,
-            "ptz_command_cooldown": 0.0,
-            "max_capture_per_target": 1,
-        }
-    )
-
-    frame = pan.get_frame()
-    coord._update_active_targets(tracker.update(frame), frame.shape)
-
-    target = coord.active_targets[1]
-    record = coord._capture_one(target)
-
-    assert record is not None
-    assert record.track_id == 1
-    assert len(ptz.commands) == 1
-    with coord._capture_counts_lock:
-        assert coord._capture_counts[1] == 1
-
-    # 超过最大抓拍数后应返回 None
-    record2 = coord._capture_one(target)
-    assert record2 is None
-
-
-def test_capture_with_pan_flip_does_not_double_flip():
-    """
-    验证:当存在校准器且 ptz_config 启用 pan_flip 时,
-    不应在校准结果上再次应用 pan_flip。
-
-    场景:球机实际朝向与枪机相反(pan_flip=True)。
-    校准器通过真实扫描得到全景中心对应的球机角度为 180°,
-    该角度应直接发送给球机。若再次翻转,球机会被转到背面(0°)。
-    """
-    pan = FakePanorama()
-    ptz = FakePTZ()
-    ptz.ptz_config["pan_flip"] = True
-    tracker = FakeTracker([
-        TrackedPerson(track_id=1, bbox=(10, 20, 30, 40), center=(320, 240), confidence=0.9),
-    ])
-    calibrator = FakeCalibrator(pan=180.0, tilt=45.0)
-
-    coord = PollingTrackingCoordinator(
-        pan, ptz, tracker,
-        config={"ptz_stabilize_time": 0.01, "ptz_command_cooldown": 0.0},
-        calibrator=calibrator,
-    )
-
-    frame = pan.get_frame()
-    coord._update_active_targets(tracker.update(frame), frame.shape)
-
-    target = coord.active_targets[1]
-    record = coord._capture_one(target)
-
-    assert record is not None
-    assert len(ptz.commands) == 1
-    sent_pan, sent_tilt, sent_zoom = ptz.commands[0]
-    # 校准器返回的 180° 应直接发送,不能因 pan_flip 而被翻为 0°
-    assert sent_pan == 180.0
-    assert sent_tilt == 45.0

+ 192 - 0
dual_camera_system/tests/test_routes.py

@@ -0,0 +1,192 @@
+import sys
+import os
+import threading
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from unittest.mock import MagicMock
+
+import pytest
+from fastapi.testclient import TestClient
+from app import create_app
+import web.state as _web_state_module
+
+
+@pytest.fixture
+def client():
+    app = create_app(test_mode=True)
+    return TestClient(app)
+
+
+@pytest.fixture
+def client_with_mocks():
+    app = create_app(test_mode=True)
+    client = TestClient(app)
+
+    scanner = MagicMock()
+    scanner.run.return_value = {
+        "samples": [],
+        "panorama_path": None,
+        "config": {},
+    }
+    scheduler = MagicMock()
+
+    _web_state_module.web_state.scanners["group_1"] = scanner
+    _web_state_module.web_state.schedulers["group_1"] = scheduler
+    return client, scanner, scheduler
+
+
+def test_status_endpoint(client):
+    response = client.get("/api/status")
+    assert response.status_code == 200
+    assert "groups" in response.json()
+
+
+def test_add_and_list_points(client):
+    response = client.post("/api/points/group_1", json={
+        "pan": 30.0,
+        "tilt": 0.0,
+        "zoom": 1,
+        "dwell_time": 3.0,
+    })
+    assert response.status_code == 200
+    data = response.json()
+    assert data["pan"] == 30.0
+
+    response = client.get("/api/points/group_1")
+    assert response.status_code == 200
+    points = response.json()["points"]
+    assert len(points) == 1
+
+
+def test_add_point_validation(client):
+    response = client.post("/api/points/group_1", json={
+        "pan": 400.0,
+        "tilt": 0.0,
+        "zoom": 1,
+        "dwell_time": 3.0,
+    })
+    assert response.status_code == 422
+
+
+def test_delete_point(client):
+    response = client.post("/api/points/group_1", json={
+        "pan": 60.0,
+        "tilt": 10.0,
+        "zoom": 1,
+        "dwell_time": 2.0,
+    })
+    point_id = response.json()["id"]
+    response = client.delete(f"/api/points/group_1/{point_id}")
+    assert response.status_code == 200
+    response = client.get("/api/points/group_1")
+    assert len(response.json()["points"]) == 0
+
+
+def test_static_index(client):
+    response = client.get("/")
+    assert response.status_code == 200
+    assert "text/html" in response.headers["content-type"]
+
+
+def test_panorama_not_found(client):
+    response = client.get("/api/panorama/group_1")
+    assert response.status_code == 404
+
+
+def test_scan_start_returns_202(client_with_mocks):
+    client, scanner, _ = client_with_mocks
+    started = threading.Event()
+
+    def run_with_event(*args, **kwargs):
+        started.set()
+        return scanner.run.return_value
+
+    scanner.run.side_effect = run_with_event
+
+    response = client.post("/api/scan/group_1")
+    assert response.status_code == 202
+    assert response.json()["group_id"] == "group_1"
+
+    deadline = time.time() + 2.0
+    while time.time() < deadline and not started.is_set():
+        time.sleep(0.01)
+    assert started.is_set(), "Scanner run() was not invoked"
+
+
+def test_poll_start_and_stop(client_with_mocks):
+    client, _, scheduler = client_with_mocks
+    response = client.post("/api/poll/group_1/start")
+    assert response.status_code == 200
+    scheduler.start.assert_called_once()
+
+    response = client.post("/api/poll/group_1/stop")
+    assert response.status_code == 200
+    scheduler.stop.assert_called_once()
+
+
+def test_live_stream_not_found(client):
+    response = client.get("/api/live/panorama/group_1")
+    assert response.status_code == 404
+
+
+def test_static_css_served(client):
+    response = client.get("/static/style.css")
+    assert response.status_code == 200
+    assert "text/css" in response.headers["content-type"]
+
+
+def test_static_js_served(client):
+    response = client.get("/static/app.js")
+    assert response.status_code == 200
+    assert "javascript" in response.headers["content-type"]
+
+
+def test_mutating_endpoints_require_api_key(client_with_mocks, monkeypatch):
+    client, scanner, scheduler = client_with_mocks
+    monkeypatch.setattr("web.auth.DEVICE_CONFIG", {"api_key": "secret123"})
+
+    # Scan
+    response = client.post("/api/scan/group_1")
+    assert response.status_code == 401
+
+    # Poll start/stop
+    response = client.post("/api/poll/group_1/start")
+    assert response.status_code == 401
+    response = client.post("/api/poll/group_1/stop")
+    assert response.status_code == 401
+
+    # Points add/delete
+    response = client.post("/api/points/group_1", json={"pan": 30, "tilt": 0})
+    assert response.status_code == 401
+    response = client.delete("/api/points/group_1/1")
+    assert response.status_code == 401
+
+    scanner.run.assert_not_called()
+    scheduler.start.assert_not_called()
+
+
+def test_mutating_endpoints_accept_api_key(client_with_mocks, monkeypatch):
+    client, scanner, scheduler = client_with_mocks
+    monkeypatch.setattr("web.auth.DEVICE_CONFIG", {"api_key": "secret123"})
+
+    response = client.post(
+        "/api/poll/group_1/start",
+        headers={"X-API-Key": "secret123"},
+    )
+    assert response.status_code == 200
+    scheduler.start.assert_called_once()
+
+
+def test_readonly_endpoints_remain_open_with_api_key(client, monkeypatch):
+    monkeypatch.setattr("web.auth.DEVICE_CONFIG", {"api_key": "secret123"})
+
+    response = client.get("/api/status")
+    assert response.status_code == 200
+
+    response = client.get("/api/points/group_1")
+    assert response.status_code == 200
+
+    response = client.get("/api/scan/group_1/progress")
+    assert response.status_code == 200

+ 290 - 0
dual_camera_system/tests/test_scan_point_store.py

@@ -0,0 +1,290 @@
+import json
+import sys
+import os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import pytest
+
+from core.scan_point_store import ScanPointStore
+
+
+def test_add_and_list_points(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("group_1", {"ptz_name": "PTZ1", "panorama_name": "PAN1"})
+    store.add_enabled_point("group_1", pan=30.0, tilt=0.0, zoom=1, dwell_time=3.0)
+    points = store.list_enabled_points("group_1")
+    assert len(points) == 1
+    assert points[0]["pan"] == 30.0
+
+
+def test_persistence(tmp_path):
+    path = str(tmp_path / "scan_models.json")
+    store = ScanPointStore(path)
+    store.ensure_group("group_1", {"ptz_name": "PTZ1", "panorama_name": "PAN1"})
+    store.add_enabled_point("group_1", pan=60.0, tilt=-5.0, zoom=2, dwell_time=2.0)
+    del store
+
+    store2 = ScanPointStore(path)
+    points = store2.list_enabled_points("group_1")
+    assert len(points) == 1
+    assert points[0]["zoom"] == 2
+
+
+def test_delete_point(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("g1", {})
+    p1 = store.add_enabled_point("g1", pan=10.0, tilt=0.0)
+    store.add_enabled_point("g1", pan=20.0, tilt=0.0)
+    assert len(store.list_enabled_points("g1")) == 2
+    ok = store.delete_enabled_point("g1", p1["id"])
+    assert ok
+    points = store.list_enabled_points("g1")
+    assert len(points) == 1
+    assert points[0]["order"] == 0
+
+
+def test_returns_deep_copies(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("g1", {"ptz_name": "PTZ1"})
+    point = store.add_enabled_point("g1", pan=10.0, tilt=0.0)
+    point["pan"] = 999.0
+    points = store.list_enabled_points("g1")
+    assert points[0]["pan"] == 10.0
+
+    group = store.get_group("g1")
+    group["ptz_name"] = "CHANGED"
+    assert store.get_group("g1")["ptz_name"] == "PTZ1"
+
+    points[0]["tilt"] = 999.0
+    assert store.list_enabled_points("g1")[0]["tilt"] == 0.0
+
+
+def test_reorder_points_exact_ids(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("g1", {})
+    p1 = store.add_enabled_point("g1", pan=10.0, tilt=0.0)
+    p2 = store.add_enabled_point("g1", pan=20.0, tilt=0.0)
+    p3 = store.add_enabled_point("g1", pan=30.0, tilt=0.0)
+
+    original_order = [p["id"] for p in store.list_enabled_points("g1")]
+    assert original_order == [p1["id"], p2["id"], p3["id"]]
+
+    reversed_order = [p3["id"], p2["id"], p1["id"]]
+    store.reorder_points("g1", reversed_order)
+    reordered = store.list_enabled_points("g1")
+    assert [p["id"] for p in reordered] == reversed_order
+    for idx, p in enumerate(reordered):
+        assert p["order"] == idx
+
+
+def test_reorder_points_rejects_mismatched_ids(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("g1", {})
+    p1 = store.add_enabled_point("g1", pan=10.0, tilt=0.0)
+    p2 = store.add_enabled_point("g1", pan=20.0, tilt=0.0)
+
+    with pytest.raises(ValueError, match="point_ids must contain exactly"):
+        store.reorder_points("g1", [p1["id"]])
+
+    with pytest.raises(ValueError, match="point_ids must contain exactly"):
+        store.reorder_points("g1", [p1["id"], p2["id"], 999])
+
+    with pytest.raises(ValueError, match="point_ids must contain exactly"):
+        store.reorder_points("g1", [p1["id"], p1["id"]])
+
+
+def test_update_enabled_point(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("g1", {})
+    p = store.add_enabled_point("g1", pan=10.0, tilt=0.0, zoom=1, dwell_time=3.0)
+    ok = store.update_enabled_point("g1", p["id"], {"pan": 45.0, "zoom": 5})
+    assert ok
+    updated = store.list_enabled_points("g1")[0]
+    assert updated["pan"] == 45.0
+    assert updated["zoom"] == 5
+    assert updated["tilt"] == 0.0
+
+
+def test_invalid_inputs_raise(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("g1", {})
+
+    with pytest.raises(ValueError):
+        store.add_enabled_point("g1", pan="bad", tilt=0.0)
+
+    with pytest.raises(ValueError):
+        store.add_enabled_point("g1", pan=10.0, tilt=0.0, zoom=0)
+
+    with pytest.raises(ValueError):
+        store.add_enabled_point("g1", pan=10.0, tilt=0.0, dwell_time=0)
+
+    p = store.add_enabled_point("g1", pan=10.0, tilt=0.0)
+
+    with pytest.raises(ValueError):
+        store.update_enabled_point("g1", p["id"], {"pan": "bad"})
+
+    with pytest.raises(ValueError):
+        store.update_enabled_point("g1", p["id"], {"zoom": -1})
+
+    with pytest.raises(ValueError):
+        store.update_enabled_point("g1", p["id"], {"dwell_time": -1.0})
+
+
+def test_corrupted_json_starts_fresh(tmp_path, capsys):
+    path = str(tmp_path / "scan_models.json")
+    with open(path, "w", encoding="utf-8") as f:
+        f.write("{not valid json")
+
+    store = ScanPointStore(path)
+    captured = capsys.readouterr()
+    assert "corrupted JSON" in captured.err
+    store.ensure_group("g1", {"ptz_name": "PTZ1"})
+    assert store.get_group("g1")["ptz_name"] == "PTZ1"
+
+
+def test_point_id_generation(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("g1", {})
+    p1 = store.add_enabled_point("g1", pan=10.0, tilt=0.0)
+    p2 = store.add_enabled_point("g1", pan=20.0, tilt=0.0)
+    store.delete_enabled_point("g1", p1["id"])
+    p3 = store.add_enabled_point("g1", pan=30.0, tilt=0.0)
+    assert p1["id"] < p2["id"] < p3["id"]
+    assert p3["id"] == p2["id"] + 1
+
+
+def test_set_samples_deep_copies(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("g1", {})
+    samples = [{"x": 1, "nested": {"y": 2}}]
+    store.set_samples("g1", samples)
+    samples[0]["nested"]["y"] = 999
+    assert store.get_group("g1")["samples"][0]["nested"]["y"] == 2
+
+
+def test_set_scan_config_deep_copies(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("g1", {})
+    config = {"threshold": {"value": 0.5}}
+    store.set_scan_config("g1", config)
+    config["threshold"]["value"] = 0.9
+    assert store.get_group("g1")["scan_config"]["threshold"]["value"] == 0.5
+
+
+def test_set_panorama_deep_copies(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("g1", {})
+    panorama = {"image": {"width": 1920}}
+    store.set_panorama("g1", panorama)
+    panorama["image"]["width"] = 999
+    assert store.get_group("g1")["panorama"]["image"]["width"] == 1920
+
+
+def test_next_point_id_seeded_from_legacy_points(tmp_path):
+    path = str(tmp_path / "scan_models.json")
+    legacy_data = {
+        "camera_groups": {
+            "g1": {
+                "ptz_name": "g1",
+                "panorama_name": "g1",
+                "scan_config": {},
+                "samples": [],
+                "enabled_points": [
+                    {"id": 5, "pan": 10.0, "tilt": 0.0, "zoom": 1, "dwell_time": 3.0, "order": 0},
+                    {"id": 12, "pan": 20.0, "tilt": 0.0, "zoom": 1, "dwell_time": 3.0, "order": 1},
+                ],
+                "panorama": {},
+            }
+        }
+    }
+    with open(path, "w", encoding="utf-8") as f:
+        json.dump(legacy_data, f)
+
+    store = ScanPointStore(path)
+    store.ensure_group("g1", {})
+    p = store.add_enabled_point("g1", pan=30.0, tilt=0.0)
+    assert p["id"] == 13
+
+
+def test_reject_boolean_values(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("g1", {})
+
+    with pytest.raises(ValueError):
+        store.add_enabled_point("g1", pan=True, tilt=0.0)
+
+    with pytest.raises(ValueError):
+        store.add_enabled_point("g1", pan=10.0, tilt=False)
+
+    with pytest.raises(ValueError):
+        store.add_enabled_point("g1", pan=10.0, tilt=0.0, zoom=True)
+
+    with pytest.raises(ValueError):
+        store.add_enabled_point("g1", pan=10.0, tilt=0.0, dwell_time=True)
+
+    p = store.add_enabled_point("g1", pan=10.0, tilt=0.0)
+
+    with pytest.raises(ValueError):
+        store.update_enabled_point("g1", p["id"], {"pan": True})
+
+    with pytest.raises(ValueError):
+        store.update_enabled_point("g1", p["id"], {"tilt": False})
+
+    with pytest.raises(ValueError):
+        store.update_enabled_point("g1", p["id"], {"zoom": True})
+
+    with pytest.raises(ValueError):
+        store.update_enabled_point("g1", p["id"], {"dwell_time": True})
+
+
+def test_update_unknown_key_raises(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("g1", {})
+    p = store.add_enabled_point("g1", pan=10.0, tilt=0.0)
+
+    with pytest.raises(ValueError, match="Unknown field: bad_key"):
+        store.update_enabled_point("g1", p["id"], {"bad_key": 123})
+
+
+def test_update_rejects_boolean_order(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("g1", {})
+    p = store.add_enabled_point("g1", pan=10.0, tilt=0.0)
+
+    with pytest.raises(ValueError, match="order must be an integer"):
+        store.update_enabled_point("g1", p["id"], {"order": True})
+
+
+def test_update_rejects_non_finite_values(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("g1", {})
+    p = store.add_enabled_point("g1", pan=10.0, tilt=0.0)
+
+    with pytest.raises(ValueError, match="pan must be finite"):
+        store.update_enabled_point("g1", p["id"], {"pan": float("nan")})
+
+    with pytest.raises(ValueError, match="tilt must be finite"):
+        store.update_enabled_point("g1", p["id"], {"tilt": float("inf")})
+
+    with pytest.raises(ValueError, match="dwell_time must be finite"):
+        store.update_enabled_point("g1", p["id"], {"dwell_time": float("-inf")})
+
+
+def test_update_rejects_negative_order(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("g1", {})
+    p = store.add_enabled_point("g1", pan=10.0, tilt=0.0)
+
+    with pytest.raises(ValueError, match="order must be non-negative"):
+        store.update_enabled_point("g1", p["id"], {"order": -1})
+
+
+def test_add_rejects_non_finite_values(tmp_path):
+    store = ScanPointStore(str(tmp_path / "scan_models.json"))
+    store.ensure_group("g1", {})
+
+    with pytest.raises(ValueError, match="pan must be finite"):
+        store.add_enabled_point("g1", pan=float("nan"), tilt=0.0)
+
+    with pytest.raises(ValueError, match="dwell_time must be finite"):
+        store.add_enabled_point("g1", pan=10.0, tilt=0.0, dwell_time=float("inf"))

+ 108 - 0
dual_camera_system/tests/test_spatial_scanner.py

@@ -0,0 +1,108 @@
+import sys
+import os
+import tempfile
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import numpy as np
+import pytest
+from core.spatial_scanner import SpatialScanner
+from core.coord_utils import compute_sample_grid
+
+
+class FakePTZ:
+    def __init__(self):
+        self.positions = []
+    def goto_exact_position(self, pan, tilt, zoom):
+        self.positions.append((pan, tilt, zoom))
+
+
+def test_spatial_scanner_grid(tmp_path):
+    ptz = FakePTZ()
+    counter = {"n": 0}
+
+    def frame_source():
+        counter["n"] += 1
+        return np.zeros((120, 160, 3), dtype=np.uint8)
+
+    scanner = SpatialScanner("g1", ptz, frame_source, str(tmp_path), stabilize_time=0.0)
+    result = scanner.run(pan_range=(0, 90), tilt_layers=(-10, 0), pan_step=30, zoom=1)
+    assert len(result["samples"]) == 6
+    assert result["panorama_path"] is not None
+    assert len(ptz.positions) == 6
+
+
+def test_invalid_pan_step_raises_value_error(tmp_path):
+    ptz = FakePTZ()
+    scanner = SpatialScanner("g1", ptz, lambda: None, str(tmp_path), stabilize_time=0.0)
+    with pytest.raises(ValueError, match="pan_step must be positive"):
+        scanner.run(pan_range=(0, 90), tilt_layers=(-10, 0), pan_step=0)
+
+
+def test_compute_sample_grid_validation():
+    with pytest.raises(ValueError, match="pan_step must be positive"):
+        compute_sample_grid(pan_step=0)
+    with pytest.raises(ValueError, match="tilt_layers must not be empty"):
+        compute_sample_grid(tilt_layers=())
+    with pytest.raises(ValueError, match="pan_range start must be less than end"):
+        compute_sample_grid(pan_range=(180.0, 180.0))
+
+
+def test_cancellation_stops_early(tmp_path):
+    ptz = FakePTZ()
+    counter = {"n": 0}
+
+    def frame_source():
+        counter["n"] += 1
+        if counter["n"] == 2:
+            scanner.cancel()
+        return np.zeros((120, 160, 3), dtype=np.uint8)
+
+    scanner = SpatialScanner("g1", ptz, frame_source, str(tmp_path), stabilize_time=0.0)
+    result = scanner.run(pan_range=(0, 60), tilt_layers=(-10, 0), pan_step=30, zoom=1)
+    assert len(result["samples"]) < 4
+    assert scanner.progress["state"] == "cancelled"
+
+
+def test_empty_sample_list_returns_no_panorama(tmp_path):
+    ptz = FakePTZ()
+    scanner = SpatialScanner("g1", ptz, lambda: None, str(tmp_path), stabilize_time=0.0)
+    scanner._wait_frame = lambda timeout: None
+    result = scanner.run(pan_range=(0, 60), tilt_layers=(-10, 0), pan_step=30, zoom=1)
+    assert result["samples"] == []
+    assert result["panorama_path"] is None
+    assert scanner.progress["current"] == 0
+
+
+def test_progress_callback_invoked(tmp_path):
+    ptz = FakePTZ()
+    progress_snapshots = []
+
+    def frame_source():
+        return np.zeros((120, 160, 3), dtype=np.uint8)
+
+    def progress_callback(progress):
+        progress_snapshots.append(dict(progress))
+
+    scanner = SpatialScanner("g1", ptz, frame_source, str(tmp_path), stabilize_time=0.0)
+    result = scanner.run(
+        pan_range=(0, 60),
+        tilt_layers=(-10, 0),
+        pan_step=30,
+        zoom=1,
+        progress_callback=progress_callback,
+    )
+    expected_samples = len(result["samples"])
+    assert expected_samples > 0
+    assert len(progress_snapshots) == expected_samples
+    assert progress_snapshots[-1]["current"] == expected_samples
+    assert progress_snapshots[-1]["state"] == "scanning"
+
+
+def test_prerun_cancellation_returns_empty_result(tmp_path):
+    ptz = FakePTZ()
+    scanner = SpatialScanner("g1", ptz, lambda: None, str(tmp_path), stabilize_time=0.0)
+    scanner.cancel()
+    result = scanner.run(pan_range=(0, 60), tilt_layers=(-10, 0), pan_step=30, zoom=1)
+    assert result["samples"] == []
+    assert result["panorama_path"] is None
+    assert scanner.progress["state"] == "cancelled"

+ 124 - 0
dual_camera_system/tests/test_stream_manager.py

@@ -0,0 +1,124 @@
+import sys
+import os
+import time
+import logging
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import cv2
+import numpy as np
+from core.stream_manager import encode_mjpeg_frame, StreamManager, CameraStream, generate_mjpeg_stream
+
+
+def test_encode_mjpeg_frame():
+    frame = np.zeros((100, 100, 3), dtype=np.uint8)
+    data = encode_mjpeg_frame(frame)
+    assert data[:2] == b"\xff\xd8"
+
+
+def test_stream_manager_register_and_get():
+    manager = StreamManager()
+    fake_frame = np.zeros((100, 100, 3), dtype=np.uint8)
+    try:
+        with patch("core.stream_manager.cv2.VideoCapture") as mock_vc:
+            mock_cap = MagicMock()
+            mock_cap.isOpened.return_value = True
+            mock_cap.read.return_value = (True, fake_frame)
+            mock_cap.set.return_value = True
+            mock_vc.return_value = mock_cap
+            stream = manager.register("test", "rtsp://dummy", reconnect_delay=0.01)
+            assert stream is not None
+            assert manager.get("test") is stream
+    finally:
+        manager.stop_all()
+
+
+def test_generate_mjpeg_stream_includes_content_length():
+    frame = np.zeros((100, 100, 3), dtype=np.uint8)
+
+    def get_frame():
+        return frame
+
+    chunks = []
+    for chunk in generate_mjpeg_stream(get_frame, fps=30):
+        chunks.append(chunk)
+        if len(chunks) >= 3:
+            break
+
+    for chunk in chunks:
+        assert b"Content-Length:" in chunk
+        assert b"Content-Type: image/jpeg" in chunk
+
+
+def test_stream_manager_stop_all_does_not_crash():
+    manager = StreamManager()
+    fake_frame = np.zeros((100, 100, 3), dtype=np.uint8)
+    try:
+        with patch("core.stream_manager.cv2.VideoCapture") as mock_vc:
+            mock_cap = MagicMock()
+            mock_cap.isOpened.return_value = True
+            mock_cap.read.return_value = (True, fake_frame)
+            mock_cap.set.return_value = True
+            mock_vc.return_value = mock_cap
+            manager.register("a", "rtsp://dummy_a", reconnect_delay=0.01)
+            manager.register("b", "rtsp://dummy_b", reconnect_delay=0.01)
+            # Should stop all streams cleanly even when none have connected.
+            manager.stop_all()
+    finally:
+        manager.stop_all()
+    assert manager.get("a") is None
+    assert manager.get("b") is None
+
+
+def test_camera_stream_last_error_initially_none():
+    stream = CameraStream("test", "rtsp://dummy")
+    assert stream.last_error is None
+
+
+def test_camera_stream_sets_timeout_properties_when_available():
+    if not hasattr(cv2, "CAP_PROP_OPEN_TIMEOUT_MSEC") or not hasattr(cv2, "CAP_PROP_READ_TIMEOUT_MSEC"):
+        pytest.skip("OpenCV timeout properties not available on this build")
+
+    fake_frame = np.zeros((100, 100, 3), dtype=np.uint8)
+    mock_cap = MagicMock()
+    mock_cap.isOpened.return_value = True
+    mock_cap.read.side_effect = [(True, fake_frame), (True, fake_frame), (False, None)]
+    mock_cap.set.return_value = True
+
+    stream = CameraStream("test", "rtsp://dummy", reconnect_delay=0.01)
+    try:
+        with patch("core.stream_manager.cv2.VideoCapture", return_value=mock_cap):
+            stream.start()
+            # Give the worker time to create a capture and set properties.
+            time.sleep(0.1)
+    finally:
+        stream.stop()
+
+    mock_cap.set.assert_any_call(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 10000)
+    mock_cap.set.assert_any_call(cv2.CAP_PROP_READ_TIMEOUT_MSEC, 10000)
+
+
+def test_camera_stream_logs_timeout_set_failure(caplog):
+    if not hasattr(cv2, "CAP_PROP_OPEN_TIMEOUT_MSEC") or not hasattr(cv2, "CAP_PROP_READ_TIMEOUT_MSEC"):
+        pytest.skip("OpenCV timeout properties not available on this build")
+
+    fake_frame = np.zeros((100, 100, 3), dtype=np.uint8)
+    mock_cap = MagicMock()
+    mock_cap.isOpened.return_value = True
+    mock_cap.read.side_effect = [(True, fake_frame), (False, None)]
+    mock_cap.set.return_value = False
+
+    stream = CameraStream("test", "rtsp://dummy", reconnect_delay=0.01)
+    with caplog.at_level(logging.WARNING, logger="core.stream_manager"):
+        try:
+            with patch("core.stream_manager.cv2.VideoCapture", return_value=mock_cap):
+                stream.start()
+                time.sleep(0.1)
+        finally:
+            stream.stop()
+
+    assert any("CAP_PROP_OPEN_TIMEOUT_MSEC" in rec.message for rec in caplog.records)
+    assert any("CAP_PROP_READ_TIMEOUT_MSEC" in rec.message for rec in caplog.records)

+ 0 - 179
dual_camera_system/tests/test_tracker.py

@@ -1,179 +0,0 @@
-import sys
-import os
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-import logging
-import types
-
-import numpy as np
-import pytest
-
-import ultralytics
-import tracker
-from tracker import UltralyticsTracker, TrackedPerson, resolve_model
-
-
-class FakeBox:
-    def __init__(self, cls, conf, xyxy, ids=None):
-        self.cls = np.array(cls)
-        self.conf = np.array(conf)
-        self.xyxy = np.array(xyxy)
-        self.id = np.array(ids) if ids is not None else None
-
-    def __len__(self):
-        return len(self.cls)
-
-
-class FakeResult:
-    def __init__(self, names, boxes):
-        self.names = names
-        self.boxes = boxes
-
-
-@pytest.fixture
-def fake_yolo(monkeypatch):
-    """返回一个函数,用于将 ultralytics.YOLO 替换为返回指定结果的假实现。"""
-    def _make(results):
-        class FakeYOLO:
-            def __init__(self, path):
-                self.path = path
-                self.predictor = types.SimpleNamespace(trackers=[])
-                self._results = results
-
-            def __call__(self, frame, **kwargs):
-                return self._results
-
-        monkeypatch.setattr(ultralytics, 'YOLO', FakeYOLO)
-
-    return _make
-
-
-def test_tracked_person_dataclass():
-    p = TrackedPerson(track_id=1, bbox=(10, 20, 30, 40), center=(20, 30), confidence=0.9)
-    assert p.track_id == 1
-    assert p.class_name == "person"
-
-
-def test_resolve_model_fallback():
-    # 不存在的路径应回退到默认 YOLO 模型
-    path, mtype = resolve_model("/not/exist/model.rknn", "auto")
-    assert path == "yolo11n.pt"
-    assert mtype == "yolo"
-
-
-def test_resolve_model_auto_by_extension(tmp_path):
-    # 创建空文件,仅用于路径/扩展名推断
-    pt_path = tmp_path / "model.pt"
-    rknn_path = tmp_path / "model.rknn"
-    onnx_path = tmp_path / "model.onnx"
-    pt_path.write_text("")
-    rknn_path.write_text("")
-    onnx_path.write_text("")
-
-    assert resolve_model(str(pt_path), "auto") == (str(pt_path), "yolo")
-    assert resolve_model(str(rknn_path), "auto") == (str(rknn_path), "rknn")
-    assert resolve_model(str(onnx_path), "auto") == (str(onnx_path), "onnx")
-
-
-def test_resolve_model_respects_explicit_type(tmp_path):
-    # 显式 model_type 优先于扩展名推断
-    path = tmp_path / "weird.bin"
-    path.write_text("")
-    assert resolve_model(str(path), "rknn") == (str(path), "rknn")
-    assert resolve_model(str(path), "onnx") == (str(path), "onnx")
-    assert resolve_model(str(path), "yolo") == (str(path), "yolo")
-
-
-def test_update_filters_non_person(fake_yolo):
-    fake_yolo([
-        FakeResult(names={0: "car"}, boxes=FakeBox(cls=[0], conf=[0.9], xyxy=[[10, 20, 30, 40]]))
-    ])
-    tracker = UltralyticsTracker(model_path="/fake/yolo11n.pt", model_type="yolo")
-    frame = np.zeros((480, 640, 3), dtype=np.uint8)
-    results = tracker.update(frame)
-    assert results == []
-
-
-def test_update_invalid_frame_returns_empty(fake_yolo):
-    fake_yolo([
-        FakeResult(names={0: "person"}, boxes=FakeBox(cls=[0], conf=[0.9], xyxy=[[10, 20, 30, 40]]))
-    ])
-    tracker = UltralyticsTracker(model_path="/fake/yolo11n.pt", model_type="yolo")
-    assert tracker.update(None) == []
-
-
-def test_tracker_lifecycle(fake_yolo):
-    fake_yolo([
-        FakeResult(
-            names={0: "person"},
-            boxes=FakeBox(cls=[0], conf=[0.8], xyxy=[[10, 20, 30, 40]], ids=[42]),
-        )
-    ])
-    tracker = UltralyticsTracker(model_path="/fake/yolo11n.pt", model_type="yolo", use_gpu=False)
-    frame = np.zeros((480, 640, 3), dtype=np.uint8)
-
-    results = tracker.update(frame)
-    assert len(results) == 1
-    assert results[0].track_id == 42
-    assert results[0].bbox == (10, 20, 30, 40)
-
-    tracker.reset()
-    assert tracker.model.predictor.trackers == []
-
-    tracker.release()
-    assert tracker.model is None
-    assert tracker.byte_tracker is None
-
-
-def test_rknn_import_fallback(fake_yolo, tmp_path, monkeypatch, caplog):
-    rknn_path = tmp_path / "model.rknn"
-    rknn_path.write_text("")
-
-    class FailingRKNN:
-        def __init__(self, path):
-            raise ImportError("rknnlite not installed")
-
-    monkeypatch.setattr("inference_backend.RKNNDetector", FailingRKNN)
-    fake_yolo([
-        FakeResult(
-            names={0: "person"},
-            boxes=FakeBox(cls=[0], conf=[0.8], xyxy=[[10, 20, 30, 40]]),
-        )
-    ])
-
-    caplog.set_level(logging.WARNING, logger="tracker")
-    tracker = UltralyticsTracker(model_path=str(rknn_path), model_type="rknn", use_gpu=False)
-
-    assert tracker.model_type == "yolo"
-    assert "RKNN 加载失败" in caplog.text
-
-    frame = np.zeros((480, 640, 3), dtype=np.uint8)
-    results = tracker.update(frame)
-    assert len(results) == 1
-
-
-def test_onnx_import_fallback(fake_yolo, tmp_path, monkeypatch, caplog):
-    onnx_path = tmp_path / "model.onnx"
-    onnx_path.write_text("")
-
-    class FailingONNX:
-        def __init__(self, path):
-            raise ImportError("onnxruntime not installed")
-
-    monkeypatch.setattr("inference_backend.ONNXDetector", FailingONNX)
-    fake_yolo([
-        FakeResult(
-            names={0: "person"},
-            boxes=FakeBox(cls=[0], conf=[0.8], xyxy=[[10, 20, 30, 40]]),
-        )
-    ])
-
-    caplog.set_level(logging.WARNING, logger="tracker")
-    tracker = UltralyticsTracker(model_path=str(onnx_path), model_type="onnx", use_gpu=False)
-
-    assert tracker.model_type == "yolo"
-    assert "ONNX 加载失败" in caplog.text
-
-    frame = np.zeros((480, 640, 3), dtype=np.uint8)
-    results = tracker.update(frame)
-    assert len(results) == 1

+ 219 - 0
dual_camera_system/tests/test_web_routes.py

@@ -0,0 +1,219 @@
+"""Tests for web.routes helpers and endpoint guards."""
+import sys
+import os
+import threading
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+from fastapi import HTTPException
+from fastapi.responses import JSONResponse
+from pydantic import ValidationError
+
+import web.routes as routes
+import web.state as _web_state_module
+from web.routes import (
+    _get_state,
+    _resolve_panorama_path,
+    AddPointPayload,
+    api_panorama,
+    api_start_scan,
+    api_live,
+)
+
+
+@pytest.fixture(autouse=True)
+def reset_web_state(monkeypatch):
+    """Isolate the module-level web_state between tests."""
+    monkeypatch.setattr(_web_state_module, "web_state", None)
+
+
+def test_get_state_raises_503_when_not_initialized():
+    _web_state_module.web_state = None
+    with pytest.raises(HTTPException) as exc_info:
+        _get_state()
+    assert exc_info.value.status_code == 503
+    assert "not initialized" in exc_info.value.detail.lower()
+
+
+def test_resolve_panorama_path_returns_valid_path(tmp_path, monkeypatch):
+    base = tmp_path / "panorama_data"
+    (base / "panorama").mkdir(parents=True)
+    img = base / "panorama" / "scan.jpg"
+    img.write_bytes(b"")
+    monkeypatch.setattr(routes, "PANORAMA_BASE", base.resolve())
+
+    resolved = _resolve_panorama_path("panorama/scan.jpg")
+    assert resolved == img.resolve()
+
+
+def test_resolve_panorama_path_rejects_escape(tmp_path, monkeypatch):
+    base = tmp_path / "panorama_data"
+    base.mkdir()
+    monkeypatch.setattr(routes, "PANORAMA_BASE", base.resolve())
+
+    with pytest.raises(HTTPException) as exc_info:
+        _resolve_panorama_path("../evil.jpg")
+    assert exc_info.value.status_code == 400
+    assert "Invalid panorama path" in exc_info.value.detail
+
+
+def test_api_panorama_rejects_traversal_from_store(tmp_path, monkeypatch):
+    base = tmp_path / "data"
+    base.mkdir()
+    monkeypatch.setattr(routes, "PANORAMA_BASE", base.resolve())
+
+    scan_store = SimpleNamespace(
+        get_group=lambda _gid: {"panorama": {"equirectangular": "../secret.txt"}}
+    )
+    _web_state_module.web_state = SimpleNamespace(
+        scan_store=scan_store,
+        group_state=None,
+        scanners={},
+        schedulers={},
+        stream_manager=None,
+    )
+
+    with pytest.raises(HTTPException) as exc_info:
+        api_panorama("g1")
+    assert exc_info.value.status_code == 400
+
+
+def test_start_scan_returns_202_and_launches_thread():
+    started = threading.Event()
+
+    scanner = MagicMock()
+
+    def run_with_event(*args, **kwargs):
+        started.set()
+        return {
+            "samples": [],
+            "panorama_path": None,
+            "config": {},
+        }
+
+    scanner.run.side_effect = run_with_event
+
+    group_data = {"polling_state": "idle"}
+
+    class MockGroupState:
+        def get(self, _gid):
+            return dict(group_data)
+
+        def update(self, _gid, key, value):
+            group_data[key] = value
+
+        def compare_and_update(self, _gid, key, expected, new_value):
+            if group_data.get(key) == expected:
+                group_data[key] = new_value
+                return True
+            return False
+
+    state = SimpleNamespace(
+        group_state=MockGroupState(),
+        scan_store=SimpleNamespace(
+            set_samples=lambda *_args, **_kwargs: None,
+            set_panorama=lambda *_args, **_kwargs: None,
+            set_scan_config=lambda *_args, **_kwargs: None,
+        ),
+        scanners={"g1": scanner},
+        schedulers={},
+        stream_manager=None,
+    )
+    _web_state_module.web_state = state
+
+    resp = api_start_scan("g1")
+    assert isinstance(resp, JSONResponse)
+    assert resp.status_code == 202
+    assert resp.body is not None
+    assert b"Scan started" in resp.body
+
+    deadline = time.time() + 1.0
+    while time.time() < deadline and not started.is_set():
+        time.sleep(0.01)
+    started.wait(timeout=0)
+    scanner.run.assert_called_once()
+
+
+def test_start_scan_rejects_concurrent_scan():
+    group_data = {"polling_state": "scanning"}
+
+    class MockGroupState:
+        def get(self, _gid):
+            return dict(group_data)
+
+        def compare_and_update(self, _gid, key, expected, new_value):
+            if group_data.get(key) == expected:
+                group_data[key] = new_value
+                return True
+            return False
+
+    state = SimpleNamespace(
+        group_state=MockGroupState(),
+        scanners={"g1": None},
+        schedulers={},
+        stream_manager=None,
+        scan_store=None,
+    )
+    _web_state_module.web_state = state
+
+    with pytest.raises(HTTPException) as exc_info:
+        api_start_scan("g1")
+    assert exc_info.value.status_code == 409
+    assert "already in progress" in exc_info.value.detail.lower()
+
+
+def test_start_scan_returns_404_when_group_not_found():
+    class MockGroupState:
+        def get(self, _gid):
+            return {}
+
+        def compare_and_update(self, _gid, _key, _expected, _new_value):
+            return False
+
+    state = SimpleNamespace(
+        group_state=MockGroupState(),
+        scanners={},
+        schedulers={},
+        stream_manager=None,
+        scan_store=None,
+    )
+    _web_state_module.web_state = state
+
+    with pytest.raises(HTTPException) as exc_info:
+        api_start_scan("missing")
+    assert exc_info.value.status_code == 404
+    assert "Group not found" in exc_info.value.detail
+
+
+def test_api_live_rejects_invalid_camera_type():
+    _web_state_module.web_state = SimpleNamespace(stream_manager=MagicMock())
+
+    with pytest.raises(HTTPException) as exc_info:
+        api_live("front_door", "g1")
+    assert exc_info.value.status_code == 400
+    assert "Invalid camera type" in exc_info.value.detail
+
+
+@pytest.mark.parametrize(
+    "payload, should_raise",
+    [
+        ({"pan": 10, "tilt": 5}, False),
+        ({"pan": 400, "tilt": 0}, True),
+        ({"pan": 0, "tilt": 100}, True),
+        ({"pan": 0, "tilt": 0, "zoom": 0}, True),
+        ({"pan": 0, "tilt": 0, "dwell_time": -1}, True),
+    ],
+)
+def test_add_point_payload_validation(payload, should_raise):
+    if should_raise:
+        with pytest.raises(ValidationError):
+            AddPointPayload(**payload)
+    else:
+        model = AddPointPayload(**payload)
+        assert model.pan == payload["pan"]
+        assert model.tilt == payload["tilt"]

+ 0 - 294
dual_camera_system/tracker.py

@@ -1,294 +0,0 @@
-"""
-Ultralytics Tracker 封装
-支持 YOLO (.pt) 端到端跟踪 和 RKNN/ONNX 检测 + BYTETracker 关联
-"""
-
-import logging
-import os
-import types
-from typing import Any, List, Tuple, Optional
-from dataclasses import dataclass
-
-import numpy as np
-
-from config import TRACKING_CONFIG
-from inference_backend import Detection
-
-
-logger = logging.getLogger(__name__)
-
-
-# Model type constants
-MODEL_TYPE_AUTO = "auto"
-MODEL_TYPE_RKNN = "rknn"
-MODEL_TYPE_ONNX = "onnx"
-MODEL_TYPE_YOLO = "yolo"
-
-# Default YOLO model used when no local model is found.
-# Ultralytics will automatically download the weights on first use.
-DEFAULT_YOLO_MODEL = "yolo11n.pt"
-
-
-@dataclass
-class TrackedPerson:
-    """跟踪目标"""
-    track_id: int
-    bbox: Tuple[int, int, int, int]   # x1, y1, x2, y2
-    center: Tuple[int, int]
-    confidence: float
-    class_name: str = "person"
-    lost: bool = False
-
-
-def resolve_model(model_path: Optional[str], model_type: str) -> Tuple[str, str]:
-    """
-    解析模型路径和类型
-
-    优先级:
-    1. 显式 model_type(非 auto)优先于扩展名推断
-    2. model_path 存在时使用 model_path
-    3. 否则使用 TRACKING_CONFIG['fallback_model_path']
-    4. 最终回退到 Ultralytics 默认模型(自动下载)
-
-    Args:
-        model_path: 模型文件路径,可为 None
-        model_type: 模型类型,'auto' 时根据扩展名推断,否则使用给定值
-
-    Returns:
-        (resolved_path, resolved_type)
-    """
-
-    def _infer_type(path: str) -> str:
-        ext = os.path.splitext(path)[1].lower()
-        if ext == ".rknn":
-            return MODEL_TYPE_RKNN
-        elif ext == ".onnx":
-            return MODEL_TYPE_ONNX
-        return MODEL_TYPE_YOLO
-
-    # 1. 优先使用传入的 model_path
-    if model_path and os.path.exists(model_path):
-        resolved_type = _infer_type(model_path) if model_type == MODEL_TYPE_AUTO else model_type
-        return model_path, resolved_type
-
-    # 2. 回退到配置中的 fallback 路径
-    fallback = TRACKING_CONFIG.get("fallback_model_path")
-    if fallback and os.path.exists(fallback):
-        resolved_type = _infer_type(fallback) if model_type == MODEL_TYPE_AUTO else model_type
-        return fallback, resolved_type
-
-    # 3. 最终回退:Ultralytics 自动下载
-    return DEFAULT_YOLO_MODEL, MODEL_TYPE_YOLO
-
-
-class UltralyticsTracker:
-    """Ultralytics 跟踪器封装
-
-    阈值说明:
-    - conf_threshold: 调用模型/跟踪器时传入的检测置信度阈值,用于控制进入
-      跟踪流程的候选框数量。
-    - person_threshold: 对检测到的 "person" 类别在解析结果时应用的过滤阈值,
-      仅保留置信度不低于该值的人员目标。
-    """
-
-    def __init__(
-        self,
-        model_path: Optional[str] = None,
-        model_type: str = MODEL_TYPE_AUTO,
-        use_gpu: bool = True,
-        tracker_type: str = "bytetrack",
-        conf_threshold: float = 0.5,
-        person_threshold: float = 0.5,
-        max_lost: int = 30,
-    ):
-        if model_path is None:
-            model_path = TRACKING_CONFIG["model_path"]
-
-        self.model_path = model_path
-        self.model_type = model_type
-        self.use_gpu = use_gpu
-        self.tracker_type = tracker_type
-        self.conf_threshold = conf_threshold
-        self.person_threshold = person_threshold
-        self.max_lost = max_lost
-
-        self.model = None
-        self.rknn_detector = None
-        self.byte_tracker = None
-
-        resolved_path, resolved_type = resolve_model(model_path, model_type)
-        self.model_path = resolved_path
-        self.model_type = resolved_type
-
-        self._load_model()
-
-    def _load_model(self) -> None:
-        if self.model_type == MODEL_TYPE_RKNN:
-            self._load_rknn_model()
-        elif self.model_type == MODEL_TYPE_ONNX:
-            self._load_onnx_model()
-        else:
-            self._load_yolo_model()
-
-    def _load_yolo_model(self) -> None:
-        from ultralytics import YOLO
-        self.model = YOLO(self.model_path)
-        dummy = np.zeros((640, 640, 3), dtype=np.uint8)
-        device = "cuda:0" if self.use_gpu else "cpu"
-        # Warmup / JIT:在空白图上执行一次跟踪,触发 ultralytics 内部
-        # 的 tracker 初始化与可能的 PyTorch JIT 编译,避免首帧真实推理延迟。
-        self.model(dummy, task="track", tracker=f"{self.tracker_type}.yaml", persist=True, verbose=False, device=device)
-        logger.info("YOLO 跟踪模型加载成功: %s", self.model_path)
-
-    def _load_rknn_model(self) -> None:
-        try:
-            from inference_backend import RKNNDetector
-            self.rknn_detector = RKNNDetector(self.model_path)
-            self._init_byte_tracker()
-            logger.info("RKNN 跟踪模型加载成功: %s", self.model_path)
-        except ImportError as e:
-            logger.warning("RKNN 加载失败 (%s),回退到 YOLO 模型", e)
-            self.model_type = MODEL_TYPE_YOLO
-            self._load_yolo_model()
-
-    def _load_onnx_model(self) -> None:
-        try:
-            from inference_backend import ONNXDetector
-            self.rknn_detector = ONNXDetector(self.model_path)
-            self._init_byte_tracker()
-            logger.info("ONNX 跟踪模型加载成功: %s", self.model_path)
-        except ImportError as e:
-            logger.warning("ONNX 加载失败 (%s),回退到 YOLO 模型", e)
-            self.model_type = MODEL_TYPE_YOLO
-            self._load_yolo_model()
-
-    def _init_byte_tracker(self) -> None:
-        try:
-            from ultralytics.trackers.byte_tracker import BYTETracker
-            self.byte_tracker = BYTETracker(args=self._tracker_args())
-        except Exception as e:
-            logger.warning("初始化 BYTETracker 失败: %s,将使用简化 IOU 关联", e)
-            self.byte_tracker = None
-
-    def _tracker_args(self) -> types.SimpleNamespace:
-        return types.SimpleNamespace(
-            track_thresh=self.conf_threshold,
-            match_thresh=0.8,
-            track_buffer=self.max_lost,
-            mot20=False,
-        )
-
-    def update(self, frame: Optional[np.ndarray]) -> List[TrackedPerson]:
-        if frame is None:
-            return []
-        if self.model_type == MODEL_TYPE_YOLO:
-            return self._update_yolo(frame)
-        else:
-            return self._update_rknn_onnx(frame)
-
-    def _update_yolo(self, frame: np.ndarray) -> List[TrackedPerson]:
-        device = "cuda:0" if self.use_gpu else "cpu"
-        results = self.model(
-            frame,
-            task="track",
-            tracker=f"{self.tracker_type}.yaml",
-            persist=True,
-            conf=self.conf_threshold,
-            verbose=False,
-            device=device,
-        )
-        return self._parse_yolo_results(results, frame.shape)
-
-    def _parse_yolo_results(self, results: List[Any], frame_shape: Tuple[int, ...]) -> List[TrackedPerson]:
-        persons = []
-        h, w = frame_shape[:2]
-        for det in results:
-            boxes = det.boxes
-            if boxes is None or len(boxes) == 0:
-                continue
-            for i in range(len(boxes)):
-                cls_id = int(boxes.cls[i])
-                cls_name = det.names.get(cls_id, str(cls_id))
-                if cls_name != "person":
-                    continue
-                conf = float(boxes.conf[i])
-                if conf < self.person_threshold:
-                    continue
-                xyxy = boxes.xyxy[i]
-                if hasattr(xyxy, "cpu"):
-                    xyxy = xyxy.cpu().numpy()
-                x1, y1, x2, y2 = map(int, xyxy)
-                track_id = int(boxes.id[i]) if boxes.id is not None else -1
-                center_x = (x1 + x2) // 2
-                center_y = (y1 + y2) // 2
-                persons.append(TrackedPerson(
-                    track_id=track_id,
-                    bbox=(x1, y1, x2, y2),
-                    center=(center_x, center_y),
-                    confidence=conf,
-                ))
-        return persons
-
-    def _update_rknn_onnx(self, frame: np.ndarray) -> List[TrackedPerson]:
-        conf_map = {3: self.person_threshold}
-        detections = self.rknn_detector.detect(frame, conf_map)
-        # 只保留 person
-        person_dets = [d for d in detections if d.class_id == 3]
-        if not person_dets:
-            return []
-
-        if self.byte_tracker is None:
-            return self._simple_association(person_dets)
-
-        # 构造 BYTETracker 输入 [x1, y1, x2, y2, conf, cls]
-        try:
-            import torch
-            dets = []
-            for d in person_dets:
-                x1, y1, x2, y2 = d.bbox
-                dets.append([x1, y1, x2, y2, d.confidence, d.class_id])
-            dets_t = torch.tensor(dets, dtype=torch.float32)
-            tracks = self.byte_tracker.update(dets_t, frame.shape)
-            persons = []
-            for t in tracks:
-                x1, y1, x2, y2 = map(int, t.tlbr)
-                center_x = (x1 + x2) // 2
-                center_y = (y1 + y2) // 2
-                persons.append(TrackedPerson(
-                    track_id=int(t.track_id),
-                    bbox=(x1, y1, x2, y2),
-                    center=(center_x, center_y),
-                    confidence=float(t.score),
-                ))
-            return persons
-        except Exception as e:
-            logger.warning("BYTETracker 更新失败: %s,使用简化关联", e)
-            return self._simple_association(person_dets)
-
-    def _simple_association(self, detections: List[Detection]) -> List[TrackedPerson]:
-        """简化关联:无 ID 复用,每次返回新 track_id"""
-        persons = []
-        for d in detections:
-            x1, y1, x2, y2 = d.bbox
-            center_x = (x1 + x2) // 2
-            center_y = (y1 + y2) // 2
-            persons.append(TrackedPerson(
-                track_id=-1,
-                bbox=(x1, y1, x2, y2),
-                center=(center_x, center_y),
-                confidence=d.confidence,
-            ))
-        return persons
-
-    def reset(self) -> None:
-        if self.model_type == MODEL_TYPE_YOLO and self.model is not None:
-            self.model.predictor.trackers = []
-        if self.byte_tracker is not None:
-            self._init_byte_tracker()
-
-    def release(self) -> None:
-        if self.rknn_detector is not None:
-            self.rknn_detector.release()
-            self.rknn_detector = None
-        self.model = None
-        self.byte_tracker = None

Неке датотеке нису приказане због велике количине промена