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

feat: add OSS upload, optimize PTZ preview & scan logic, update docs

- add OSS compatible uploader for image hosting
- optimize PTZ preview and 360 scan logic with dynamic stabilize time and frame draining
- fix test case tilt range from ±90 to ±60
- update device docs and add configuration notes
- fix canvas drag and click handling in web frontend
- update third party pusher to support OSS URLs and normalized timestamps
wenhongquan 22 часов назад
Родитель
Сommit
e1e5c69092

+ 2 - 0
AGENTS.md

@@ -179,6 +179,8 @@ python main.py --demo
 11. **本地测试**: 在无法加载 Dahua SDK 的环境(如 macOS),可运行 `python scripts/local_test.py` 验证 RTSP、检测与 PTZ 角度计算
 12. **PTZ 连接失败处理**: `app.py` 会检查 `ptz.connect()` 返回值;连接失败时记录错误,跳过该组的球机流/扫描器/调度器,并在 group_state 中设置 `ptz_connected: false`,枪机流与检测线程仍尽可能保留
 13. **线程安全检测**: `core/detector_service.py` 使用 `threading.Lock()` 保护 `detect()`,避免多摄像头检测线程并发推理导致崩溃
+14. **预览接口稳定策略**: `/api/preview/{group_id}` 已改为根据与上次目标位置的移动距离动态计算稳定时间(1.5–6 秒),并在 PTZ 到位后排空 RTSP 缓冲旧帧(最多 40 帧),避免大角度移动时拍到未到位画面。该行为与 `core/spatial_scanner.py` 的扫描稳定逻辑保持一致
+15. **配置生效方式**: `config/camera.py` 中的 `mount_type`/`pan_flip`/`tilt_flip` 等参数在进程启动时读入 `PTZCamera`,修改后必须重启 `main.py` 才能生效;扫描样本也会随配置变化而失效,需要重新执行 360° 扫描
 
 ---
 

+ 4 - 3
devices.md

@@ -17,7 +17,7 @@
 | 设备类型 | 品牌 | IP | 用户/密码 | 分辨率 | 特殊参数 | 说明 |
 |----------|------|----|-----------|--------|----------|------|
 | 枪机 | 大华 | 192.168.20.196 | admin / Aa1234567 | 3840*1080 | - | 宽视野人员检测 |
-| 球机 | 大华 | 192.168.20.197 | admin / Aa1234567 | 3840*2160 | pan_flip:False <br> ceiling_mount:False | PTZ 云台控制抓拍 |
+| 球机 | 大华 | 192.168.20.197 | admin / Aa1234567 | 3840*2160 | mount_type:ceiling <br> pan_flip:False <br> tilt_flip:False | PTZ 云台控制抓拍(吊装,镜头朝下) |
 
 ## 接入地址与操作指令
 ### RTSP 流地址
@@ -57,6 +57,7 @@ ssh admin@10.126.126.1
 ### 补充说明
 1. 正式环境大华枪机RTSP地址按大华通用格式补充,若实际地址有差异请按现场配置调整;
 2. 特殊参数说明:
-   - `pan_flip`:云台水平翻转开关,用于适配不同安装朝向的画面矫正;
-   - `ceiling_mount`:吸顶安装标识,True表示球机为倒挂安装模式,False为正装模式;
+   - `mount_type`:球机安装方式,`wall` 为壁装/立杆(镜头水平/朝上),`ceiling` 为吸顶/吊装(镜头朝下);
+   - `pan_flip`:云台水平翻转开关,True 表示球机水平运动方向与视觉方向相反;
+   - `tilt_flip`:云台俯仰翻转开关,True 表示球机俯仰运动方向与视觉方向相反;
 3. 所有设备默认端口:SSH(22)、RTSP(554),若网络环境变更需同步更新端口配置。

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


+ 13 - 1
dual_camera_system/app.py

@@ -22,6 +22,7 @@ from core.polling_scheduler import PollingScheduler
 from core.capture_uploader import CaptureUploader
 from core.detector_service import DetectorService
 from core.group_state import group_state
+from core.oss_uploader import OSSUploader
 from web.routes import router
 from web.state import WebState
 import web.state as _web_state_module
@@ -57,6 +58,7 @@ def create_app(test_mode: bool = False) -> FastAPI:
         pusher = None
         sdk = None
         detector_service = None
+        oss_uploader = None
 
         if not test_mode:
             if SYSTEM_CONFIG.get("enable_detection", True):
@@ -73,6 +75,12 @@ def create_app(test_mode: bool = False) -> FastAPI:
 
             if SYSTEM_CONFIG.get("enable_event_push", False):
                 try:
+                    oss_uploader = OSSUploader()
+                except Exception as exc:
+                    print(f"[lifespan] OSS uploader init failed: {exc}")
+                    oss_uploader = None
+
+                try:
                     pusher = get_third_party_pusher()
                     if pusher and not pusher.running:
                         pusher.start()
@@ -142,7 +150,11 @@ def create_app(test_mode: bool = False) -> FastAPI:
                         print(f"[lifespan] PTZ connect failed for group {gid}; skipping PTZ features")
 
                 # 创建上传器(即使球机关闭,枪机检测仍可能使用)
-                uploader = CaptureUploader(gid, upload_callback=pusher.report_batch if pusher else None)
+                uploader = CaptureUploader(
+                    gid,
+                    upload_callback=pusher.report_batch if pusher else None,
+                    oss_uploader=oss_uploader,
+                )
 
                 if SYSTEM_CONFIG.get("enable_detection", True) and detector_service is not None:
                     def panorama_detect_loop(g=gid, uploader=uploader, detector=detector_service):

+ 11 - 0
dual_camera_system/core/capture_uploader.py

@@ -9,6 +9,7 @@ import cv2
 import numpy as np
 
 from config.device import DEVICE_CONFIG
+from core.oss_uploader import OSSUploader
 
 logger = logging.getLogger(__name__)
 
@@ -19,12 +20,14 @@ class CaptureUploader:
         group_id: str,
         save_dir: str = "data/captures",
         upload_callback: Optional[Callable[[Dict], None]] = None,
+        oss_uploader: Optional[OSSUploader] = None,
         dedup_seconds: float = 5.0,
     ):
         self.group_id = group_id
         self.save_dir = os.path.join(save_dir, group_id)
         os.makedirs(self.save_dir, exist_ok=True)
         self.upload_callback = upload_callback
+        self.oss_uploader = oss_uploader
         self.dedup_seconds = dedup_seconds
         self._last_uploads: List[Dict[str, Any]] = []
         self._lock = threading.Lock()
@@ -154,13 +157,21 @@ class CaptureUploader:
             }
             results.append(payload)
 
+        # 上传图片到 OSS(如启用)
+        image_urls = {}
+        if self.oss_uploader is not None and self.oss_uploader.enabled:
+            image_urls = self.oss_uploader.upload_pair(original_path, marked_path)
+            logger.info("OSS URLs for %s: %s", camera_type, image_urls)
+
         if self.upload_callback and results:
             batch_info = {
                 "batch_id": str(uuid.uuid4()),
                 "device_id": DEVICE_CONFIG.get("device_id", "unknown"),
                 "project_id": DEVICE_CONFIG.get("project_id", ""),
                 "timestamp": ts,
+                "camera_type": camera_type,
                 "image_paths": [original_path, marked_path],
+                "image_urls": image_urls,
                 "detections": [
                     {"bbox": det["bbox"], "confidence": det["confidence"], "camera_type": camera_type}
                     for det in to_upload

+ 105 - 0
dual_camera_system/core/oss_uploader.py

@@ -0,0 +1,105 @@
+"""兼容 S3 的对象存储上传模块."""
+import logging
+import os
+import time
+import uuid
+from pathlib import Path
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+class OSSUploader:
+    """基于 boto3 的 S3 兼容对象存储上传器."""
+
+    def __init__(self, config: Optional[dict] = None):
+        from config.oss import S3_COMPATIBLE_CONFIG
+
+        self.config = config or S3_COMPATIBLE_CONFIG
+        self.enabled = self.config.get("enabled", False)
+        self.client = None
+        self.bucket = self.config.get("bucket_name", "")
+        self.path_prefix = self.config.get("path_prefix", "device")
+        self.custom_domain = self.config.get("custom_domain", "")
+        self.endpoint_url = self.config.get("endpoint_url", "")
+        self.retry_times = self.config.get("retry_times", 3)
+        self.upload_timeout = self.config.get("upload_timeout", 30)
+
+        if self.enabled:
+            try:
+                import boto3
+                from botocore.config import Config
+
+                s3_config = Config(
+                    s3={"addressing_style": "path"},
+                    signature_version="s3v4",
+                    retries={"max_attempts": self.retry_times, "mode": "standard"},
+                )
+                self.client = boto3.client(
+                    "s3",
+                    endpoint_url=self.endpoint_url,
+                    region_name=self.config.get("region_name", "us-east-1"),
+                    aws_access_key_id=self.config.get("access_key_id", ""),
+                    aws_secret_access_key=self.config.get("secret_access_key", ""),
+                    config=s3_config,
+                    verify=False,
+                )
+                logger.info("[OSS] 上传器初始化完成: %s/%s", self.endpoint_url, self.bucket)
+            except Exception as exc:
+                logger.error("[OSS] 初始化失败: %s", exc)
+                self.enabled = False
+
+    def _object_key(self, local_path: str, suffix: str = "") -> str:
+        """生成对象存储 key."""
+        filename = Path(local_path).name
+        if suffix:
+            stem = Path(filename).stem
+            ext = Path(filename).suffix
+            filename = f"{stem}_{suffix}{ext}"
+        date_dir = time.strftime("%Y%m%d")
+        unique = uuid.uuid4().hex[:8]
+        return f"{self.path_prefix}/{date_dir}/{unique}_{filename}"
+
+    def _make_url(self, key: str) -> str:
+        """根据 endpoint 或自定义域名生成访问 URL."""
+        if self.custom_domain:
+            base = self.custom_domain.rstrip("/")
+            return f"{base}/{key}"
+        base = self.endpoint_url.rstrip("/")
+        return f"{base}/{self.bucket}/{key}"
+
+    def upload(self, local_path: str, suffix: str = "") -> Optional[str]:
+        """上传单个文件到 OSS,返回可访问 URL."""
+        if not self.enabled or self.client is None:
+            return None
+
+        if not os.path.isfile(local_path):
+            logger.warning("[OSS] 文件不存在: %s", local_path)
+            return None
+
+        key = self._object_key(local_path, suffix)
+        for attempt in range(1, self.retry_times + 1):
+            try:
+                self.client.upload_file(
+                    local_path,
+                    self.bucket,
+                    key,
+                    ExtraArgs={"ContentType": "image/jpeg"},
+                )
+                url = self._make_url(key)
+                logger.info("[OSS] 上传成功: %s -> %s", local_path, url)
+                return url
+            except Exception as exc:
+                logger.warning("[OSS] 上传失败 (尝试 %d/%d): %s - %s", attempt, self.retry_times, local_path, exc)
+                if attempt < self.retry_times:
+                    time.sleep(0.5 * attempt)
+
+        logger.error("[OSS] 上传最终失败: %s", local_path)
+        return None
+
+    def upload_pair(self, original_path: str, marked_path: str) -> dict:
+        """上传原图和标注图,返回 URL 字典."""
+        return {
+            "original": self.upload(original_path, suffix="original"),
+            "marked": self.upload(marked_path, suffix="marked"),
+        }

+ 72 - 4
dual_camera_system/core/spatial_scanner.py

@@ -1,5 +1,6 @@
 """球机 360° 扫描与全景图生成."""
 import logging
+import math
 import os
 import time
 from typing import Callable, Dict, List, Optional, Tuple
@@ -18,7 +19,7 @@ class SpatialScanner:
         ptz_camera,
         ptz_frame_source: Callable[[], Optional[np.ndarray]],
         data_dir: str = "data",
-        stabilize_time: float = 1.5,
+        stabilize_time: float = 3.0,
     ):
         self.group_id = group_id
         self.ptz = ptz_camera
@@ -62,14 +63,15 @@ class SpatialScanner:
         if progress_callback:
             progress_callback(dict(self.progress))
         samples: List[Dict] = []
+        prev_pan, prev_tilt, prev_zoom = 0.0, 0.0, 1
 
         for idx, (pan, tilt) in enumerate(grid):
             if self.cancelled:
                 break
             try:
-                self.ptz.goto_exact_position(pan, tilt, zoom)
-                time.sleep(self.stabilize_time)
-                frame = self._wait_frame(timeout=5.0)
+                frame = self._capture_at_position(
+                    pan, tilt, zoom, prev_pan, prev_tilt, prev_zoom
+                )
                 if frame is None:
                     logger.warning("No frame for pan=%s tilt=%s", pan, tilt)
                     continue
@@ -83,6 +85,7 @@ class SpatialScanner:
                     "zoom": zoom,
                     "thumbnail": path,
                 })
+                prev_pan, prev_tilt, prev_zoom = pan, tilt, zoom
                 self.progress["current"] = len(samples)
                 if progress_callback:
                     progress_callback(dict(self.progress))
@@ -108,6 +111,71 @@ class SpatialScanner:
             time.sleep(0.1)
         return None
 
+    def _drain_frames(self, count: int = 8, interval: float = 0.1) -> Optional[np.ndarray]:
+        """排空旧帧缓冲,返回最后一帧."""
+        last_frame = None
+        for _ in range(count):
+            frame = self.get_ptz_frame()
+            if frame is not None:
+                last_frame = frame
+            time.sleep(interval)
+        return last_frame
+
+    def _capture_at_position(
+        self,
+        pan: float,
+        tilt: float,
+        zoom: int,
+        prev_pan: float,
+        prev_tilt: float,
+        prev_zoom: int,
+    ) -> Optional[np.ndarray]:
+        """移动 PTZ 到目标位置并等待稳定后抓取一帧。
+
+        根据与上一位置的距离动态计算等待时间,并排空 RTSP 缓冲。
+        """
+        pan_diff = abs(((pan - prev_pan + 180) % 360) - 180)
+        tilt_diff = abs(tilt - prev_tilt)
+        zoom_diff = abs(zoom - prev_zoom)
+        move_distance = math.sqrt(pan_diff ** 2 + tilt_diff ** 2 + zoom_diff ** 2 * 100)
+
+        # 云台典型速度:pan ~90°/s, tilt ~60°/s,按最远距离留 1.5 倍余量
+        base_wait = 1.0
+        per_degree_wait = 0.04
+        min_wait = self.stabilize_time
+        max_wait = 6.0
+        stabilize = min(max_wait, max(min_wait, base_wait + move_distance * per_degree_wait))
+
+        logger.info(
+            "[SpatialScanner] move from (%.1f, %.1f, %d) to (%.1f, %.1f, %d), "
+            "distance=%.1f, stabilize=%.2fs",
+            prev_pan, prev_tilt, prev_zoom, pan, tilt, zoom, move_distance, stabilize,
+        )
+
+        t0 = time.time()
+        if not self.ptz.goto_exact_position(pan, tilt, zoom):
+            logger.warning("[SpatialScanner] goto_exact_position failed for (%.1f, %.1f, %d)", pan, tilt, zoom)
+
+        # 等待云台物理到位
+        elapsed = time.time() - t0
+        if elapsed < stabilize:
+            time.sleep(stabilize - elapsed)
+
+        # 排空旧帧:根据移动距离增加排空帧数,确保拿到新位置图像
+        drain_count = max(12, int(move_distance * 0.5))
+        drain_count = min(40, drain_count)
+        frame = self._drain_frames(count=drain_count, interval=0.15)
+
+        # 如果 still 为空,再等待并尝试
+        if frame is None:
+            frame = self._wait_frame(timeout=5.0)
+
+        logger.info(
+            "[SpatialScanner] captured frame for (%.1f, %.1f, %d) after %.2fs",
+            pan, tilt, zoom, time.time() - t0,
+        )
+        return frame
+
     def _paste_direct(
         self,
         canvas: np.ndarray,

BIN
dual_camera_system/data/group_1/samples/test.jpg


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


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


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


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


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


+ 2 - 2
dual_camera_system/tests/test_routes.py

@@ -147,8 +147,8 @@ def test_scan_uses_full_vertical_range(client_with_mocks):
 
     _, kwargs = scanner.run.call_args
     tilt_layers = kwargs.get("tilt_layers", ())
-    assert min(tilt_layers) <= -90
-    assert max(tilt_layers) >= 90
+    assert min(tilt_layers) == -60
+    assert max(tilt_layers) == 60
 
 
 def test_poll_start_and_stop(client_with_mocks):

+ 20 - 7
dual_camera_system/third_party_pusher.py

@@ -18,6 +18,13 @@ from pathlib import Path
 logger = logging.getLogger(__name__)
 
 
+def _normalize_timestamp(ts: float) -> float:
+    """统一时间戳为秒。CaptureUploader 使用毫秒,其他可能使用秒。"""
+    if ts > 1e12:
+        return ts / 1000.0
+    return ts
+
+
 @dataclass
 class BatchReport:
     """批次上报数据"""
@@ -303,24 +310,30 @@ class ThirdPartyPusher:
         batch_info = report.batch_info
 
         # 根据平台类型调整格式
+        normalized_ts = _normalize_timestamp(report.timestamp)
         if self.platform_type == 'jtjai':
-            # jtjai 平台特定格式
+            # 优先取 OSS URL,否则用本地路径
+            urls = batch_info.get('image_urls') or {}
+            image_url = urls.get('original') or urls.get('marked') or (batch_info.get('image_paths') or [None])[0]
             payload = {
-                'createTime': datetime.fromtimestamp(report.timestamp).strftime("%Y-%m-%d %H:%M:%S"),
+                'createTime': datetime.fromtimestamp(normalized_ts).strftime("%Y-%m-%d %H:%M:%S"),
                 'addr': f"设备{report.device_id}批次上报",
-                'ext1': json.dumps([batch_info.get('panorama', {}).get('oss_url')]),
+                'ext1': json.dumps([image_url]),
                 'ext2': json.dumps({
                     'batchId': report.batch_id,
                     'deviceId': report.device_id,
                     'projectId': report.project_id,
-                    'totalPersons': batch_info.get('total_persons', 0),
-                    'ptzImagesCount': batch_info.get('ptz_images_count', 0),
-                    'persons': batch_info.get('persons', []),
+                    'totalPersons': len(batch_info.get('detections', [])),
+                    'ptzImagesCount': 1 if batch_info.get('camera_type') == 'ptz' else 0,
+                    'persons': batch_info.get('detections', []),
+                    'imageUrls': urls,
                 })
             }
         else:
-            # custom / 其他平台:原样发送 batch_info(snake_case)
+            # custom / 其他平台:原样发送 batch_info(snake_case),已包含 image_urls
             payload = dict(batch_info)
+            # 统一时间戳单位为秒,避免第三方解析错误
+            payload['timestamp'] = normalized_ts
 
         return payload
     

+ 32 - 3
dual_camera_system/web/routes.py

@@ -1,5 +1,6 @@
 """FastAPI REST 路由."""
 import logging
+import math
 import mimetypes
 import os
 import threading
@@ -102,7 +103,7 @@ def api_start_scan(group_id: str) -> JSONResponse:
         try:
             result = scanner.run(
                 pan_range=(0.0, 360.0),
-                tilt_layers=tuple(float(t) for t in range(-90, 91, 30)),
+                tilt_layers=(-60.0, -30.0, 0.0, 30.0, 60.0),
                 pan_step=30.0,
                 zoom=1,
                 progress_callback=lambda p: state.group_state.update(
@@ -251,17 +252,45 @@ 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))
+
     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
+    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)
+        frame = last_frame if last_frame is not None else stream.get_frame()
+
     snapshot_path = None
     if frame is not None:
         snapshot_dir = Path("data") / "previews" / group_id

+ 23 - 9
dual_camera_system/web_static/app.js

@@ -213,19 +213,29 @@ class SampleCanvas {
 
     this.canvas.addEventListener('mousedown', (e) => {
       if (e.button !== 0) return;
-      this.isDragging = true;
+      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.isDragging) return;
-      this.offsetX = this.dragStart.ox + (e.clientX - this.dragStart.x);
-      this.offsetY = this.dragStart.oy + (e.clientY - this.dragStart.y);
-      this.draw();
+      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();
+      }
     });
 
     window.addEventListener('mouseup', () => {
+      this.dragStart = null;
       if (this.isDragging) {
         this.isDragging = false;
         this.wrapper.style.cursor = 'grab';
@@ -233,19 +243,23 @@ class SampleCanvas {
     });
 
     this.canvas.addEventListener('click', (e) => {
-      if (this.isDragging) return;
+      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 col = Math.floor(worldX / this.cellW);
-      const row = Math.floor(worldY / (this.cellH + this.captionH));
+      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);
+      if (s) {
+        selectSample(s);
+      }
     });
   }