const zoneIds = ["r1c1", "r1c2", "r1c3", "r1c4", "r2c1", "r2c2", "r2c3", "r2c4", "trash"]; const colors = { r1c1: "#e11d48", r1c2: "#f97316", r1c3: "#ca8a04", r1c4: "#16a34a", r2c1: "#0891b2", r2c2: "#2563eb", r2c3: "#7c3aed", r2c4: "#db2777", trash: "#111827", }; const state = { activeZone: "r1c1", polygons: Object.fromEntries(zoneIds.map((id) => [id, []])), image: null, imageUrl: null, }; const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d"); const zoneList = document.getElementById("zoneList"); const statusEl = document.getElementById("status"); const tomlOutput = document.getElementById("tomlOutput"); function init() { for (const zoneId of zoneIds) { const button = document.createElement("button"); button.type = "button"; button.className = "zone-button"; button.textContent = zoneId; button.dataset.zoneId = zoneId; button.addEventListener("click", () => { state.activeZone = zoneId; render(); }); zoneList.appendChild(button); } document.getElementById("captureFrame").addEventListener("click", captureFrame); document.getElementById("undoPoint").addEventListener("click", undoPoint); document.getElementById("clearZone").addEventListener("click", clearZone); document.getElementById("clearAll").addEventListener("click", clearAll); document.getElementById("copyToml").addEventListener("click", copyToml); canvas.addEventListener("click", addPoint); window.addEventListener("resize", render); render(); } async function captureFrame() { const rtspUrl = document.getElementById("rtspUrl").value.trim(); if (!rtspUrl) { setStatus("请输入 RTSP 地址"); return; } setStatus("正在抓取一帧..."); try { const response = await fetch("/api/capture", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({rtsp_url: rtspUrl, timeout_seconds: 12}), }); if (!response.ok) { const payload = await response.json(); throw new Error(payload.error || `HTTP ${response.status}`); } const blob = await response.blob(); if (state.imageUrl) { URL.revokeObjectURL(state.imageUrl); } state.imageUrl = URL.createObjectURL(blob); const image = new Image(); image.onload = () => { state.image = image; canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; setStatus(`已抓取 ${image.naturalWidth}x${image.naturalHeight}`); render(); }; image.src = state.imageUrl; } catch (error) { setStatus(`抓帧失败:${error.message}`); } } function addPoint(event) { if (!state.image) { setStatus("请先抓取一帧"); return; } const imageRect = getCanvasImageRect(); const rawX = event.clientX - imageRect.left; const rawY = event.clientY - imageRect.top; if (rawX < 0 || rawY < 0 || rawX > imageRect.width || rawY > imageRect.height) { setStatus("请点击截图实际显示区域内"); return; } const point = { x: clamp(rawX / imageRect.width), y: clamp(rawY / imageRect.height), }; state.polygons[state.activeZone].push(point); render(); } function getCanvasImageRect() { const rect = canvas.getBoundingClientRect(); const canvasRatio = canvas.width / canvas.height; const elementRatio = rect.width / rect.height; if (elementRatio > canvasRatio) { const width = rect.height * canvasRatio; return { left: rect.left + (rect.width - width) / 2, top: rect.top, width, height: rect.height, }; } const height = rect.width / canvasRatio; return { left: rect.left, top: rect.top + (rect.height - height) / 2, width: rect.width, height, }; } function undoPoint() { state.polygons[state.activeZone].pop(); render(); } function clearZone() { state.polygons[state.activeZone] = []; render(); } function clearAll() { for (const zoneId of zoneIds) { state.polygons[zoneId] = []; } render(); } async function copyToml() { const text = tomlOutput.value; if (!text.trim()) { setStatus("没有可复制的 TOML"); return; } await navigator.clipboard.writeText(text); setStatus("TOML 已复制"); } function render() { renderZoneButtons(); renderCanvas(); tomlOutput.value = buildToml(); } function renderZoneButtons() { for (const button of zoneList.querySelectorAll("button")) { const zoneId = button.dataset.zoneId; button.classList.toggle("active", zoneId === state.activeZone); button.classList.toggle("done", state.polygons[zoneId].length >= 3); } } function renderCanvas() { ctx.clearRect(0, 0, canvas.width, canvas.height); if (state.image) { ctx.drawImage(state.image, 0, 0, canvas.width, canvas.height); } else { ctx.fillStyle = "#111820"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#d9e0e7"; ctx.font = "22px sans-serif"; ctx.textAlign = "center"; ctx.fillText("输入 RTSP 地址后抓取一帧", canvas.width / 2, canvas.height / 2); } for (const zoneId of zoneIds) { drawPolygon(zoneId, state.polygons[zoneId], zoneId === state.activeZone); } } function drawPolygon(zoneId, points, active) { if (!points.length) { return; } const color = colors[zoneId] || "#ffffff"; ctx.save(); ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = active ? 4 : 2; ctx.globalAlpha = 0.22; ctx.beginPath(); points.forEach((point, index) => { const x = point.x * canvas.width; const y = point.y * canvas.height; if (index === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } }); if (points.length >= 3) { ctx.closePath(); ctx.fill(); } ctx.globalAlpha = 1; ctx.stroke(); for (const [index, point] of points.entries()) { const x = point.x * canvas.width; const y = point.y * canvas.height; ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#ffffff"; ctx.font = "12px sans-serif"; ctx.textAlign = "center"; ctx.fillText(String(index + 1), x, y - 9); ctx.fillStyle = color; } const first = points[0]; ctx.fillStyle = color; ctx.font = active ? "bold 18px sans-serif" : "14px sans-serif"; ctx.textAlign = "left"; ctx.fillText(zoneId, first.x * canvas.width + 8, first.y * canvas.height + 18); ctx.restore(); } function buildToml() { const lines = [ "[layout]", "rows = 2", "cols = 4", `zone_ids = [${zoneIds.filter((id) => id !== "trash").map((id) => quote(id)).join(", ")}]`, "", ]; for (const zoneId of zoneIds.filter((id) => id !== "trash")) { const points = state.polygons[zoneId]; if (points.length < 3) { continue; } lines.push("[[zones]]"); lines.push(`id = ${quote(zoneId)}`); lines.push(`polygon = ${formatPoints(points)}`); lines.push(""); } const trashPoints = state.polygons.trash; if (trashPoints.length >= 3) { lines.push("[trash]"); lines.push(`roi = ${formatPoints(trashPoints)}`); } return lines.join("\n"); } function formatPoints(points) { return `[${points.map((point) => `[${point.x.toFixed(4)}, ${point.y.toFixed(4)}]`).join(", ")}]`; } function quote(value) { return `"${value}"`; } function clamp(value) { return Math.min(1, Math.max(0, value)); } function setStatus(message) { statusEl.textContent = message; } init();