Просмотр исходного кода

feat: add PTZ calibration, file cleanup and optimize preview logic

1. 新增PTZ坐标校准模块,支持线性映射和查找表校准
2. 新增本地文件定期清理功能,支持按保留天数和最大文件数清理
3. 重构预览接口逻辑,使用固定稳定时间替代动态计算,避免依赖过期状态
4. 新增存储配置项,拆分抓拍和预览图的存储策略
5. 优化第三方上报兼容性,兼容老版字段格式
6. 调整启动脚本默认参数,移除跳过校准选项
7. 更新前端画布渲染为G6图库实现,优化样本展示
8. 新增对应单元测试覆盖新增功能
wenhongquan 23 часов назад
Родитель
Сommit
40453bc8ef

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


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


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


+ 47 - 3
dual_camera_system/app.py

@@ -1,4 +1,5 @@
 """FastAPI 应用工厂与全局服务初始化."""
+import logging
 import os
 import tempfile
 import threading
@@ -10,7 +11,7 @@ from fastapi import FastAPI
 from fastapi.staticfiles import StaticFiles
 from fastapi.responses import FileResponse
 
-from config import CAMERA_GROUPS, SDK_PATH, SYSTEM_CONFIG
+from config import CAMERA_GROUPS, SDK_PATH, SYSTEM_CONFIG, STORAGE_CONFIG
 from dahua_sdk import DahuaSDK
 from ptz_camera import PTZCamera
 from third_party_pusher import get_third_party_pusher
@@ -23,10 +24,13 @@ from core.capture_uploader import CaptureUploader
 from core.detector_service import DetectorService
 from core.group_state import group_state
 from core.oss_uploader import OSSUploader
+from core.file_cleanup import make_cleanup_workers, CleanupWorker
 from web.routes import router
 from web.state import WebState
 import web.state as _web_state_module
 
+logger = logging.getLogger(__name__)
+
 
 def build_rtsp_url(camera_config: dict) -> str:
     if camera_config.get("rtsp_url"):
@@ -59,6 +63,7 @@ def create_app(test_mode: bool = False) -> FastAPI:
         sdk = None
         detector_service = None
         oss_uploader = None
+        cleanup_workers: list = []
 
         if not test_mode:
             if SYSTEM_CONFIG.get("enable_detection", True):
@@ -88,6 +93,37 @@ def create_app(test_mode: bool = False) -> FastAPI:
                     print(f"[lifespan] Pusher start failed: {exc}")
                     pusher = None
 
+            # 上报成功后,根据配置删除本地抓拍图片
+            if pusher is not None:
+                def _delete_captures_on_success(report):
+                    if STORAGE_CONFIG.get("captures", {}).get("keep_local_copy", False):
+                        return
+                    for path in report.batch_info.get("image_paths") or []:
+                        try:
+                            if path and os.path.exists(path):
+                                os.remove(path)
+                                logger.info("[cleanup] 上报成功后删除本地图片: %s", path)
+                        except Exception as exc:
+                            logger.warning("[cleanup] 删除本地图片失败: %s, %s", path, exc)
+
+                try:
+                    pusher.set_callbacks(on_success=_delete_captures_on_success)
+                except Exception as exc:
+                    print(f"[lifespan] set pusher success callback failed: {exc}")
+
+            # 启动 captures / previews 定期清理 worker
+            try:
+                group_ids = [
+                    g.get("group_id", g.get("id"))
+                    for g in CAMERA_GROUPS
+                    if g.get("enabled", True)
+                ]
+                cleanup_workers = make_cleanup_workers(STORAGE_CONFIG, group_ids)
+                for w in cleanup_workers:
+                    w.start()
+            except Exception as exc:
+                print(f"[lifespan] cleanup worker start failed: {exc}")
+
         for group in CAMERA_GROUPS:
             if not group.get("enabled", True):
                 continue
@@ -116,8 +152,10 @@ def create_app(test_mode: bool = False) -> FastAPI:
 
                 ptz = None
                 if SYSTEM_CONFIG.get("enable_ptz_camera", True) and sdk is not None:
-                    # 连接球机
-                    ptz = PTZCamera(sdk, ptz_cfg)
+                    # 连接球机,并把组级别的校准文件路径透传给球机
+                    ptz_cfg_with_calib = dict(ptz_cfg)
+                    ptz_cfg_with_calib['calibration_file'] = group.get('calibration_file')
+                    ptz = PTZCamera(sdk, ptz_cfg_with_calib)
                     try:
                         ptz_connected = ptz.connect()
                     except Exception as exc:
@@ -260,6 +298,12 @@ def create_app(test_mode: bool = False) -> FastAPI:
             except Exception as exc:
                 print(f"[lifespan] SDK cleanup error: {exc}")
 
+        for w in cleanup_workers:
+            try:
+                w.stop()
+            except Exception as exc:
+                print(f"[lifespan] cleanup worker stop error: {exc}")
+
     if test_mode:
         # 在测试模式下预先初始化共享状态(不依赖 lifespan,兼容非上下文管理器的 TestClient)
         group_state.reset()

+ 1 - 1
dual_camera_system/config/__init__.py

@@ -10,7 +10,7 @@ from .camera import (
 from .detection import DETECTION_CONFIG
 from .ptz import PTZ_CONFIG
 from .system import SYSTEM_CONFIG
-from .device import DEVICE_CONFIG, THIRD_PARTY_CONFIG
+from .device import DEVICE_CONFIG, THIRD_PARTY_CONFIG, STORAGE_CONFIG
 
 
 # 导出活跃代码实际使用的配置

+ 28 - 2
dual_camera_system/config/device.py

@@ -84,11 +84,37 @@ BATCH_REPORT_CONFIG = {
     'include_ptz_urls': True,       # 包含球机图 OSS URLs
     'include_raw_images': False,    # 是否包含原始图片数据(Base64)
 
-    # 本地保留
-    'keep_local_copy': True,        # 上报后是否保留本地副本
+    # 本地保留(已迁移到 STORAGE_CONFIG,此处保留仅作兼容)
+    'keep_local_copy': False,       # 上报成功后是否删除本地副本(False=删除,省磁盘)
     'local_retention_days': 7,      # 本地保留天数
 }
 
+# 本地图片存储与清理配置
+STORAGE_CONFIG = {
+    'captures': {
+        # 抓拍图片保存根目录(实际会按 group_id 创建子目录)
+        'base_dir': 'data/captures',
+        # 第三方平台上报成功后是否保留本地图片
+        'keep_local_copy': False,
+        # 本地保留天数,超过此时间的文件会被定期清理
+        'retention_days': 7,
+        # 每组目录最大文件数,超过时按修改时间删除最旧的
+        'max_files': 2000,
+        # 清理线程执行间隔(秒)
+        'cleanup_interval_seconds': 3600,
+    },
+    'previews': {
+        # 预览图保存根目录
+        'base_dir': 'data/previews',
+        # 本地保留天数
+        'retention_days': 7,
+        # 每组目录最大文件数
+        'max_files': 1000,
+        # 清理线程执行间隔(秒)
+        'cleanup_interval_seconds': 3600,
+    },
+}
+
 # 配对图片保存配置
 PAIRED_IMAGE_CONFIG = {
     # 本地存储目录

+ 132 - 0
dual_camera_system/core/calibration.py

@@ -0,0 +1,132 @@
+"""加载并应用 PTZ 校准映射."""
+import json
+import logging
+import math
+from bisect import bisect_left
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+
+class CalibrationMapper:
+    """
+    把视觉坐标 (pan, tilt) 映射到球机物理坐标 (device_pan, device_tilt)。
+
+    支持两种校准文件格式:
+    1. 线性/仿射系数格式:
+       pan = pan_offset + pan_scale_x * x_ratio + pan_scale_y * y_ratio
+       tilt = tilt_offset + tilt_scale_x * x_ratio + tilt_scale_y * y_ratio
+    2. 查找表格式:
+       pan_lookup: [[x_ratio, pan_angle], ...]
+       tilt_lookup: [[y_ratio, tilt_angle], ...]
+
+    x_ratio / y_ratio 由视觉角度和视野配置计算:
+       x_ratio = (visual_pan - pan_center) / (pan_range[1] - pan_range[0]) + 0.5
+       y_ratio = (visual_tilt - tilt_center) / (tilt_range[1] - tilt_range[0]) + 0.5
+    """
+
+    def __init__(
+        self,
+        calibration_path: Optional[str],
+        ptz_config: Dict,
+    ):
+        self.path = calibration_path
+        self.data: Optional[Dict] = None
+        self.mount_type: Optional[str] = None
+        self.pan_flip: bool = False
+        self.tilt_flip: bool = False
+
+        # 视野配置
+        self.pan_range = tuple(ptz_config.get("pan_range", (-90, 90)))
+        self.pan_center = float(ptz_config.get("pan_center", 0.0))
+        self.tilt_range = tuple(ptz_config.get("tilt_range", (-5, 20)))
+        self.tilt_center = float(ptz_config.get("tilt_center", 0.0))
+
+        if calibration_path:
+            self._load(calibration_path)
+
+    def is_loaded(self) -> bool:
+        return self.data is not None
+
+    def _load(self, path: str) -> None:
+        try:
+            p = Path(path)
+            if not p.exists():
+                logger.warning("[calibration] 校准文件不存在: %s", path)
+                return
+            self.data = json.loads(p.read_text(encoding="utf-8"))
+            self.mount_type = self.data.get("mount_type")
+            self.pan_flip = bool(self.data.get("pan_flip", False))
+            self.tilt_flip = bool(self.data.get("tilt_flip", False))
+            logger.info("[calibration] 加载校准文件: %s", path)
+        except Exception as exc:
+            logger.error("[calibration] 加载校准文件失败: %s, %s", path, exc)
+            self.data = None
+
+    def _visual_to_ratio(self, pan: float, tilt: float) -> Tuple[float, float]:
+        """视觉角度 -> 归一化坐标 (x_ratio, y_ratio)。"""
+        pan_span = self.pan_range[1] - self.pan_range[0]
+        tilt_span = self.tilt_range[1] - self.tilt_range[0]
+        x = 0.5 if pan_span == 0 else (pan - self.pan_center) / pan_span + 0.5
+        y = 0.5 if tilt_span == 0 else (tilt - self.tilt_center) / tilt_span + 0.5
+        return float(x), float(y)
+
+    @staticmethod
+    def _interpolate(lookup: List[List[float]], ratio: float) -> float:
+        """分段线性插值。"""
+        if not lookup:
+            return float("nan")
+        ratios = [row[0] for row in lookup]
+        values = [row[1] for row in lookup]
+        if ratio <= ratios[0]:
+            return values[0]
+        if ratio >= ratios[-1]:
+            return values[-1]
+        idx = bisect_left(ratios, ratio)
+        x0, x1 = ratios[idx - 1], ratios[idx]
+        y0, y1 = values[idx - 1], values[idx]
+        if abs(x1 - x0) < 1e-9:
+            return y0
+        return y0 + (y1 - y0) * (ratio - x0) / (x1 - x0)
+
+    def visual_to_device(self, pan: float, tilt: float) -> Tuple[float, float]:
+        """
+        把视觉坐标转换为球机物理坐标。
+
+        Returns:
+            (device_pan, device_tilt)
+        """
+        if not self.data:
+            return float(pan), float(tilt)
+
+        x, y = self._visual_to_ratio(pan, tilt)
+
+        # 优先使用仿射系数
+        pan_offset = self.data.get("pan_offset")
+        pan_scale_x = self.data.get("pan_scale_x")
+        tilt_offset = self.data.get("tilt_offset")
+        tilt_scale_y = self.data.get("tilt_scale_y")
+
+        if (
+            pan_offset is not None
+            and pan_scale_x is not None
+            and tilt_offset is not None
+            and tilt_scale_y is not None
+        ):
+            pan_scale_y = self.data.get("pan_scale_y", 0.0) or 0.0
+            tilt_scale_x = self.data.get("tilt_scale_x", 0.0) or 0.0
+            device_pan = pan_offset + pan_scale_x * x + pan_scale_y * y
+            device_tilt = tilt_offset + tilt_scale_x * x + tilt_scale_y * y
+            return float(device_pan), float(device_tilt)
+
+        # 回退到查找表
+        pan_lookup = self.data.get("pan_lookup", [])
+        tilt_lookup = self.data.get("tilt_lookup", [])
+        device_pan = self._interpolate(pan_lookup, x)
+        device_tilt = self._interpolate(tilt_lookup, y)
+        if math.isnan(device_pan):
+            device_pan = pan
+        if math.isnan(device_tilt):
+            device_tilt = tilt
+        return float(device_pan), float(device_tilt)

+ 202 - 0
dual_camera_system/core/file_cleanup.py

@@ -0,0 +1,202 @@
+"""本地图片文件清理工具."""
+import logging
+import os
+import threading
+import time
+from pathlib import Path
+from typing import Callable, List, Optional
+
+logger = logging.getLogger(__name__)
+
+
+def _file_mtime(path: Path) -> float:
+    """获取文件修改时间,失败返回 0。"""
+    try:
+        return path.stat().st_mtime
+    except OSError:
+        return 0.0
+
+
+def cleanup_directory(
+    directory: str,
+    retention_days: int,
+    max_files: Optional[int] = None,
+    pattern: str = "*",
+    now: Optional[float] = None,
+) -> int:
+    """
+    清理目录中的过期文件。
+
+    规则:
+    1. 删除修改时间超过 retention_days 的文件。
+    2. 如果指定 max_files 且文件总数超过,删除最旧的文件直到数量符合限制。
+
+    Args:
+        directory: 要清理的目录。
+        retention_days: 保留天数。
+        max_files: 最大文件数,None 表示不限制。
+        pattern: 匹配模式,默认匹配所有文件。
+        now: 当前时间戳,默认 time.time()。
+
+    Returns:
+        删除的文件数量。
+    """
+    if retention_days <= 0 and max_files is None:
+        return 0
+
+    dir_path = Path(directory)
+    if not dir_path.exists():
+        return 0
+
+    now = now or time.time()
+    cutoff = now - retention_days * 86400.0
+
+    try:
+        files = [p for p in dir_path.rglob(pattern) if p.is_file()]
+    except OSError as exc:
+        logger.warning("[cleanup] 扫描目录失败: %s, %s", directory, exc)
+        return 0
+
+    deleted = 0
+    for p in files:
+        mtime = _file_mtime(p)
+        if retention_days > 0 and mtime < cutoff:
+            try:
+                p.unlink()
+                deleted += 1
+                logger.debug("[cleanup] 删除过期文件: %s", p)
+            except OSError as exc:
+                logger.warning("[cleanup] 删除文件失败: %s, %s", p, exc)
+
+    # 如果仍然超过最大数量,按修改时间删除最旧的
+    if max_files is not None and max_files > 0:
+        try:
+            remaining = sorted(
+                [p for p in dir_path.rglob(pattern) if p.is_file()],
+                key=_file_mtime,
+            )
+        except OSError:
+            remaining = []
+        while len(remaining) > max_files:
+            oldest = remaining.pop(0)
+            try:
+                oldest.unlink()
+                deleted += 1
+                logger.debug("[cleanup] 删除超量旧文件: %s", oldest)
+            except OSError as exc:
+                logger.warning("[cleanup] 删除文件失败: %s, %s", oldest, exc)
+                break
+
+    if deleted:
+        logger.info("[cleanup] %s 清理完成,删除 %d 个文件", directory, deleted)
+    return deleted
+
+
+class CleanupWorker:
+    """定时清理 worker,每个实例管理一个目录。"""
+
+    def __init__(
+        self,
+        directory: str,
+        retention_days: int,
+        max_files: Optional[int] = None,
+        interval_seconds: float = 3600.0,
+        pattern: str = "*",
+    ):
+        self.directory = directory
+        self.retention_days = retention_days
+        self.max_files = max_files
+        self.interval_seconds = interval_seconds
+        self.pattern = pattern
+        self._stop_event = threading.Event()
+        self._thread: Optional[threading.Thread] = None
+
+    def start(self) -> None:
+        if self._thread is not None and self._thread.is_alive():
+            return
+        self._stop_event.clear()
+        self._thread = threading.Thread(target=self._loop, daemon=True)
+        self._thread.start()
+        logger.info(
+            "[cleanup] worker 启动: %s, retention=%d天, max=%s, interval=%.0fs",
+            self.directory,
+            self.retention_days,
+            self.max_files,
+            self.interval_seconds,
+        )
+
+    def stop(self) -> None:
+        self._stop_event.set()
+        if self._thread is not None:
+            self._thread.join(timeout=2.0)
+
+    def run_once(self) -> int:
+        return cleanup_directory(
+            self.directory,
+            self.retention_days,
+            self.max_files,
+            self.pattern,
+        )
+
+    def _loop(self) -> None:
+        while not self._stop_event.is_set():
+            try:
+                self.run_once()
+            except Exception as exc:
+                logger.error("[cleanup] 清理异常: %s", exc)
+            # 等待下一次清理或停止信号
+            self._stop_event.wait(timeout=self.interval_seconds)
+
+
+def make_cleanup_workers(
+    storage_config: dict,
+    group_ids: List[str],
+    base_path: str = ".",
+) -> List[CleanupWorker]:
+    """
+    根据 STORAGE_CONFIG 为 captures 和 previews 创建清理 worker。
+
+    Args:
+        storage_config: STORAGE_CONFIG 字典。
+        group_ids: 摄像头组 ID 列表,用于为 captures 每组创建 worker。
+        base_path: 项目根目录,相对路径基于此。
+
+    Returns:
+        CleanupWorker 列表。
+    """
+    workers: List[CleanupWorker] = []
+
+    captures_cfg = storage_config.get("captures", {})
+    captures_base = captures_cfg.get("base_dir", "data/captures")
+    captures_retention = captures_cfg.get("retention_days", 7)
+    captures_max = captures_cfg.get("max_files")
+    captures_interval = captures_cfg.get("cleanup_interval_seconds", 3600)
+
+    for gid in group_ids:
+        workers.append(
+            CleanupWorker(
+                directory=os.path.join(base_path, captures_base, gid),
+                retention_days=captures_retention,
+                max_files=captures_max,
+                interval_seconds=captures_interval,
+                pattern="*",
+            )
+        )
+
+    previews_cfg = storage_config.get("previews", {})
+    previews_base = previews_cfg.get("base_dir", "data/previews")
+    previews_retention = previews_cfg.get("retention_days", 7)
+    previews_max = previews_cfg.get("max_files")
+    previews_interval = previews_cfg.get("cleanup_interval_seconds", 3600)
+
+    workers.append(
+        CleanupWorker(
+            directory=os.path.join(base_path, previews_base),
+            retention_days=previews_retention,
+            max_files=previews_max,
+            interval_seconds=previews_interval,
+            pattern="*",
+        )
+    )
+
+    return workers

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


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


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


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


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


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


+ 5 - 0
dual_camera_system/ptz_camera.py

@@ -18,6 +18,7 @@ import cv2
 import numpy as np
 
 from config import PTZ_CAMERA, PTZ_CONFIG
+from core.calibration import CalibrationMapper
 from dahua_sdk import DahuaSDK, PTZCommand
 from video_lock import safe_read, safe_is_opened
 
@@ -69,6 +70,10 @@ class PTZCamera:
         self.stream_thread = None
         self.running_stream = False
         self._camera_id = 'ptz'  # 用于per-camera锁
+
+        # 加载校准映射(如果配置中提供了校准文件)
+        calibration_path = self.config.get('calibration_file') or camera_config.get('calibration_file') if camera_config else None
+        self.calibration = CalibrationMapper(calibration_path, self.ptz_config)
     
     def connect(self) -> bool:
         """

+ 1 - 1
dual_camera_system/scripts/start.sh

@@ -27,7 +27,7 @@ LOG_DIR="/home/admin/dsh/logs"
 LOG_FILE="${LOG_DIR}/dual-camera.log"
 
 # 启动参数
-START_ARGS="--skip-calibration"
+START_ARGS=""
 
 # ============================================================
 # 环境设置

+ 85 - 0
dual_camera_system/tests/test_calibration.py

@@ -0,0 +1,85 @@
+import json
+import math
+
+import pytest
+
+from core.calibration import CalibrationMapper
+
+
+def test_calibration_mapper_offset_scale(tmp_path):
+    calib = {
+        "pan_offset": 200.0,
+        "pan_scale_x": 104.0,
+        "pan_scale_y": 0.0,
+        "tilt_offset": -10.0,
+        "tilt_scale_x": 0.0,
+        "tilt_scale_y": 30.0,
+    }
+    path = tmp_path / "calib.json"
+    path.write_text(json.dumps(calib), encoding="utf-8")
+
+    mapper = CalibrationMapper(
+        str(path),
+        {"pan_range": (-90, 90), "pan_center": 0, "tilt_range": (-5, 20), "tilt_center": 7},
+    )
+    assert mapper.is_loaded()
+
+    # visual pan=0 (center, x=0.5) -> pan=200+104*0.5=252
+    device_pan, device_tilt = mapper.visual_to_device(0, 7)
+    assert device_pan == pytest.approx(252.0)
+    # visual tilt=7 (center, y=0.5) -> tilt=-10+30*0.5=5
+    assert device_tilt == pytest.approx(5.0)
+
+    # visual pan=30 (x=0.6667) -> pan=200+104*0.6667=269.3
+    device_pan, _ = mapper.visual_to_device(30, 7)
+    assert device_pan == pytest.approx(269.333, abs=0.01)
+
+
+def test_calibration_mapper_lookup(tmp_path):
+    calib = {
+        "pan_lookup": [[0.0, 200.0], [0.5, 250.0], [1.0, 300.0]],
+        "tilt_lookup": [[0.0, 10.0], [0.5, 0.0], [1.0, -10.0]],
+    }
+    path = tmp_path / "calib.json"
+    path.write_text(json.dumps(calib), encoding="utf-8")
+
+    mapper = CalibrationMapper(
+        str(path),
+        {"pan_range": (-90, 90), "pan_center": 0, "tilt_range": (-5, 20), "tilt_center": 7},
+    )
+    # visual pan=0 -> x=0.5 -> pan=250
+    device_pan, device_tilt = mapper.visual_to_device(0, 7)
+    assert device_pan == pytest.approx(250.0)
+    assert device_tilt == pytest.approx(0.0)
+
+
+def test_calibration_mapper_missing_file():
+    mapper = CalibrationMapper(
+        "/nonexistent/calib.json",
+        {"pan_range": (-90, 90), "pan_center": 0, "tilt_range": (-5, 20), "tilt_center": 7},
+    )
+    assert not mapper.is_loaded()
+    device_pan, device_tilt = mapper.visual_to_device(30, 5)
+    assert device_pan == pytest.approx(30.0)
+    assert device_tilt == pytest.approx(5.0)
+
+
+def test_calibration_mapper_flips_and_mount_type(tmp_path):
+    calib = {
+        "pan_offset": 200.0,
+        "pan_scale_x": 100.0,
+        "pan_scale_y": 0.0,
+        "tilt_offset": 0.0,
+        "tilt_scale_x": 0.0,
+        "tilt_scale_y": 50.0,
+        "mount_type": "ceiling",
+        "pan_flip": True,
+        "tilt_flip": False,
+    }
+    path = tmp_path / "calib.json"
+    path.write_text(json.dumps(calib), encoding="utf-8")
+
+    mapper = CalibrationMapper(str(path), {})
+    assert mapper.mount_type == "ceiling"
+    assert mapper.pan_flip is True
+    assert mapper.tilt_flip is False

+ 74 - 0
dual_camera_system/tests/test_file_cleanup.py

@@ -0,0 +1,74 @@
+import os
+import time
+from pathlib import Path
+
+import pytest
+
+from core.file_cleanup import cleanup_directory, CleanupWorker
+
+
+def test_cleanup_directory_deletes_old_files(tmp_path: Path):
+    old = tmp_path / "old.jpg"
+    new = tmp_path / "new.jpg"
+    old.write_text("old")
+    new.write_text("new")
+
+    # 让 old 文件过期
+    old_mtime = time.time() - 8 * 86400
+    os.utime(old, (old_mtime, old_mtime))
+
+    deleted = cleanup_directory(str(tmp_path), retention_days=7, now=time.time())
+    assert deleted == 1
+    assert not old.exists()
+    assert new.exists()
+
+
+def test_cleanup_directory_respects_max_files(tmp_path: Path):
+    files = []
+    for i in range(5):
+        p = tmp_path / f"img_{i}.jpg"
+        p.write_text("x")
+        files.append(p)
+        time.sleep(0.01)
+
+    deleted = cleanup_directory(str(tmp_path), retention_days=0, max_files=2)
+    assert deleted == 3
+    assert len([p for p in tmp_path.iterdir() if p.is_file()]) == 2
+
+
+def test_cleanup_directory_ignores_nonexistent():
+    deleted = cleanup_directory("/nonexistent/path", retention_days=7)
+    assert deleted == 0
+
+
+def test_cleanup_worker_runs_and_stops(tmp_path: Path):
+    f = tmp_path / "a.jpg"
+    f.write_text("x")
+    old_mtime = time.time() - 8 * 86400
+    os.utime(f, (old_mtime, old_mtime))
+
+    worker = CleanupWorker(
+        directory=str(tmp_path),
+        retention_days=7,
+        interval_seconds=0.1,
+    )
+    worker.start()
+    time.sleep(0.3)
+    worker.stop()
+    assert not f.exists()
+
+
+def test_cleanup_worker_run_once(tmp_path: Path):
+    f = tmp_path / "a.jpg"
+    f.write_text("x")
+    old_mtime = time.time() - 8 * 86400
+    os.utime(f, (old_mtime, old_mtime))
+
+    worker = CleanupWorker(
+        directory=str(tmp_path),
+        retention_days=7,
+        interval_seconds=3600,
+    )
+    deleted = worker.run_once()
+    assert deleted == 1
+    assert not f.exists()

+ 52 - 0
dual_camera_system/tests/test_web_routes.py

@@ -9,6 +9,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 from types import SimpleNamespace
 from unittest.mock import MagicMock
 
+import numpy as np
 import pytest
 from fastapi import HTTPException
 from fastapi.responses import JSONResponse
@@ -20,7 +21,9 @@ from web.routes import (
     _get_state,
     _resolve_panorama_path,
     AddPointPayload,
+    PreviewPayload,
     api_panorama,
+    api_preview,
     api_start_scan,
     api_live,
 )
@@ -217,3 +220,52 @@ def test_add_point_payload_validation(payload, should_raise):
         model = AddPointPayload(**payload)
         assert model.pan == payload["pan"]
         assert model.tilt == payload["tilt"]
+
+
+def test_api_preview_uses_fixed_settle_and_drain(monkeypatch):
+    """preview 不依赖过期的 ptz_position 计算等待/排空,防止拍到旧帧。"""
+    sleeps = []
+
+    def fake_sleep(duration):
+        sleeps.append(duration)
+
+    monkeypatch.setattr(time, "sleep", fake_sleep)
+
+    frame = np.zeros((100, 100, 3), dtype=np.uint8)
+    stream = MagicMock()
+    stream.get_frame.return_value = frame
+    stream_manager = MagicMock()
+    stream_manager.get.return_value = stream
+
+    ptz = MagicMock()
+    ptz.goto_exact_position.return_value = True
+
+    # 故意让上一次目标与本次完全相同;旧实现会因此缩短等待时间
+    group_data = {"ptz_position": {"pan": 30.0, "tilt": 0.0, "zoom": 1}}
+
+    class MockGroupState:
+        def get(self, _gid):
+            return dict(group_data)
+
+        def update(self, _gid, key, value):
+            group_data[key] = value
+
+    state = SimpleNamespace(
+        group_state=MockGroupState(),
+        ptz_cameras={"g1": ptz},
+        stream_manager=stream_manager,
+    )
+    _web_state_module.web_state = state
+
+    monkeypatch.setattr(routes.cv2, "imwrite", lambda _path, _img: True)
+
+    payload = PreviewPayload(pan=30.0, tilt=0.0, zoom=1)
+    resp = api_preview("g1", payload)
+
+    assert resp["position"] == {"pan": 30.0, "tilt": 0.0, "zoom": 1}
+    ptz.goto_exact_position.assert_called_once_with(30.0, 0.0, 1)
+
+    # 应等待完整的稳定时间,并执行固定次数的排空
+    assert sleeps[0] == pytest.approx(3.0, abs=0.01)
+    assert sum(sleeps[1:]) == pytest.approx(20 * 0.15, abs=0.01)
+    assert stream.get_frame.call_count >= 20

+ 59 - 2
dual_camera_system/third_party_pusher.py

@@ -25,6 +25,62 @@ def _normalize_timestamp(ts: float) -> float:
     return ts
 
 
+def _convert_to_legacy_batch_info(new_info: Dict[str, Any]) -> Dict[str, Any]:
+    """
+    把新版 CaptureUploader 生成的 batch_info 转成老版 PairedImageSaver 的字段名。
+
+    老字段包括:
+    - panorama.local_path / panorama.oss_url
+    - total_persons
+    - ptz_images_count
+    - persons(含 person_index、bbox 字典、confidence 等)
+    """
+    normalized_ts = _normalize_timestamp(new_info.get("timestamp", time.time()))
+
+    urls = new_info.get("image_urls") or {}
+    image_paths = new_info.get("image_paths") or []
+    camera_type = new_info.get("camera_type", "panorama")
+
+    # 优先取 marked OSS URL,兼容老代码只读取 panorama.oss_url 的行为
+    oss_url = urls.get("marked") or urls.get("original") or None
+    local_path = image_paths[0] if image_paths else None
+
+    persons = []
+    for i, det in enumerate(new_info.get("detections") or []):
+        bbox = det.get("bbox", [0, 0, 0, 0])
+        person = {
+            "person_index": i,
+            "bbox": {
+                "x1": int(bbox[0]),
+                "y1": int(bbox[1]),
+                "x2": int(bbox[2]),
+                "y2": int(bbox[3]),
+            },
+            "confidence": float(det.get("confidence", 0.0)),
+            "camera_type": det.get("camera_type", camera_type),
+        }
+        ptz_position = new_info.get("ptz_position")
+        if ptz_position:
+            person["ptz_position"] = ptz_position
+        persons.append(person)
+
+    legacy = {
+        "batch_id": new_info.get("batch_id", ""),
+        "device_id": new_info.get("device_id", ""),
+        "project_id": new_info.get("project_id", ""),
+        "timestamp": normalized_ts,
+        "datetime": datetime.fromtimestamp(normalized_ts).isoformat(),
+        "panorama": {
+            "local_path": local_path,
+            "oss_url": oss_url,
+        },
+        "total_persons": len(persons),
+        "ptz_images_count": 1 if camera_type == "ptz" else 0,
+        "persons": persons,
+    }
+    return legacy
+
+
 @dataclass
 class BatchReport:
     """批次上报数据"""
@@ -330,8 +386,9 @@ class ThirdPartyPusher:
                 })
             }
         else:
-            # custom / 其他平台:原样发送 batch_info(snake_case),已包含 image_urls
-            payload = dict(batch_info)
+            # custom / 其他平台:把新版 batch_info 转回老字段名后上报,
+            # 兼容原人体分析平台对 panorama / total_persons / persons 的解析。
+            payload = _convert_to_legacy_batch_info(batch_info)
             # 统一时间戳单位为秒,避免第三方解析错误
             payload['timestamp'] = normalized_ts
 

+ 9 - 21
dual_camera_system/web/routes.py

@@ -1,6 +1,5 @@
 """FastAPI REST 路由."""
 import logging
-import math
 import mimetypes
 import os
 import threading
@@ -18,7 +17,7 @@ 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
+from config.ptz import PTZ_CONFIG
 
 router = APIRouter()
 
@@ -252,22 +251,12 @@ def api_preview(group_id: str, payload: PreviewPayload) -> dict:
     if not ptz:
         raise HTTPException(status_code=404, detail="PTZ camera not connected")
 
-    # 基于上一次目标位置计算本次移动距离,动态决定稳定时间
-    prev_pos = state.group_state.get(group_id).get("ptz_position") or {}
-    prev_pan = float(prev_pos.get("pan", payload.pan))
-    prev_tilt = float(prev_pos.get("tilt", payload.tilt))
-    prev_zoom = int(prev_pos.get("zoom", payload.zoom))
-
-    pan_diff = abs(((payload.pan - prev_pan + 180) % 360) - 180)
-    tilt_diff = abs(payload.tilt - prev_tilt)
-    zoom_diff = abs(payload.zoom - prev_zoom)
-    move_distance = math.sqrt(pan_diff ** 2 + tilt_diff ** 2 + zoom_diff ** 2 * 100)
-
-    base_wait = 1.0
-    per_degree_wait = 0.04
-    min_wait = COORDINATOR_CONFIG.get("ptz_stabilize_time", 1.5)
-    max_wait = 6.0
-    stabilize = min(max_wait, max(min_wait, base_wait + move_distance * per_degree_wait))
+    # 使用与扫描一致的稳定时间,不依赖可能过期的 group_state["ptz_position"]
+    # 扫描/轮询/人工都可能改变球机实际位置,基于上一次目标距离估算等待时间会不可靠
+    capture_cfg = PTZ_CONFIG.get('capture', {})
+    stabilize = float(capture_cfg.get('stabilize_time', 3.0))
+    drain_interval = 0.15
+    drain_count = 20
 
     try:
         ptz.goto_exact_position(payload.pan, payload.tilt, payload.zoom)
@@ -275,20 +264,19 @@ def api_preview(group_id: str, payload: PreviewPayload) -> dict:
         logging.exception("PTZ preview move failed for %s", group_id)
         raise HTTPException(status_code=500, detail=f"PTZ move failed: {exc}") from exc
 
+    # 等待云台物理到位并稳定
     time.sleep(stabilize)
 
     stream = state.stream_manager.get(f"{group_id}_ptz")
     frame = None
     if stream:
         # 排空 RTSP 缓冲中的旧帧,确保取到 PTZ 到位后的新图像
-        drain_count = max(12, int(move_distance * 0.5))
-        drain_count = min(40, drain_count)
         last_frame = None
         for _ in range(drain_count):
             f = stream.get_frame()
             if f is not None:
                 last_frame = f
-            time.sleep(0.15)
+            time.sleep(drain_interval)
         frame = last_frame if last_frame is not None else stream.get_frame()
 
     snapshot_path = None

+ 128 - 187
dual_camera_system/web_static/app.js

@@ -118,230 +118,171 @@ function selectSample(sample) {
 }
 
 class SampleCanvas {
-  constructor(canvasId, wrapperId) {
-    this.canvas = document.getElementById(canvasId);
+  constructor(containerId, wrapperId) {
+    this.container = document.getElementById(containerId);
     this.wrapper = document.getElementById(wrapperId);
-    this.ctx = this.canvas.getContext('2d');
     this.samples = [];
     this.pans = [];
     this.tilts = [];
     this.sampleMap = new Map();
-    this.images = new Map();
     this.cellW = 160;
     this.cellH = 120;
     this.captionH = 20;
     this.scale = 1;
-    this.offsetX = 0;
-    this.offsetY = 0;
     this.selectedPan = null;
     this.selectedTilt = null;
-    this.isDragging = false;
-    this.dragStart = { x: 0, y: 0, ox: 0, oy: 0 };
-    this.pendingDraw = false;
+    this.selectedNodeId = null;
+    this.graph = null;
 
-    this.resize();
+    this.initGraph();
     window.addEventListener('resize', () => this.resize());
-    this.setupEvents();
   }
 
-  resize() {
-    const rect = this.wrapper.getBoundingClientRect();
-    this.canvas.width = rect.width;
-    this.canvas.height = rect.height;
-    this.draw();
-  }
+  initGraph() {
+    const G6 = window.G6;
+    if (!G6) {
+      log('G6 库未加载,扫描矩阵不可用');
+      return;
+    }
 
-  setSamples(samples) {
-    this.samples = samples || [];
-    this.pans = Array.from(new Set(this.samples.map(s => s.pan))).sort((a, b) => a - b);
-    this.tilts = Array.from(new Set(this.samples.map(s => s.tilt))).sort((a, b) => b - a);
-    this.sampleMap = new Map();
-    this.samples.forEach(s => this.sampleMap.set(`${s.pan},${s.tilt}`, s));
-    this.images = new Map();
-    this.samples.forEach(s => {
-      const img = new Image();
-      img.crossOrigin = 'anonymous';
-      img.src = `/api/sample-image?path=${encodeURIComponent(s.thumbnail)}`;
-      img.onload = () => this.draw();
-      this.images.set(`${s.pan},${s.tilt}`, img);
+    const rect = this.wrapper.getBoundingClientRect();
+    const width = rect.width || this.wrapper.clientWidth || 800;
+    const height = rect.height || this.wrapper.clientHeight || 600;
+    this.graph = new G6.Graph({
+      container: this.container,
+      width,
+      height,
+      animation: false,
+      zoomRange: [0.1, 5],
+      data: { nodes: [], edges: [] },
+      node: {
+        type: 'image',
+        style: {
+          size: [this.cellW, this.cellH],
+          cursor: 'pointer',
+          labelFill: '#cbd5e1',
+          labelFontSize: 11,
+          labelPlacement: 'bottom',
+          labelOffsetY: 4,
+          radius: 4,
+        },
+        state: {
+          selected: {
+            halo: true,
+            haloStroke: '#4ade80',
+            haloLineWidth: 6,
+            haloOpacity: 1,
+          },
+        },
+      },
+      layout: {
+        type: 'grid',
+        cols: 1,
+        nodeSize: [this.cellW, this.cellH + this.captionH],
+        preventOverlap: true,
+      },
+      behaviors: ['drag-canvas', 'zoom-canvas'],
     });
-    this.fitToView();
-    this.draw();
-  }
 
-  contentWidth() {
-    return this.pans.length * this.cellW;
-  }
+    this.graph.on('node:click', (e) => {
+      const target = e.item || e.target;
+      const id = target?.id;
+      if (!id) return;
+      const nodeData = this.graph.getNodeData(id);
+      if (nodeData?.data?.sample) {
+        selectSample(nodeData.data.sample);
+      }
+    });
 
-  contentHeight() {
-    return this.tilts.length * (this.cellH + this.captionH);
+    this.graph.on('aftertransform', () => {
+      if (!this.graph) return;
+      this.scale = this.graph.getZoom();
+      this.updateZoomLabel();
+    });
   }
 
-  fitToView() {
-    const pw = this.canvas.width / this.contentWidth();
-    const ph = this.canvas.height / this.contentHeight();
-    this.scale = Math.min(pw, ph, 1);
-    this.offsetX = (this.canvas.width - this.contentWidth() * this.scale) / 2;
-    this.offsetY = (this.canvas.height - this.contentHeight() * this.scale) / 2;
+  async resize() {
+    if (!this.graph) return;
+    const rect = this.wrapper.getBoundingClientRect();
+    this.graph.setSize(rect.width, rect.height);
+    await this.graph.fitView();
+    this.scale = this.graph.getZoom();
     this.updateZoomLabel();
   }
 
-  resetView() {
-    this.fitToView();
-    this.draw();
-  }
-
-  updateZoomLabel() {
-    const label = document.getElementById('zoom-level');
-    if (label) label.textContent = `${Math.round(this.scale * 100)}%`;
-  }
-
-  setupEvents() {
-    this.canvas.addEventListener('wheel', (e) => {
-      e.preventDefault();
-      const rect = this.canvas.getBoundingClientRect();
-      const mx = e.clientX - rect.left;
-      const my = e.clientY - rect.top;
-      const factor = e.deltaY < 0 ? 1.1 : 0.9;
-      const newScale = Math.max(0.1, Math.min(5.0, this.scale * factor));
-      this.offsetX = mx - (mx - this.offsetX) * (newScale / this.scale);
-      this.offsetY = my - (my - this.offsetY) * (newScale / this.scale);
-      this.scale = newScale;
-      this.updateZoomLabel();
-      this.draw();
-    }, { passive: false });
-
-    this.canvas.addEventListener('mousedown', (e) => {
-      if (e.button !== 0) return;
-      this.isDragging = false;
-      this.dragMoved = false;
-      this.dragStart = { x: e.clientX, y: e.clientY, ox: this.offsetX, oy: this.offsetY };
-      this.wrapper.style.cursor = 'grabbing';
-    });
-
-    window.addEventListener('mousemove', (e) => {
-      if (this.dragStart == null) return;
-      const dx = e.clientX - this.dragStart.x;
-      const dy = e.clientY - this.dragStart.y;
-      if (!this.isDragging && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
-        this.isDragging = true;
-        this.dragMoved = true;
-      }
-      if (this.isDragging) {
-        this.offsetX = this.dragStart.ox + dx;
-        this.offsetY = this.dragStart.oy + dy;
-        this.draw();
-      }
-    });
+  async setSamples(samples) {
+    this.samples = samples || [];
+    this.pans = Array.from(new Set(this.samples.map(s => s.pan))).sort((a, b) => a - b);
+    this.tilts = Array.from(new Set(this.samples.map(s => s.tilt))).sort((a, b) => b - a);
+    this.sampleMap = new Map();
+    this.samples.forEach(s => this.sampleMap.set(`${s.pan},${s.tilt}`, s));
+    if (!this.graph) return;
 
-    window.addEventListener('mouseup', () => {
-      this.dragStart = null;
-      if (this.isDragging) {
-        this.isDragging = false;
-        this.wrapper.style.cursor = 'grab';
-      }
+    const nodes = [];
+    this.tilts.forEach(tilt => {
+      this.pans.forEach(pan => {
+        const s = this.sampleMap.get(`${pan},${tilt}`);
+        if (!s) return;
+        const id = `p${pan.toFixed(1)}_t${tilt.toFixed(1)}`;
+        nodes.push({
+          id,
+          data: { sample: s },
+          style: {
+            src: `/api/sample-image?path=${encodeURIComponent(s.thumbnail)}`,
+            labelText: `P:${pan.toFixed(0)} T:${tilt.toFixed(0)}`,
+          },
+        });
+      });
     });
 
-    this.canvas.addEventListener('click', (e) => {
-      if (this.isDragging || this.dragMoved) return;
-      const rect = this.canvas.getBoundingClientRect();
-      const mx = e.clientX - rect.left;
-      const my = e.clientY - rect.top;
-      const worldX = (mx - this.offsetX) / this.scale;
-      const worldY = (my - this.offsetY) / this.scale;
-      const rowH = this.cellH + this.captionH;
-      // 使用最近单元格,避免边界处 floor 导致点不到
-      const col = Math.round((worldX - this.cellW / 2) / this.cellW);
-      const row = Math.round((worldY - rowH / 2) / rowH);
-      if (col < 0 || col >= this.pans.length || row < 0 || row >= this.tilts.length) return;
-      const pan = this.pans[col];
-      const tilt = this.tilts[row];
-      const s = this.sampleMap.get(`${pan},${tilt}`);
-      if (s) {
-        selectSample(s);
-      }
+    this.graph.setLayout({
+      type: 'grid',
+      cols: this.pans.length || 1,
+      nodeSize: [this.cellW, this.cellH + this.captionH],
+      preventOverlap: true,
     });
+    this.graph.setData({ nodes, edges: [] });
+    await this.graph.render();
+    if (nodes.length > 0) await this.graph.fitView();
+    this.selectedNodeId = null;
+    this.selectedPan = null;
+    this.selectedTilt = null;
+    this.scale = this.graph.getZoom();
+    this.updateZoomLabel();
   }
 
   setSelected(pan, tilt) {
+    if (!this.graph) return;
+    const prevId = this.selectedNodeId;
     this.selectedPan = pan;
     this.selectedTilt = tilt;
-    this.draw();
+    this.selectedNodeId = pan !== null && tilt !== null
+      ? `p${pan.toFixed(1)}_t${tilt.toFixed(1)}`
+      : null;
+
+    const states = {};
+    if (prevId && prevId !== this.selectedNodeId) states[prevId] = [];
+    if (this.selectedNodeId) states[this.selectedNodeId] = ['selected'];
+    if (Object.keys(states).length > 0) {
+      this.graph.setElementState(states, false);
+    }
   }
 
-  draw() {
-    if (this.pendingDraw) return;
-    this.pendingDraw = true;
-    requestAnimationFrame(() => {
-      this.pendingDraw = false;
-      this._draw();
-    });
+  async resetView() {
+    if (!this.graph) return;
+    await this.graph.fitView();
+    this.scale = this.graph.getZoom();
+    this.updateZoomLabel();
   }
 
-  _draw() {
-    const ctx = this.ctx;
-    const w = this.canvas.width;
-    const h = this.canvas.height;
-    ctx.clearRect(0, 0, w, h);
-
-    if (this.samples.length === 0) {
-      ctx.fillStyle = '#94a3b8';
-      ctx.font = '14px sans-serif';
-      ctx.fillText('暂无扫描样本,请先执行 360° 扫描', 20, 30);
-      return;
-    }
-
-    const rowH = this.cellH + this.captionH;
-    const startCol = Math.floor((-this.offsetX / this.scale) / this.cellW);
-    const endCol = Math.ceil((w - this.offsetX) / this.scale / this.cellW);
-    const startRow = Math.floor((-this.offsetY / this.scale) / rowH);
-    const endRow = Math.ceil((h - this.offsetY) / this.scale / rowH);
-
-    ctx.save();
-    ctx.translate(this.offsetX, this.offsetY);
-    ctx.scale(this.scale, this.scale);
-
-    for (let r = Math.max(0, startRow); r <= Math.min(this.tilts.length - 1, endRow); r++) {
-      for (let c = Math.max(0, startCol); c <= Math.min(this.pans.length - 1, endCol); c++) {
-        const pan = this.pans[c];
-        const tilt = this.tilts[r];
-        const x = c * this.cellW;
-        const y = r * rowH;
-        const s = this.sampleMap.get(`${pan},${tilt}`);
-
-        ctx.fillStyle = '#0f172a';
-        ctx.fillRect(x, y, this.cellW, rowH);
-
-        const img = this.images.get(`${pan},${tilt}`);
-        if (img && img.complete && img.naturalWidth) {
-          const sx = 0, sy = 0, sw = img.naturalWidth, sh = img.naturalHeight;
-          const dw = this.cellW - 4;
-          const dh = this.cellH - 4;
-          const scale = Math.min(dw / sw, dh / sh);
-          const iw = sw * scale;
-          const ih = sh * scale;
-          const ix = x + 2 + (dw - iw) / 2;
-          const iy = y + 2 + (dh - ih) / 2;
-          ctx.drawImage(img, ix, iy, iw, ih);
-        } else {
-          ctx.fillStyle = '#1e293b';
-          ctx.fillRect(x + 2, y + 2, this.cellW - 4, this.cellH - 4);
-        }
-
-        ctx.fillStyle = '#cbd5e1';
-        ctx.font = '11px sans-serif';
-        ctx.textAlign = 'center';
-        ctx.fillText(`P:${pan.toFixed(0)} T:${tilt.toFixed(0)}`, x + this.cellW / 2, y + this.cellH + 14);
-
-        if (this.selectedPan === pan && this.selectedTilt === tilt) {
-          ctx.strokeStyle = '#22c55e';
-          ctx.lineWidth = 2;
-          ctx.strokeRect(x + 1, y + 1, this.cellW - 2, rowH - 2);
-        }
-      }
-    }
+  updateZoomLabel() {
+    const label = document.getElementById('zoom-level');
+    if (label) label.textContent = `${Math.round(this.scale * 100)}%`;
+  }
 
-    ctx.restore();
+  draw() {
+    if (!this.graph) return;
+    this.graph.zoomTo(this.scale);
   }
 }
 
@@ -353,7 +294,7 @@ async function loadSamples(groupId) {
     if (!sampleCanvas) {
       sampleCanvas = new SampleCanvas('sample-canvas', 'sample-grid-wrapper');
     }
-    sampleCanvas.setSamples(data.samples || []);
+    await sampleCanvas.setSamples(data.samples || []);
   } catch (e) {
     log(`加载扫描样本失败: ${e.message}`);
   }

+ 3 - 2
dual_camera_system/web_static/index.html

@@ -27,7 +27,7 @@
             </span>
           </div>
           <div id="sample-grid-wrapper">
-            <canvas id="sample-canvas"></canvas>
+            <div id="sample-canvas"></div>
           </div>
         </div>
         <div id="point-list">
@@ -46,6 +46,7 @@
         </div>
     </div>
     <div id="log-panel"></div>
-    <script type="module" src="/static/app.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/@antv/g6@5.1.1/dist/g6.min.js"></script>
+    <script type="module" src="/static/app.js?v=4"></script>
 </body>
 </html>