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

feat: 新增样本管理相关功能和前端展示界面

- 新增获取样本列表和加载样本图片的后端API接口
- 新增样本网格预览前端组件,支持缩放、拖拽交互
- 重构前端UI布局,添加样本展示面板和相关操作控件
- 新增样本管理相关的单元测试
- 添加测试用的样本图片资源
wenhongquan 1 день назад
Родитель
Сommit
43076c898a

BIN
data/group_1/samples/test.jpg


BIN
data/previews/group_1/preview_1781591585131.jpg


BIN
data/previews/group_1/preview_1781592018851.jpg


BIN
data/previews/group_1/preview_1781592313444.jpg


+ 36 - 0
dual_camera_system/tests/test_routes.py

@@ -7,8 +7,10 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 
 from unittest.mock import MagicMock
 
+import cv2
 import numpy as np
 import pytest
+from pathlib import Path
 from fastapi.testclient import TestClient
 from app import create_app
 import web.state as _web_state_module
@@ -255,3 +257,37 @@ def test_preview_does_not_update_saved_point_image(client_with_mocks):
     point = next(p for p in response.json()["points"] if p["id"] == point_id)
     # 保存点的 preview_image 应保持原值,不被预览抓拍覆盖
     assert point["preview_image"] == "data/previews/group_1/existing.jpg"
+
+
+def test_list_samples_returns_scan_samples(client_with_mocks):
+    client, _, _, _, _ = client_with_mocks
+    samples = [
+        {"id": 1, "pan": 0.0, "tilt": 0.0, "zoom": 1, "thumbnail": "data/group_1/samples/p0_t0.jpg"},
+        {"id": 2, "pan": 30.0, "tilt": -10.0, "zoom": 1, "thumbnail": "data/group_1/samples/p30_t-10.jpg"},
+    ]
+    _web_state_module.web_state.scan_store.set_samples("group_1", samples)
+
+    response = client.get("/api/samples/group_1")
+    assert response.status_code == 200
+    data = response.json()
+    assert len(data["samples"]) == 2
+    assert data["samples"][0]["pan"] == 0.0
+    assert data["samples"][1]["pan"] == 30.0
+
+
+def test_sample_image_serves_file_and_rejects_traversal(client_with_mocks, tmp_path):
+    client, _, _, _, _ = client_with_mocks
+    sample_dir = Path("data/group_1/samples")
+    sample_dir.mkdir(parents=True, exist_ok=True)
+    sample_path = sample_dir / "test.jpg"
+    cv2.imwrite(str(sample_path), np.zeros((20, 20, 3), dtype=np.uint8))
+
+    response = client.get("/api/sample-image?path=data/group_1/samples/test.jpg")
+    assert response.status_code == 200
+    assert int(response.headers["content-length"]) > 0
+
+    response = client.get("/api/sample-image?path=data/group_1/samples/missing.jpg")
+    assert response.status_code == 404
+
+    response = client.get("/api/sample-image?path=../../etc/passwd")
+    assert response.status_code == 400

+ 31 - 0
dual_camera_system/web/routes.py

@@ -22,6 +22,7 @@ from config.coordinator import COORDINATOR_CONFIG
 router = APIRouter()
 
 PANORAMA_BASE = Path(os.environ.get("PANORAMA_DIR", ".")).resolve()
+SAMPLE_BASE = Path("data").resolve()
 
 
 class AddPointPayload(BaseModel):
@@ -176,6 +177,36 @@ def api_delete_point(group_id: str, point_id: int) -> dict:
     return {"ok": True}
 
 
+@router.get("/api/samples/{group_id}")
+def api_list_samples(group_id: str) -> dict:
+    state = _get_state()
+    _require_group(state, group_id)
+    group = state.scan_store.get_group(group_id) or {}
+    return {"samples": group.get("samples", [])}
+
+
+@router.get("/api/sample-image")
+def api_sample_image(path: str) -> FileResponse:
+    if not path:
+        raise HTTPException(status_code=400, detail="Path required")
+    raw = Path(path)
+    if raw.is_absolute():
+        raise HTTPException(status_code=400, detail="Invalid sample path")
+    try:
+        rel = raw.relative_to("data")
+    except ValueError:
+        rel = raw
+    resolved = (SAMPLE_BASE / rel).resolve()
+    try:
+        resolved.relative_to(SAMPLE_BASE)
+    except ValueError as exc:
+        raise HTTPException(status_code=400, detail="Invalid sample path") from exc
+    if not resolved.exists():
+        raise HTTPException(status_code=404, detail="Sample not found")
+    media_type = mimetypes.guess_type(str(resolved))[0] or "image/jpeg"
+    return FileResponse(resolved, media_type=media_type)
+
+
 @router.post("/api/poll/{group_id}/start", dependencies=[Depends(verify_api_key)])
 def api_poll_start(group_id: str) -> dict:
     state = _get_state()

+ 268 - 169
dual_camera_system/web_static/app.js

@@ -1,6 +1,3 @@
-import * as THREE from 'three';
-import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-
 const parseResponse = async (r) => {
   const contentType = r.headers.get('content-type') || '';
   if (contentType.includes('application/json')) {
@@ -42,15 +39,10 @@ const API = {
 };
 
 let currentGroup = null;
-let scene, camera, renderer, sphere, controls;
-let raycaster = new THREE.Raycaster();
-let mouse = new THREE.Vector2();
-let markers = [];
-let tempMarker = null;
-let previewSprites = [];
-let tempPreview = null;
 let scanPollInterval = null;
 let controlsGloballyDisabled = false;
+let tempPreview = null;
+let selectedSampleEl = null;
 
 function log(msg) {
   const panel = document.getElementById('log-panel');
@@ -105,152 +97,252 @@ function escapeHtml(text) {
   return div.innerHTML;
 }
 
-function initThree() {
-  const container = document.getElementById('panorama-container');
-  scene = new THREE.Scene();
-  camera = new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.1, 1000);
-  camera.position.set(0, 0, 0.1);
-  renderer = new THREE.WebGLRenderer({ antialias: true });
-  renderer.setSize(container.clientWidth, container.clientHeight);
-  container.appendChild(renderer.domElement);
-
-  controls = new OrbitControls(camera, renderer.domElement);
-  controls.enableZoom = true;
-  controls.enablePan = false;
-  controls.rotateSpeed = -0.3;
-  controls.minPolarAngle = 0.1;
-  controls.maxPolarAngle = Math.PI - 0.1;
-
-  renderer.domElement.addEventListener('click', onPanoramaClick);
-  window.addEventListener('resize', onResize);
-  animate();
+function setSelectedPreview(url) {
+  const img = document.getElementById('selected-preview');
+  if (url) {
+    img.src = url;
+    img.style.display = 'block';
+  } else {
+    img.src = '';
+    img.style.display = 'none';
+  }
 }
 
-function onResize() {
-  const container = document.getElementById('panorama-container');
-  camera.aspect = container.clientWidth / container.clientHeight;
-  camera.updateProjectionMatrix();
-  renderer.setSize(container.clientWidth, container.clientHeight);
+function selectSample(sample) {
+  document.getElementById('inp-pan').value = sample.pan.toFixed(2);
+  document.getElementById('inp-tilt').value = sample.tilt.toFixed(2);
+  document.getElementById('inp-zoom').value = sample.zoom;
+  tempPreview = { path: sample.thumbnail };
+  setSelectedPreview(`/api/sample-image?path=${encodeURIComponent(sample.thumbnail)}`);
+  if (sampleCanvas) sampleCanvas.setSelected(sample.pan, sample.tilt);
 }
 
-function loadPanorama(groupId) {
-  clearMarkers();
-  const loader = new THREE.TextureLoader();
-  loader.load(`/api/panorama/${groupId}`,
-    (texture) => {
-      if (sphere) scene.remove(sphere);
-      const geo = new THREE.SphereGeometry(50, 60, 40);
-      geo.scale(-1, 1, 1);
-      const mat = new THREE.MeshBasicMaterial({ map: texture });
-      sphere = new THREE.Mesh(geo, mat);
-      scene.add(sphere);
-      log(`加载全景图: ${groupId}`);
-      loadPoints(groupId);
-    },
-    undefined,
-    () => {
-      log('全景图未就绪,请先执行扫描');
-    }
-  );
-}
+class SampleCanvas {
+  constructor(canvasId, wrapperId) {
+    this.canvas = document.getElementById(canvasId);
+    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.resize();
+    window.addEventListener('resize', () => this.resize());
+    this.setupEvents();
+  }
 
-function clearMarkers() {
-  markers.forEach(m => scene.remove(m));
-  markers = [];
-}
+  resize() {
+    const rect = this.wrapper.getBoundingClientRect();
+    this.canvas.width = rect.width;
+    this.canvas.height = rect.height;
+    this.draw();
+  }
 
-function clearTempMarker() {
-  if (tempMarker) {
-    scene.remove(tempMarker);
-    tempMarker = null;
+  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);
+    });
+    this.fitToView();
+    this.draw();
   }
-}
 
-function setTempMarker(pan, tilt) {
-  clearTempMarker();
-  tempMarker = addMarker(pan, tilt, 0x3b82f6);
-}
+  contentWidth() {
+    return this.pans.length * this.cellW;
+  }
 
-function clearPreviewSprites() {
-  previewSprites.forEach(s => scene.remove(s));
-  previewSprites = [];
-}
+  contentHeight() {
+    return this.tilts.length * (this.cellH + this.captionH);
+  }
 
-function addPreviewPlane(pan, tilt, imageUrl) {
-  const loader = new THREE.TextureLoader();
-  const mesh = new THREE.Mesh(
-    new THREE.PlaneGeometry(1, 1),
-    new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide, transparent: true, opacity: 0.95 })
-  );
-  mesh.visible = false;
-  scene.add(mesh);
-  previewSprites.push(mesh);
-
-  loader.load(imageUrl, (texture) => {
-    const imageWidth = texture.image.width || 100;
-    const imageHeight = texture.image.height || 100;
-    const aspect = imageWidth / imageHeight;
-    const baseSize = 5;
-    mesh.geometry.dispose();
-    mesh.geometry = new THREE.PlaneGeometry(baseSize * aspect, baseSize);
-    mesh.material.map = texture;
-    mesh.material.needsUpdate = true;
-
-    const r = 46;
-    const phi = (90 - tilt) * Math.PI / 180;
-    const theta = (pan - 90) * Math.PI / 180;
-    const x = r * Math.sin(phi) * Math.cos(theta);
-    const y = r * Math.cos(phi);
-    const z = r * Math.sin(phi) * Math.sin(theta);
-    mesh.position.set(x, y, z);
-    // 让平面法线朝外(贴合球面)
-    mesh.lookAt(x * 2, y * 2, z * 2);
-    mesh.visible = true;
-    mesh.userData = { type: 'preview', pan, tilt, imageUrl };
-  }, undefined, (err) => {
-    log(`加载预览图失败: ${err.message || err}`);
-  });
+  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;
+    this.updateZoomLabel();
+  }
 
-  return mesh;
-}
+  resetView() {
+    this.fitToView();
+    this.draw();
+  }
 
-function addMarker(pan, tilt, color = 0x22c55e) {
-  const r = 49.5;
-  const phi = (90 - tilt) * Math.PI / 180;
-  const theta = (pan - 90) * Math.PI / 180;
-  const x = r * Math.sin(phi) * Math.cos(theta);
-  const y = r * Math.cos(phi);
-  const z = r * Math.sin(phi) * Math.sin(theta);
-  const geo = new THREE.SphereGeometry(0.4, 16, 16);
-  const mat = new THREE.MeshBasicMaterial({ color });
-  const mesh = new THREE.Mesh(geo, mat);
-  mesh.position.set(x, y, z);
-  scene.add(mesh);
-  markers.push(mesh);
-  return mesh;
-}
+  updateZoomLabel() {
+    const label = document.getElementById('zoom-level');
+    if (label) label.textContent = `${Math.round(this.scale * 100)}%`;
+  }
 
-function onPanoramaClick(event) {
-  if (!sphere) return;
-  const rect = renderer.domElement.getBoundingClientRect();
-  mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
-  mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
-  raycaster.setFromCamera(mouse, camera);
-  const intersects = raycaster.intersectObject(sphere);
-  if (intersects.length === 0) return;
-  const p = intersects[0].point.clone().normalize();
-  const pan = ((Math.atan2(p.x, -p.z) * 180 / Math.PI) + 360) % 360;
-  const tilt = Math.asin(Math.max(-1, Math.min(1, p.y))) * 180 / Math.PI;
-  document.getElementById('inp-pan').value = pan.toFixed(2);
-  document.getElementById('inp-tilt').value = tilt.toFixed(2);
-  tempPreview = null;
-  setTempMarker(pan, tilt);
+  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 = true;
+      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();
+    });
+
+    window.addEventListener('mouseup', () => {
+      if (this.isDragging) {
+        this.isDragging = false;
+        this.wrapper.style.cursor = 'grab';
+      }
+    });
+
+    this.canvas.addEventListener('click', (e) => {
+      if (this.isDragging) 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));
+      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);
+    });
+  }
+
+  setSelected(pan, tilt) {
+    this.selectedPan = pan;
+    this.selectedTilt = tilt;
+    this.draw();
+  }
+
+  draw() {
+    if (this.pendingDraw) return;
+    this.pendingDraw = true;
+    requestAnimationFrame(() => {
+      this.pendingDraw = false;
+      this._draw();
+    });
+  }
+
+  _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);
+        }
+      }
+    }
+
+    ctx.restore();
+  }
 }
 
-function animate() {
-  requestAnimationFrame(animate);
-  if (controls) controls.update();
-  renderer.render(scene, camera);
+let sampleCanvas = null;
+
+async function loadSamples(groupId) {
+  try {
+    const data = await API.get(`/api/samples/${groupId}`);
+    if (!sampleCanvas) {
+      sampleCanvas = new SampleCanvas('sample-canvas', 'sample-grid-wrapper');
+    }
+    sampleCanvas.setSamples(data.samples || []);
+  } catch (e) {
+    log(`加载扫描样本失败: ${e.message}`);
+  }
 }
 
 async function loadGroups() {
@@ -283,12 +375,17 @@ async function loadGroups() {
   }
 }
 
+function resetSampleZoom() {
+  if (sampleCanvas) sampleCanvas.resetView();
+}
+
 function onGroupChange() {
   currentGroup = document.getElementById('group-select').value;
-  clearTempMarker();
-  clearPreviewSprites();
+  selectedSampleEl = null;
   tempPreview = null;
-  loadPanorama(currentGroup);
+  setSelectedPreview(null);
+  resetSampleZoom();
+  loadSamples(currentGroup);
   renderVideos(currentGroup);
   loadPoints(currentGroup);
 }
@@ -317,15 +414,9 @@ function renderVideos(groupId) {
 async function loadPoints(groupId) {
   try {
     const data = await API.get(`/api/points/${groupId}`);
-    clearMarkers();
-    clearPreviewSprites();
     const ul = document.getElementById('points');
     ul.innerHTML = '';
     data.points.forEach(p => {
-      addMarker(p.pan, p.tilt, 0x22c55e);
-      if (p.preview_image) {
-        addPreviewPlane(p.pan, p.tilt, `/api/preview-image?path=${encodeURIComponent(p.preview_image)}`);
-      }
       const li = document.createElement('li');
 
       const span = document.createElement('span');
@@ -407,7 +498,8 @@ document.getElementById('btn-scan').addEventListener('click', async () => {
           if (!controlsGloballyDisabled) scanBtn.disabled = false;
           if (prog.state === 'done') {
             log('扫描完成');
-            loadPanorama(scannedGroup);
+            resetSampleZoom();
+            loadSamples(scannedGroup);
             loadPoints(scannedGroup);
           } else if (prog.state === 'failed') {
             log(`扫描失败: ${prog.error || 'unknown'}`);
@@ -462,26 +554,13 @@ document.getElementById('btn-poll-stop').addEventListener('click', async () => {
 
 async function runPreview(groupId, pan, tilt, zoom, pointId = null) {
   const payload = { pan, tilt, zoom };
-  if (pointId !== null) {
-    payload.point_id = pointId;
-  }
   const result = await API.post(`/api/preview/${groupId}`, payload);
   log(`预览位置: P=${pan.toFixed(1)} T=${tilt.toFixed(1)} Z=${zoom}`);
   if (result.snapshot_url) {
     log(`预览抓拍已保存: ${result.snapshot_path}`);
-    if (pointId === null) {
-      // 未保存的临时点:记录本次预览图,保存时可关联
-      if (tempPreview && tempPreview.mesh) {
-        scene.remove(tempPreview.mesh);
-        previewSprites = previewSprites.filter(s => s !== tempPreview.mesh);
-      }
-      clearTempMarker();
-      const mesh = addPreviewPlane(pan, tilt, result.snapshot_url);
-      tempPreview = { pan, tilt, url: result.snapshot_url, path: result.snapshot_path, mesh };
-    } else {
-      // 已保存点:刷新列表以显示最新预览图
-      loadPoints(groupId);
-    }
+    // 预览抓拍显示在右侧,但不更新保存点图片
+    setSelectedPreview(result.snapshot_url);
+    tempPreview = { path: result.snapshot_path };
   }
   return result;
 }
@@ -554,8 +633,9 @@ document.getElementById('btn-add').addEventListener('click', async () => {
     try {
       await API.post(`/api/points/${currentGroup}`, payload);
       log('扫描点已保存');
-      clearTempMarker();
+      selectedSampleEl = null;
       tempPreview = null;
+      setSelectedPreview(null);
       loadPoints(currentGroup);
     } catch (e) {
       log(`保存失败: ${e.message}`);
@@ -563,7 +643,26 @@ document.getElementById('btn-add').addEventListener('click', async () => {
   });
 });
 
-initThree();
+document.getElementById('btn-zoom-in').addEventListener('click', () => {
+  if (sampleCanvas) {
+    sampleCanvas.scale = Math.min(5.0, sampleCanvas.scale * 1.2);
+    sampleCanvas.updateZoomLabel();
+    sampleCanvas.draw();
+  }
+});
+
+document.getElementById('btn-zoom-out').addEventListener('click', () => {
+  if (sampleCanvas) {
+    sampleCanvas.scale = Math.max(0.1, sampleCanvas.scale / 1.2);
+    sampleCanvas.updateZoomLabel();
+    sampleCanvas.draw();
+  }
+});
+
+document.getElementById('btn-zoom-reset').addEventListener('click', () => {
+  resetSampleZoom();
+});
+
 setControlsDisabled(true);
 loadGroups();
 setInterval(updateStatus, 2000);

+ 19 - 12
dual_camera_system/web_static/index.html

@@ -5,29 +5,34 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>PTZ 扫描轮询控制台</title>
     <link rel="stylesheet" href="/static/style.css">
-    <script type="importmap">
-    {
-      "imports": {
-        "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
-        "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
-      }
-    }
-    </script>
 </head>
 <body>
     <div id="toolbar">
         <select id="group-select"></select>
-        <button id="btn-scan">执行 360° 扫描建模</button>
+        <button id="btn-scan">执行 360° 扫描</button>
         <button id="btn-poll-start">开始轮询</button>
         <button id="btn-poll-stop">停止轮询</button>
         <span id="status">状态:空闲</span>
     </div>
     <div id="video-grid"></div>
     <div id="panorama-panel">
-        <div id="panorama-container"></div>
+        <div>
+          <div id="sample-header">
+            扫描样本矩阵(同列同 P,同行同 T)
+            <span id="zoom-controls">
+              <button id="btn-zoom-out">-</button>
+              <span id="zoom-level">100%</span>
+              <button id="btn-zoom-in">+</button>
+              <button id="btn-zoom-reset">重置</button>
+            </span>
+          </div>
+          <div id="sample-grid-wrapper">
+            <canvas id="sample-canvas"></canvas>
+          </div>
+        </div>
         <div id="point-list">
-            <h3>已选扫描点</h3>
-            <ul id="points"></ul>
+            <h3>扫描点设置</h3>
+            <img id="selected-preview" src="" alt="选中/预览" style="width:100%;max-height:140px;object-fit:cover;background:#000;border-radius:6px;margin-bottom:10px;display:none;">
             <div id="point-form">
                 <label>Pan: <input id="inp-pan" type="number" step="0.1"></label>
                 <label>Tilt: <input id="inp-tilt" type="number" step="0.1"></label>
@@ -36,6 +41,8 @@
                 <button id="btn-preview">球机预览</button>
                 <button id="btn-add">保存扫描点</button>
             </div>
+            <h4 style="margin:10px 0 6px 0;font-size:13px;color:#94a3b8;">已选扫描点</h4>
+            <ul id="points"></ul>
         </div>
     </div>
     <div id="log-panel"></div>

+ 14 - 6
dual_camera_system/web_static/style.css

@@ -1,21 +1,29 @@
-body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #e2e8f0; }
+html, body { height: 100%; margin: 0; }
+body { display: flex; flex-direction: column; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #e2e8f0; }
 #toolbar { padding: 10px 16px; background: #1e293b; display: flex; gap: 12px; align-items: center; border-bottom: 1px solid #334155; }
 #toolbar button { padding: 6px 12px; border: none; border-radius: 4px; background: #3b82f6; color: #fff; cursor: pointer; }
 #toolbar button:hover { background: #2563eb; }
 #toolbar button:disabled { background: #64748b; cursor: not-allowed; }
 #status { margin-left: auto; color: #94a3b8; }
-#video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 12px; padding: 12px; }
+#video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; padding: 12px; max-height: 220px; overflow: hidden; }
 .video-box { background: #1e293b; border-radius: 8px; overflow: hidden; }
 .video-box .title { padding: 8px 12px; font-size: 13px; color: #cbd5e1; }
 .video-box img { width: 100%; display: block; background: #000; }
-#panorama-panel { display: grid; grid-template-columns: 1fr 280px; gap: 12px; padding: 12px; }
-#panorama-container { height: 500px; background: #111; border-radius: 8px; position: relative; }
-#point-list { background: #1e293b; border-radius: 8px; padding: 12px; }
+#panorama-panel { flex: 1; min-height: 0; display: grid; grid-template-columns: 1fr 280px; grid-template-rows: minmax(0, 1fr); gap: 12px; padding: 12px; overflow: hidden; }
+#sample-header { padding: 8px 12px; background: #1e293b; border-radius: 8px 8px 0 0; font-size: 14px; color: #cbd5e1; display: flex; justify-content: space-between; align-items: center; }
+#zoom-controls { display: flex; align-items: center; gap: 6px; }
+#zoom-controls button { padding: 2px 8px; font-size: 13px; background: #334155; border: none; border-radius: 4px; color: #fff; cursor: pointer; }
+#zoom-controls button:hover { background: #475569; }
+#sample-grid-wrapper { height: 100%; background: #111; border-radius: 0 0 8px 8px; overflow: hidden; position: relative; cursor: grab; }
+#sample-grid-wrapper:active { cursor: grabbing; }
+#sample-canvas { display: block; width: 100%; height: 100%; }
+#point-list { background: #1e293b; border-radius: 8px; padding: 12px; height: 100%; overflow-y: auto; }
 #point-list h3 { margin-top: 0; }
 #point-list label { display: block; margin-bottom: 8px; font-size: 13px; }
 #point-list input { width: 80px; padding: 4px; border-radius: 4px; border: 1px solid #475569; background: #0f172a; color: #fff; }
 #point-list button { margin-top: 8px; margin-right: 8px; padding: 6px 10px; border: none; border-radius: 4px; background: #22c55e; color: #fff; cursor: pointer; }
 button:disabled { background: #64748b; cursor: not-allowed; }
-#points { list-style: none; padding: 0; max-height: 200px; overflow: auto; }
+#points { list-style: none; padding: 0; margin: 0; }
+#point-list h4 { margin: 10px 0 6px 0; font-size: 13px; color: #94a3b8; }
 #points li { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #334155; font-size: 13px; }
 #log-panel { padding: 12px; background: #1e293b; font-family: monospace; font-size: 12px; max-height: 160px; overflow: auto; border-top: 1px solid #334155; }