Explorar o código

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

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

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


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


BIN=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=BIN
dual_camera_system/data/previews/group_1/preview_1781671206065.jpg


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


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


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


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


BIN=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>