Bladeren bron

feat: 完成双摄全景扫描系统功能迭代

1. 新增PTZ相机方向修正逻辑,支持壁装/吸顶安装配置
2. 更新目标检测模型为RK3588平台的yolo26n_end2end.rknn
3. 重构全景拼接逻辑,使用直接覆盖而非加权融合避免虚化
4. 添加预览抓拍API与前端展示,支持保存点预览图
5. 完善扫描点存储与Web状态管理,新增PTZ相机状态支持
6. 新增PTZ相机单元测试与全景扫描相关测试
7. 优化检测结果过滤,跳过无效边界框与置信度异常值
8. 调整相机配置参数,适配竖装球机的运动方向翻转
wenhongquan 2 dagen geleden
bovenliggende
commit
8fe88ac8a9

BIN
data/previews/group_1/preview_1781589698304.jpg


BIN
data/previews/group_1/preview_1781590071892.jpg


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


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


+ 8 - 3
dual_camera_system/app.py

@@ -28,6 +28,8 @@ import web.state as _web_state_module
 
 
 def build_rtsp_url(camera_config: dict) -> str:
+    if camera_config.get("rtsp_url"):
+        return camera_config["rtsp_url"]
     ip = camera_config["ip"]
     port = camera_config.get("rtsp_port", 554)
     username = camera_config["username"]
@@ -62,7 +64,8 @@ def create_app(test_mode: bool = False) -> FastAPI:
 
             if SYSTEM_CONFIG.get("enable_ptz_camera", True):
                 try:
-                    sdk = DahuaSDK(SDK_PATH["lib_path"])
+                    sdk_path = os.path.join(SDK_PATH["lib_path"], SDK_PATH.get("netsdk", "libdhnetsdk.so"))
+                    sdk = DahuaSDK(sdk_path)
                     sdk.init()
                 except Exception as exc:
                     print(f"[lifespan] SDK init failed: {exc}")
@@ -202,7 +205,9 @@ def create_app(test_mode: bool = False) -> FastAPI:
                 print(f"[lifespan] Group {gid} setup failed: {exc}")
                 continue
 
-        _web_state_module.web_state = WebState(group_state, stream_manager, scan_store, scanners, schedulers)
+        _web_state_module.web_state = WebState(
+            group_state, stream_manager, scan_store, scanners, schedulers, ptz_cameras
+        )
 
         yield
 
@@ -267,7 +272,7 @@ def create_app(test_mode: bool = False) -> FastAPI:
             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, {}, {}
+            group_state, test_stream_manager, test_scan_store, {}, {}, {}
         )
 
     app = FastAPI(lifespan=lifespan)

+ 6 - 8
dual_camera_system/config/camera.py

@@ -74,15 +74,13 @@ CAMERA_GROUPS = [
             'password': 'Aa1234567',
             'channel': 0,
             'rtsp_url': 'rtsp://admin:Aa1234567@192.168.20.197:554/cam/realmonitor?channel=1&subtype=0',
-            # 球机安装方向:ceiling=吸顶/吊装(镜头朝下), wall=壁装/立杆(镜头水平)
-            # 测试环境球机已在设备端设置为 ceiling 模式,代码层按 wall 避免双重翻转
+            # 球机安装方向:ceiling=吸顶/吊装(镜头朝下), wall=壁装/立杆(镜头水平/竖装镜头朝上
+            # 当前球机竖装、镜头朝上,pan/tilt 运动方向均与视觉方向相反
             'mount_type': 'wall',
-            # pan_flip: 球机与全景朝向相反时设为 True
-            # 测试环境球机竖装且设备端已做倒影,与枪机同向,无需翻转 pan
-            'pan_flip': False,
-            # tilt_flip: 俯仰方向相反时设为 True(设备端 ceiling 模式已处理 tilt,代码层不翻转)
-            # 2026-06-15 实测:使用手动标定 lookup 表,不在 transform 中额外翻转
-            'tilt_flip': False,
+            # pan_flip: 球机水平运动方向与视觉方向相反时设为 True
+            'pan_flip': True,
+            # tilt_flip: 球机俯仰运动方向与视觉方向相反时设为 True
+            'tilt_flip': True,
             # 视野映射:全景 x=0 -> pan=-90, x=1 -> pan=+90
             'pan_range': (-90, 90),
             'pan_center': 0,

+ 2 - 2
dual_camera_system/config/detection.py

@@ -14,8 +14,8 @@ def _default_model():
 
     if system == 'Linux' and machine == 'aarch64':
         # RK3588 / ARM64 NPU
-        # 使用 yolo11s.onnx(Ultralytics 推理
-        return '/home/admin/dsh/testrk3588/yolo11s.onnx', 'onnx'
+        # 使用 yolo26n end2end RKNN(单输出 1x300x6,x1/y1/x2/y2/conf/cls
+        return '/home/admin/dsh/testrk3588/yolo26n_end2end.rknn', 'rknn'
     elif system == 'Darwin':
         # macOS 本地测试:使用 yolo11n.pt,Ultralytics 会自动下载
         return '/Users/wenhongquan/Desktop/阿里云同步/项目/dnn/sb/model/yolo11n.pt', 'yolo'

+ 2 - 0
dual_camera_system/core/coord_utils.py

@@ -1,4 +1,6 @@
 """球面坐标与 PTZ pan/tilt 角度换算工具."""
+from __future__ import annotations
+
 import math
 from typing import Tuple
 

+ 10 - 2
dual_camera_system/core/scan_point_store.py

@@ -120,6 +120,7 @@ class ScanPointStore:
         tilt: float,
         zoom: int = 1,
         dwell_time: float = 3.0,
+        preview_image: Optional[str] = None,
     ) -> Dict[str, Any]:
         self._validate_point_field("pan", pan)
         self._validate_point_field("tilt", tilt)
@@ -141,6 +142,8 @@ class ScanPointStore:
                 "dwell_time": float(dwell_time),
                 "order": len(points),
             }
+            if preview_image is not None:
+                point["preview_image"] = preview_image
             points.append(point)
             group["next_point_id"] = point_id + 1
             self._save()
@@ -149,7 +152,7 @@ class ScanPointStore:
     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", "order", "preview_image"):
                 if key in ("pan", "tilt", "zoom", "dwell_time"):
                     self._validate_point_field(key, value)
                 elif key == "order":
@@ -157,6 +160,9 @@ class ScanPointStore:
                         raise ValueError("order must be an integer")
                     if value < 0:
                         raise ValueError("order must be non-negative")
+                elif key == "preview_image":
+                    if value is not None and not isinstance(value, str):
+                        raise ValueError("preview_image must be a string or null")
             else:
                 raise ValueError(f"Unknown field: {key}")
 
@@ -168,6 +174,8 @@ class ScanPointStore:
                 normalized[key] = int(value)
             elif key == "dwell_time":
                 normalized[key] = float(value)
+            elif key == "preview_image":
+                normalized[key] = value
             else:
                 normalized[key] = value
 
@@ -175,7 +183,7 @@ class ScanPointStore:
             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"):
+                    for key in ("pan", "tilt", "zoom", "dwell_time", "order", "preview_image"):
                         if key in normalized:
                             p[key] = normalized[key]
                     self._save()

+ 55 - 12
dual_camera_system/core/spatial_scanner.py

@@ -59,6 +59,8 @@ class SpatialScanner:
 
         grid = compute_sample_grid(pan_range, tilt_layers, pan_step)
         self.progress = {"total": len(grid), "current": 0, "state": "scanning"}
+        if progress_callback:
+            progress_callback(dict(self.progress))
         samples: List[Dict] = []
 
         for idx, (pan, tilt) in enumerate(grid):
@@ -69,6 +71,7 @@ class SpatialScanner:
                 time.sleep(self.stabilize_time)
                 frame = self._wait_frame(timeout=5.0)
                 if frame is None:
+                    logger.warning("No frame for pan=%s tilt=%s", pan, tilt)
                     continue
                 filename = f"p{pan:.1f}_t{tilt:.1f}.jpg"
                 path = os.path.join(self.data_dir, "samples", filename)
@@ -105,6 +108,31 @@ class SpatialScanner:
             time.sleep(0.1)
         return None
 
+    def _paste_direct(
+        self,
+        canvas: np.ndarray,
+        patch: np.ndarray,
+        u: int,
+        v: int,
+        width: int,
+        height: int,
+    ) -> None:
+        """直接把图块贴到画布上(无融合),支持 360° 环绕。"""
+        ph, pw = patch.shape[:2]
+        x0 = int(round(u - pw / 2))
+        y0 = int(round(v - ph / 2))
+        xs = max(0, x0)
+        xe = min(width, x0 + pw)
+        ys = max(0, y0)
+        ye = min(height, y0 + ph)
+        pxs = xs - x0
+        pxe = pxs + (xe - xs)
+        pys = ys - y0
+        pye = pys + (ye - ys)
+        if xe <= xs or ye <= ys:
+            return
+        canvas[ys:ye, xs:xe] = patch[pys:pye, pxs:pxe]
+
     def _build_equirectangular(
         self,
         samples: List[Dict],
@@ -115,28 +143,43 @@ class SpatialScanner:
     ) -> 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]
+            fh, fw = frame.shape[:2]
             pan = s["pan"]
             tilt = s["tilt"]
+            zoom = max(1, s.get("zoom", 1))
+
+            # 按 zoom 估算水平/垂直视场角(zoom 越大视场角越小)
+            hfov = 55.0 / zoom
+            vfov = hfov * (fh / fw)
+            patch_w = max(1, int(hfov * width / 360.0))
+            patch_h = max(1, int(vfov * height / 180.0))
+            patch = cv2.resize(frame, (patch_w, patch_h), interpolation=cv2.INTER_AREA)
+
+            # 仅裁剪相机 OSD 时间戳/水印边缘,避免重复文字破坏连续性
+            crop_top = int(patch_h * 0.08)
+            crop_bottom = int(patch_h * 0.03)
+            crop_left = int(patch_w * 0.03)
+            crop_right = int(patch_w * 0.03)
+            patch = patch[crop_top:patch_h - crop_bottom, crop_left:patch_w - crop_right]
+            patch_h, patch_w = patch.shape[:2]
+
             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
+
+            self._paste_direct(panorama, patch, u_center, v_center, width, height)
+            # 处理 0°/360° 接缝:左右两侧重复贴图
+            if u_center - patch_w // 2 < 0:
+                self._paste_direct(panorama, patch, u_center + width, v_center, width, height)
+            if u_center + patch_w // 2 > width:
+                self._paste_direct(panorama, patch, u_center - width, v_center, width, height)
 
         path = os.path.join(self.data_dir, "panorama", f"scan_{int(time.time())}.jpg")
         cv2.imwrite(path, panorama)

BIN
dual_camera_system/data/previews/group_1/preview_1781589666285.jpg


BIN
dual_camera_system/data/previews/group_1/preview_1781589684876.jpg


+ 5 - 1
dual_camera_system/panorama_camera.py

@@ -849,7 +849,11 @@ class ObjectDetector:
 
                 for i in range(output.shape[0]):
                     x1_lb, y1_lb, x2_lb, y2_lb, conf, cls_id = output[i]
-                    if conf < conf_threshold:
+                    if not np.isfinite(conf) or conf < conf_threshold:
+                        continue
+                    if not (np.isfinite(x1_lb) and np.isfinite(y1_lb) and np.isfinite(x2_lb) and np.isfinite(y2_lb)):
+                        continue
+                    if x1_lb >= x2_lb or y1_lb >= y2_lb:
                         continue
                     cls_name = class_map.get(int(cls_id), str(int(cls_id)))
                     if cls_name not in self.config['target_classes']:

+ 37 - 31
dual_camera_system/ptz_camera.py

@@ -294,30 +294,54 @@ class PTZCamera:
         self.zoom_in(0, True)
         return True
     
+    def _apply_orientation_correction(self, pan: float, tilt: float) -> Tuple[float, float]:
+        """根据安装方式/翻转配置,把视觉坐标转换为发送给 SDK 的物理坐标。"""
+        mount_type = self.ptz_config.get('mount_type', 'wall')
+        tilt_flip = self.ptz_config.get('tilt_flip', False)
+        pan_flip = self.ptz_config.get('pan_flip', False)
+
+        # 吸顶安装或显式 tilt_flip 时反转 tilt
+        if mount_type == 'ceiling' or tilt_flip:
+            tilt = -tilt
+            print(f"[PTZCamera] tilt方向修正: {-tilt} -> {tilt}")
+
+        # pan_flip 时水平方向旋转 180°
+        if pan_flip:
+            pan = (pan + 180) % 360
+            print(f"[PTZCamera] pan方向翻转: {(pan - 180) % 360} -> {pan}")
+
+        return pan, tilt
+
     def goto_exact_position(self, pan: float, tilt: float, zoom: int) -> bool:
         """
         三维精确定位
         Args:
-            pan: 水平角度 (0-360度)
-            tilt: 垂直角度 (-90到90度)
+            pan: 水平角度 (0-360度,视觉坐标)
+            tilt: 垂直角度 (-90到90度,视觉坐标)
             zoom: 变倍 (1-128)
         Returns:
             是否成功
         """
-        param1 = int(pan * 10)
-        param2 = int(tilt * 10)
+        # 保存视觉坐标,SDK 发送前根据安装方向做修正
+        visual_pan, visual_tilt = pan, tilt
+        physical_pan, physical_tilt = self._apply_orientation_correction(pan, tilt)
+
+        param1 = int(physical_pan * 10)
+        param2 = int(physical_tilt * 10)
         param3 = int(min(zoom, 128))
-        
-        print(f"[PTZCamera] goto_exact_position: pan={pan:.1f}° tilt={tilt:.1f}° zoom={zoom} → p1={param1} p2={param2} p3={param3}")
-        
+
+        print(f"[PTZCamera] goto_exact_position: visual=({visual_pan:.1f}°, {visual_tilt:.1f}°) "
+              f"physical=({physical_pan:.1f}°, {physical_tilt:.1f}°) zoom={zoom} "
+              f"→ p1={param1} p2={param2} p3={param3}")
+
         result = self.ptz_control(PTZCommand.EXACTGOTO, param1, param2, param3)
-        
+
         if result:
             with self.position_lock:
-                self.current_position = PTZPosition(pan=pan, tilt=tilt, zoom=zoom)
+                self.current_position = PTZPosition(pan=visual_pan, tilt=visual_tilt, zoom=zoom)
         else:
             print(f"[PTZCamera] goto_exact_position FAILED!")
-        
+
         return result
     
     def goto_preset(self, preset_id: int) -> bool:
@@ -375,31 +399,13 @@ class PTZCamera:
         pan_center = self.ptz_config.get('pan_center', 90)
         tilt_center = self.ptz_config.get('tilt_center', 0)
     
-        # 将画面比例转换为角度
+        # 将画面比例转换为角度(视觉坐标,翻转统一在 goto_exact_position 处理)
         # x_ratio=0 对应 pan_range[0], x_ratio=1 对应 pan_range[1]
         pan = pan_range[0] + (pan_range[1] - pan_range[0]) * x_ratio
-    
+
         # y_ratio=0.5 对应 tilt_center, y_ratio=0 对应 tilt_range[0], y_ratio=1 对应 tilt_range[1]
         tilt = tilt_range[0] + (tilt_range[1] - tilt_range[0]) * y_ratio
-            
-        # 应用方向修正
-        # 1. 检查安装类型,吸顶安装需要反转tilt
-        mount_type = self.ptz_config.get('mount_type', 'wall')
-        tilt_flip = self.ptz_config.get('tilt_flip', False)
-            
-        # 吸顶安装时自动反转tilt方向
-        if mount_type == 'ceiling' or tilt_flip:
-            # 反转tilt:原来的正角度变负,负角度变正
-            tilt = -tilt
-            print(f"[PTZCamera] 吸顶安装,tilt方向修正: {-tilt} -> {tilt}")
-            
-        # 2. 应用pan方向修正
-        pan_flip = self.ptz_config.get('pan_flip', False)
-        if pan_flip:
-            # 反转pan方向(180度偏移)
-            pan = (pan + 180) % 360
-            print(f"[PTZCamera] pan方向翻转: {(pan - 180) % 360} -> {pan}")
-    
+
         return (pan, tilt, zoom)
     
     def move_to_target(self, x_ratio: float, y_ratio: float, 

+ 89 - 0
dual_camera_system/tests/test_ptz_camera.py

@@ -0,0 +1,89 @@
+import sys
+import os
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from unittest.mock import MagicMock
+import pytest
+
+from ptz_camera import PTZCamera, PTZCommand
+
+
+@pytest.fixture
+def fake_sdk():
+    return MagicMock()
+
+
+def make_camera(fake_sdk, **ptz_overrides):
+    cam = PTZCamera(fake_sdk, camera_config={"ip": "1.2.3.4", "port": 37777,
+                                              "username": "u", "password": "p",
+                                              "channel": 0, **ptz_overrides})
+    cam.connected = True
+    cam.login_handle = 1
+    fake_sdk.ptz_control.return_value = True
+    return cam
+
+
+def capture_goto_call(cam):
+    """返回最后一次 EXACTGOTO 调用的 (pan*10, tilt*10, zoom)。"""
+    calls = cam.sdk.ptz_control.call_args_list
+    for call in reversed(calls):
+        args = call[0]
+        if args[2] == PTZCommand.EXACTGOTO:
+            return args[3], args[4], args[5]
+    raise AssertionError("EXACTGOTO not called")
+
+
+def test_goto_no_flip(fake_sdk):
+    cam = make_camera(fake_sdk, mount_type="wall", pan_flip=False, tilt_flip=False)
+    assert cam.goto_exact_position(30.0, 10.0, 2)
+    p1, p2, p3 = capture_goto_call(cam)
+    assert p1 == 300
+    assert p2 == 100
+    assert p3 == 2
+
+
+def test_goto_tilt_flip(fake_sdk):
+    cam = make_camera(fake_sdk, mount_type="wall", pan_flip=False, tilt_flip=True)
+    assert cam.goto_exact_position(30.0, 10.0, 2)
+    p1, p2, p3 = capture_goto_call(cam)
+    assert p1 == 300
+    assert p2 == -100
+    assert p3 == 2
+
+
+def test_goto_ceiling_mount_flips_tilt(fake_sdk):
+    cam = make_camera(fake_sdk, mount_type="ceiling", pan_flip=False, tilt_flip=False)
+    assert cam.goto_exact_position(30.0, 10.0, 2)
+    p1, p2, p3 = capture_goto_call(cam)
+    assert p1 == 300
+    assert p2 == -100
+    assert p3 == 2
+
+
+def test_goto_pan_flip(fake_sdk):
+    cam = make_camera(fake_sdk, mount_type="wall", pan_flip=True, tilt_flip=False)
+    assert cam.goto_exact_position(30.0, 10.0, 2)
+    p1, p2, p3 = capture_goto_call(cam)
+    assert p1 == 2100  # (30+180)*10
+    assert p2 == 100
+    assert p3 == 2
+
+
+def test_goto_pan_and_tilt_flip(fake_sdk):
+    cam = make_camera(fake_sdk, mount_type="wall", pan_flip=True, tilt_flip=True)
+    assert cam.goto_exact_position(30.0, 10.0, 2)
+    p1, p2, p3 = capture_goto_call(cam)
+    assert p1 == 2100
+    assert p2 == -100
+    assert p3 == 2
+
+
+def test_calculate_position_does_not_apply_flips(fake_sdk):
+    """calculate_ptz_position 返回视觉坐标,翻转统一在 goto_exact_position 处理。"""
+    cam = make_camera(fake_sdk, mount_type="wall", pan_flip=True, tilt_flip=True,
+                      pan_range=(0, 90), tilt_range=(-45, 45), pan_center=45, tilt_center=0)
+    pan, tilt, zoom = cam.calculate_ptz_position(0.5, 0.5, 3)
+    assert pan == 45.0
+    assert tilt == 0.0
+    assert zoom == 3

+ 70 - 5
dual_camera_system/tests/test_routes.py

@@ -7,6 +7,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 
 from unittest.mock import MagicMock
 
+import numpy as np
 import pytest
 from fastapi.testclient import TestClient
 from app import create_app
@@ -32,9 +33,17 @@ def client_with_mocks():
     }
     scheduler = MagicMock()
 
+    ptz = MagicMock()
+    ptz_stream = MagicMock()
+    ptz_stream.get_frame.return_value = np.zeros((100, 100, 3), dtype=np.uint8)
+    stream_manager = MagicMock()
+    stream_manager.get.return_value = ptz_stream
+
     _web_state_module.web_state.scanners["group_1"] = scanner
     _web_state_module.web_state.schedulers["group_1"] = scheduler
-    return client, scanner, scheduler
+    _web_state_module.web_state.ptz_cameras["group_1"] = ptz
+    _web_state_module.web_state.stream_manager = stream_manager
+    return client, scanner, scheduler, ptz, ptz_stream
 
 
 def test_status_endpoint(client):
@@ -96,7 +105,7 @@ def test_panorama_not_found(client):
 
 
 def test_scan_start_returns_202(client_with_mocks):
-    client, scanner, _ = client_with_mocks
+    client, scanner, _, _, _ = client_with_mocks
     started = threading.Event()
 
     def run_with_event(*args, **kwargs):
@@ -115,8 +124,33 @@ def test_scan_start_returns_202(client_with_mocks):
     assert started.is_set(), "Scanner run() was not invoked"
 
 
+def test_scan_uses_full_vertical_range(client_with_mocks):
+    """360° 扫描应覆盖 -90° 到 +90° 的完整上下范围。"""
+    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
+
+    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"
+
+    _, kwargs = scanner.run.call_args
+    tilt_layers = kwargs.get("tilt_layers", ())
+    assert min(tilt_layers) <= -90
+    assert max(tilt_layers) >= 90
+
+
 def test_poll_start_and_stop(client_with_mocks):
-    client, _, scheduler = 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()
@@ -144,7 +178,7 @@ def test_static_js_served(client):
 
 
 def test_mutating_endpoints_require_api_key(client_with_mocks, monkeypatch):
-    client, scanner, scheduler = client_with_mocks
+    client, scanner, scheduler, _, _ = client_with_mocks
     monkeypatch.setattr("web.auth.DEVICE_CONFIG", {"api_key": "secret123"})
 
     # Scan
@@ -168,7 +202,7 @@ def test_mutating_endpoints_require_api_key(client_with_mocks, monkeypatch):
 
 
 def test_mutating_endpoints_accept_api_key(client_with_mocks, monkeypatch):
-    client, scanner, scheduler = client_with_mocks
+    client, scanner, scheduler, _, _ = client_with_mocks
     monkeypatch.setattr("web.auth.DEVICE_CONFIG", {"api_key": "secret123"})
 
     response = client.post(
@@ -190,3 +224,34 @@ def test_readonly_endpoints_remain_open_with_api_key(client, monkeypatch):
 
     response = client.get("/api/scan/group_1/progress")
     assert response.status_code == 200
+
+
+def test_preview_does_not_update_saved_point_image(client_with_mocks):
+    """点球机预览只临时抓拍并显示,不应更新保存点的 preview_image。"""
+    client, _, _, _, _ = client_with_mocks
+
+    # 创建一个带有固定 preview_image 的保存点
+    response = client.post("/api/points/group_1", json={
+        "pan": 30.0,
+        "tilt": 0.0,
+        "zoom": 1,
+        "dwell_time": 3.0,
+        "preview_image": "data/previews/group_1/existing.jpg",
+    })
+    assert response.status_code == 200
+    point_id = response.json()["id"]
+
+    response = client.post("/api/preview/group_1", json={
+        "pan": 30.0,
+        "tilt": 0.0,
+        "zoom": 1,
+        "point_id": point_id,
+    })
+    assert response.status_code == 200
+    assert response.json()["snapshot_url"] is not None
+
+    response = client.get(f"/api/points/group_1")
+    assert response.status_code == 200
+    point = next(p for p in response.json()["points"] if p["id"] == point_id)
+    # 保存点的 preview_image 应保持原值,不被预览抓拍覆盖
+    assert point["preview_image"] == "data/previews/group_1/existing.jpg"

+ 44 - 1
dual_camera_system/tests/test_spatial_scanner.py

@@ -3,6 +3,7 @@ import os
 import tempfile
 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 
+import cv2
 import numpy as np
 import pytest
 from core.spatial_scanner import SpatialScanner
@@ -93,7 +94,10 @@ def test_progress_callback_invoked(tmp_path):
     )
     expected_samples = len(result["samples"])
     assert expected_samples > 0
-    assert len(progress_snapshots) == expected_samples
+    # 扫描开始前会报告一次初始进度,之后每成功采集一个点报告一次
+    assert len(progress_snapshots) == expected_samples + 1
+    assert progress_snapshots[0]["current"] == 0
+    assert progress_snapshots[0]["state"] == "scanning"
     assert progress_snapshots[-1]["current"] == expected_samples
     assert progress_snapshots[-1]["state"] == "scanning"
 
@@ -106,3 +110,42 @@ def test_prerun_cancellation_returns_empty_result(tmp_path):
     assert result["samples"] == []
     assert result["panorama_path"] is None
     assert scanner.progress["state"] == "cancelled"
+
+
+def test_panorama_does_not_blend_overlapping_samples(tmp_path):
+    """重叠区域应直接覆盖,而不是加权融合导致虚化。"""
+    ptz = FakePTZ()
+    calls = {"n": 0}
+
+    def frame_source():
+        calls["n"] += 1
+        # 第一张红色,第二张蓝色,两者水平视场约 55°,在 0°/30° 处明显重叠
+        color = (0, 0, 255) if calls["n"] == 1 else (255, 0, 0)
+        return np.full((100, 100, 3), color, 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=(0,), pan_step=30, zoom=1)
+    assert result["panorama_path"] is not None
+    panorama = cv2.imread(result["panorama_path"])
+    assert panorama is not None
+
+    # 在第二张图(pan=30°)的中心区域采样;直接覆盖应接近蓝色
+    height, width = panorama.shape[:2]
+    u = int((30 / 60) * width)
+    v = int(((90 - 0) / 180) * height)
+    roi = panorama[
+        max(0, v - 50):min(height, v + 50),
+        max(0, u - 50):min(width, u + 50),
+    ]
+    mask = roi.sum(axis=2) > 0
+    assert mask.sum() > 0
+    mean_color = roi[mask].mean(axis=0)
+
+    blue = np.array([255, 0, 0], dtype=np.float32)
+    blend = np.array([127, 0, 127], dtype=np.float32)
+    dist_to_blue = np.linalg.norm(mean_color - blue)
+    dist_to_blend = np.linalg.norm(mean_color - blend)
+
+    # 均值应更接近蓝色,而不是红蓝融合色
+    assert dist_to_blue < 60
+    assert dist_to_blend > 90

+ 85 - 4
dual_camera_system/web/routes.py

@@ -3,9 +3,11 @@ import logging
 import mimetypes
 import os
 import threading
+import time
 from pathlib import Path
 from typing import Optional
 
+import cv2
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi.responses import StreamingResponse, FileResponse, JSONResponse
 
@@ -15,10 +17,11 @@ from pydantic import BaseModel, Field
 import web.state as _web_state_module
 from web.state import WebState
 from core.stream_manager import generate_mjpeg_stream
+from config.coordinator import COORDINATOR_CONFIG
 
 router = APIRouter()
 
-PANORAMA_BASE = Path(os.environ.get("PANORAMA_DIR", "data")).resolve()
+PANORAMA_BASE = Path(os.environ.get("PANORAMA_DIR", ".")).resolve()
 
 
 class AddPointPayload(BaseModel):
@@ -26,6 +29,14 @@ class AddPointPayload(BaseModel):
     tilt: float = Field(..., ge=-90, le=90)
     zoom: int = Field(1, ge=1)
     dwell_time: float = Field(3.0, gt=0)
+    preview_image: Optional[str] = None
+
+
+class PreviewPayload(BaseModel):
+    pan: float = Field(..., ge=-360, le=360)
+    tilt: float = Field(..., ge=-90, le=90)
+    zoom: int = Field(1, ge=1)
+    point_id: Optional[int] = None
 
 
 def _get_state() -> WebState:
@@ -39,8 +50,10 @@ def _resolve_panorama_path(raw_path: Optional[str]) -> Path:
     if not raw_path:
         raise HTTPException(status_code=404, detail="Panorama not found")
     resolved = (PANORAMA_BASE / raw_path).resolve()
-    if not resolved.is_relative_to(PANORAMA_BASE):
-        raise HTTPException(status_code=400, detail="Invalid panorama path")
+    try:
+        resolved.relative_to(PANORAMA_BASE)
+    except ValueError as exc:
+        raise HTTPException(status_code=400, detail="Invalid panorama path") from exc
     return resolved
 
 
@@ -88,7 +101,7 @@ def api_start_scan(group_id: str) -> JSONResponse:
         try:
             result = scanner.run(
                 pan_range=(0.0, 360.0),
-                tilt_layers=(-20.0, 0.0, 20.0),
+                tilt_layers=tuple(float(t) for t in range(-90, 91, 30)),
                 pan_step=30.0,
                 zoom=1,
                 progress_callback=lambda p: state.group_state.update(
@@ -148,6 +161,7 @@ def api_add_point(group_id: str, payload: AddPointPayload) -> dict:
         tilt=payload.tilt,
         zoom=payload.zoom,
         dwell_time=payload.dwell_time,
+        preview_image=payload.preview_image,
     )
     return point
 
@@ -197,6 +211,73 @@ def api_poll_stop(group_id: str) -> dict:
     return {"message": "Polling stopped"}
 
 
+@router.post("/api/preview/{group_id}", dependencies=[Depends(verify_api_key)])
+def api_preview(group_id: str, payload: PreviewPayload) -> dict:
+    state = _get_state()
+    _require_group(state, group_id)
+
+    ptz = state.ptz_cameras.get(group_id)
+    if not ptz:
+        raise HTTPException(status_code=404, detail="PTZ camera not connected")
+
+    try:
+        ptz.goto_exact_position(payload.pan, payload.tilt, payload.zoom)
+    except Exception as exc:
+        logging.exception("PTZ preview move failed for %s", group_id)
+        raise HTTPException(status_code=500, detail=f"PTZ move failed: {exc}") from exc
+
+    stabilize = COORDINATOR_CONFIG.get("ptz_stabilize_time", 1.5)
+    time.sleep(stabilize)
+
+    stream = state.stream_manager.get(f"{group_id}_ptz")
+    frame = stream.get_frame() if stream else None
+    snapshot_path = None
+    if frame is not None:
+        snapshot_dir = Path("data") / "previews" / group_id
+        snapshot_dir.mkdir(parents=True, exist_ok=True)
+        snapshot_path = str(snapshot_dir / f"preview_{int(time.time() * 1000)}.jpg")
+        if not cv2.imwrite(snapshot_path, frame):
+            raise HTTPException(status_code=500, detail="Failed to save preview snapshot")
+
+    state.group_state.update(group_id, "ptz_position", {
+        "pan": payload.pan, "tilt": payload.tilt, "zoom": payload.zoom
+    })
+
+    snapshot_url = f"/api/preview-image?path={snapshot_path}" if snapshot_path else None
+
+    return {
+        "message": "Preview done",
+        "position": {"pan": payload.pan, "tilt": payload.tilt, "zoom": payload.zoom},
+        "snapshot_path": snapshot_path,
+        "snapshot_url": snapshot_url,
+    }
+
+
+PREVIEW_BASE = Path("data/previews").resolve()
+
+
+@router.get("/api/preview-image")
+def api_preview_image(path: str) -> FileResponse:
+    if not path:
+        raise HTTPException(status_code=400, detail="Path required")
+    raw = Path(path)
+    if raw.is_absolute():
+        raise HTTPException(status_code=400, detail="Invalid preview path")
+    try:
+        rel = raw.relative_to("data/previews")
+    except ValueError:
+        rel = raw
+    resolved = (PREVIEW_BASE / rel).resolve()
+    try:
+        resolved.relative_to(PREVIEW_BASE)
+    except ValueError as exc:
+        raise HTTPException(status_code=400, detail="Invalid preview path") from exc
+    if not resolved.exists():
+        raise HTTPException(status_code=404, detail="Preview not found")
+    media_type = mimetypes.guess_type(str(resolved))[0] or "image/jpeg"
+    return FileResponse(resolved, media_type=media_type)
+
+
 @router.get("/api/live/{camera}/{group_id}")
 def api_live(
     camera: str, group_id: str, marked: bool = False

+ 2 - 1
dual_camera_system/web/state.py

@@ -3,12 +3,13 @@ from typing import Dict, Optional
 
 
 class WebState:
-    def __init__(self, group_state, stream_manager, scan_store, scanners: Dict, schedulers: Dict):
+    def __init__(self, group_state, stream_manager, scan_store, scanners: Dict, schedulers: Dict, ptz_cameras: Dict = None):
         self.group_state = group_state
         self.stream_manager = stream_manager
         self.scan_store = scan_store
         self.scanners = scanners
         self.schedulers = schedulers
+        self.ptz_cameras = ptz_cameras or {}
 
 
 web_state: Optional[WebState] = None

+ 122 - 11
dual_camera_system/web_static/app.js

@@ -46,6 +46,9 @@ let scene, camera, renderer, sphere, controls;
 let raycaster = new THREE.Raycaster();
 let mouse = new THREE.Vector2();
 let markers = [];
+let tempMarker = null;
+let previewSprites = [];
+let tempPreview = null;
 let scanPollInterval = null;
 let controlsGloballyDisabled = false;
 
@@ -156,6 +159,61 @@ function clearMarkers() {
   markers = [];
 }
 
+function clearTempMarker() {
+  if (tempMarker) {
+    scene.remove(tempMarker);
+    tempMarker = null;
+  }
+}
+
+function setTempMarker(pan, tilt) {
+  clearTempMarker();
+  tempMarker = addMarker(pan, tilt, 0x3b82f6);
+}
+
+function clearPreviewSprites() {
+  previewSprites.forEach(s => scene.remove(s));
+  previewSprites = [];
+}
+
+function addPreviewPlane(pan, tilt, imageUrl) {
+  const loader = new THREE.TextureLoader();
+  const mesh = new THREE.Mesh(
+    new THREE.PlaneGeometry(1, 1),
+    new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide, transparent: true, opacity: 0.95 })
+  );
+  mesh.visible = false;
+  scene.add(mesh);
+  previewSprites.push(mesh);
+
+  loader.load(imageUrl, (texture) => {
+    const imageWidth = texture.image.width || 100;
+    const imageHeight = texture.image.height || 100;
+    const aspect = imageWidth / imageHeight;
+    const baseSize = 5;
+    mesh.geometry.dispose();
+    mesh.geometry = new THREE.PlaneGeometry(baseSize * aspect, baseSize);
+    mesh.material.map = texture;
+    mesh.material.needsUpdate = true;
+
+    const r = 46;
+    const phi = (90 - tilt) * Math.PI / 180;
+    const theta = (pan - 90) * Math.PI / 180;
+    const x = r * Math.sin(phi) * Math.cos(theta);
+    const y = r * Math.cos(phi);
+    const z = r * Math.sin(phi) * Math.sin(theta);
+    mesh.position.set(x, y, z);
+    // 让平面法线朝外(贴合球面)
+    mesh.lookAt(x * 2, y * 2, z * 2);
+    mesh.visible = true;
+    mesh.userData = { type: 'preview', pan, tilt, imageUrl };
+  }, undefined, (err) => {
+    log(`加载预览图失败: ${err.message || err}`);
+  });
+
+  return mesh;
+}
+
 function addMarker(pan, tilt, color = 0x22c55e) {
   const r = 49.5;
   const phi = (90 - tilt) * Math.PI / 180;
@@ -185,7 +243,8 @@ function onPanoramaClick(event) {
   const tilt = Math.asin(Math.max(-1, Math.min(1, p.y))) * 180 / Math.PI;
   document.getElementById('inp-pan').value = pan.toFixed(2);
   document.getElementById('inp-tilt').value = tilt.toFixed(2);
-  addMarker(pan, tilt, 0x3b82f6);
+  tempPreview = null;
+  setTempMarker(pan, tilt);
 }
 
 function animate() {
@@ -226,6 +285,9 @@ async function loadGroups() {
 
 function onGroupChange() {
   currentGroup = document.getElementById('group-select').value;
+  clearTempMarker();
+  clearPreviewSprites();
+  tempPreview = null;
   loadPanorama(currentGroup);
   renderVideos(currentGroup);
   loadPoints(currentGroup);
@@ -256,36 +318,50 @@ async function loadPoints(groupId) {
   try {
     const data = await API.get(`/api/points/${groupId}`);
     clearMarkers();
+    clearPreviewSprites();
     const ul = document.getElementById('points');
     ul.innerHTML = '';
     data.points.forEach(p => {
       addMarker(p.pan, p.tilt, 0x22c55e);
+      if (p.preview_image) {
+        addPreviewPlane(p.pan, p.tilt, `/api/preview-image?path=${encodeURIComponent(p.preview_image)}`);
+      }
       const li = document.createElement('li');
 
       const span = document.createElement('span');
       span.textContent = `P:${p.pan.toFixed(0)} T:${p.tilt.toFixed(0)}`;
 
-      const btn = document.createElement('button');
-      btn.dataset.id = String(p.id);
-      btn.textContent = '删除';
-      btn.onclick = async () => {
+      const previewBtn = document.createElement('button');
+      previewBtn.textContent = '预览';
+      previewBtn.onclick = async () => {
+        document.getElementById('inp-pan').value = p.pan.toFixed(2);
+        document.getElementById('inp-tilt').value = p.tilt.toFixed(2);
+        document.getElementById('inp-zoom').value = p.zoom;
+        await runPreview(groupId, p.pan, p.tilt, p.zoom, p.id);
+      };
+
+      const delBtn = document.createElement('button');
+      delBtn.dataset.id = String(p.id);
+      delBtn.textContent = '删除';
+      delBtn.onclick = async () => {
         if (!currentGroup) {
           log('未选择摄像头组');
           return;
         }
         if (!confirm('确定删除该扫描点?')) return;
-        btn.disabled = true;
+        delBtn.disabled = true;
         try {
           await API.del(`/api/points/${groupId}/${p.id}`);
           loadPoints(groupId);
         } catch (e) {
           log(`删除失败: ${e.message}`);
-          btn.disabled = false;
+          delBtn.disabled = false;
         }
       };
 
       li.appendChild(span);
-      li.appendChild(btn);
+      li.appendChild(previewBtn);
+      li.appendChild(delBtn);
       ul.appendChild(li);
     });
   } catch (e) {
@@ -384,6 +460,32 @@ document.getElementById('btn-poll-stop').addEventListener('click', async () => {
   });
 });
 
+async function runPreview(groupId, pan, tilt, zoom, pointId = null) {
+  const payload = { pan, tilt, zoom };
+  if (pointId !== null) {
+    payload.point_id = pointId;
+  }
+  const result = await API.post(`/api/preview/${groupId}`, payload);
+  log(`预览位置: P=${pan.toFixed(1)} T=${tilt.toFixed(1)} Z=${zoom}`);
+  if (result.snapshot_url) {
+    log(`预览抓拍已保存: ${result.snapshot_path}`);
+    if (pointId === null) {
+      // 未保存的临时点:记录本次预览图,保存时可关联
+      if (tempPreview && tempPreview.mesh) {
+        scene.remove(tempPreview.mesh);
+        previewSprites = previewSprites.filter(s => s !== tempPreview.mesh);
+      }
+      clearTempMarker();
+      const mesh = addPreviewPlane(pan, tilt, result.snapshot_url);
+      tempPreview = { pan, tilt, url: result.snapshot_url, path: result.snapshot_path, mesh };
+    } else {
+      // 已保存点:刷新列表以显示最新预览图
+      loadPoints(groupId);
+    }
+  }
+  return result;
+}
+
 document.getElementById('btn-preview').addEventListener('click', async () => {
   if (!currentGroup) {
     log('未选择摄像头组');
@@ -402,9 +504,13 @@ document.getElementById('btn-preview').addEventListener('click', async () => {
     log('错误:zoom 必须是大于等于 1 的整数');
     return;
   }
-  log(`预览位置: P=${pan.toFixed(1)} T=${tilt.toFixed(1)} Z=${zoom}`);
-  // Preview is implemented by the user visually confirming via the PTZ live stream window.
-  // A future backend endpoint could move the PTZ to this exact point.
+  await withDisabled('btn-preview', async () => {
+    try {
+      await runPreview(currentGroup, pan, tilt, zoom);
+    } catch (e) {
+      log(`预览失败: ${e.message}`);
+    }
+  });
 });
 
 document.getElementById('btn-add').addEventListener('click', async () => {
@@ -442,9 +548,14 @@ document.getElementById('btn-add').addEventListener('click', async () => {
     }
 
     const payload = { pan, tilt, zoom, dwell_time: dwellTime };
+    if (tempPreview) {
+      payload.preview_image = tempPreview.path;
+    }
     try {
       await API.post(`/api/points/${currentGroup}`, payload);
       log('扫描点已保存');
+      clearTempMarker();
+      tempPreview = null;
       loadPoints(currentGroup);
     } catch (e) {
       log(`保存失败: ${e.message}`);