冷藏展示柜区域标定
+从 RTSP 拉取一帧截图,在图上标定 8 个格口和垃圾桶区域。
+diff --git a/README_zh.md b/README_zh.md index 23fe2c6..97cd5ed 100644 --- a/README_zh.md +++ b/README_zh.md @@ -49,6 +49,22 @@ - 最大放置时间:`10800` 秒,也就是 3 小时 - 垃圾桶投放确认窗口:`120` 秒 +## 区域标定 + +项目内置一个本地 Web 标定工具,可以从 RTSP 拉取一帧截图,再用鼠标标定 8 个格口和垃圾桶区域: + +```bash +python3 tools/calibrator/server.py --host 127.0.0.1 --port 18090 +``` + +打开: + +```text +http://127.0.0.1:18090 +``` + +详细说明见 `tools/calibrator/README_zh.md`。 + ## 本地测试 ```bash diff --git a/docs/plans/2026-04-27-cold-display-guard-design.md b/docs/plans/2026-04-27-cold-display-guard-design.md index 0ca5e39..c69a663 100644 --- a/docs/plans/2026-04-27-cold-display-guard-design.md +++ b/docs/plans/2026-04-27-cold-display-guard-design.md @@ -111,3 +111,11 @@ The vision layer should output normalized observations: ``` Trash disposal confirmation should use motion/object evidence inside the trash ROI, not merely a person standing near the bin. + +## Calibration Tool + +The project includes a local RTSP snapshot calibration tool under `tools/calibrator`. + +The tool runs a small standard-library HTTP server. The browser submits an RTSP URL to `/api/capture`; the server calls `ffmpeg`, extracts one JPEG frame, and returns it to the browser. The page then lets the operator draw normalized polygons for `r1c1` through `r2c4` plus `trash`. + +This intentionally uses a single captured frame rather than a live preview. Calibration only needs a representative camera view, and a snapshot avoids browser RTSP limitations and live stream transcoding. diff --git a/progress.md b/progress.md index 8dfa22d..acffb6c 100644 --- a/progress.md +++ b/progress.md @@ -9,3 +9,4 @@ - First test run failed because `_end_batch()` set `ended_at` before calculating dwell seconds, causing ended batches to report `0` seconds. Fixed by calculating dwell before assigning `ended_at`. - Test suite now passes: `PYTHONPATH=src python3 -m unittest discover -s tests -v`. - Initialized git repository and created the initial project commit. +- Added RTSP single-frame calibration tool under `tools/calibrator`. diff --git a/tools/calibrator/README_zh.md b/tools/calibrator/README_zh.md new file mode 100644 index 0000000..ef9895b --- /dev/null +++ b/tools/calibrator/README_zh.md @@ -0,0 +1,52 @@ +# RTSP 单帧区域标定工具 + +这个工具用于从 RTSP 摄像头拉取一帧截图,然后在浏览器里标定展示柜格口和垃圾桶区域。 + +## 依赖 + +本机需要安装 `ffmpeg`,并且命令行可直接执行: + +```bash +ffmpeg -version +``` + +## 启动 + +在项目根目录执行: + +```bash +python3 tools/calibrator/server.py --host 127.0.0.1 --port 18090 +``` + +然后打开: + +```text +http://127.0.0.1:18090 +``` + +## 使用步骤 + +1. 输入 RTSP 地址。 +2. 点击“抓取一帧”。 +3. 选择 `r1c1` 到 `r2c4` 中的一个区域。 +4. 在截图上按顺时针或逆时针点击格口顶点。 +5. 每个格口建议标 4 个点;如果透视明显,可以标更多点。 +6. 标完 8 个格口后,选择 `trash` 并标定垃圾桶区域。 +7. 复制右侧生成的 TOML 配置。 +8. 把生成内容合入 `config/example.toml` 或实际部署配置。 + +## 坐标说明 + +导出的坐标是归一化坐标: + +- 左上角是 `[0.0, 0.0]` +- 右下角是 `[1.0, 1.0]` + +这样即使摄像头截图分辨率变化,标定结果也可以复用。 + +## 注意 + +- 标定截图应来自真实安装角度。 +- 标定时展示柜门最好保持日常运行状态。 +- 垃圾桶区域只框垃圾桶开口和投放可见区域,不要框太大。 +- RTSP 密码只会发给本地标定服务,不会保存到项目文件。 diff --git a/tools/calibrator/app.js b/tools/calibrator/app.js new file mode 100644 index 0000000..39fd7ed --- /dev/null +++ b/tools/calibrator/app.js @@ -0,0 +1,256 @@ +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 rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const point = { + x: clamp(((event.clientX - rect.left) * scaleX) / canvas.width), + y: clamp(((event.clientY - rect.top) * scaleY) / canvas.height), + }; + state.polygons[state.activeZone].push(point); + render(); +} + +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(); diff --git a/tools/calibrator/index.html b/tools/calibrator/index.html new file mode 100644 index 0000000..70f7670 --- /dev/null +++ b/tools/calibrator/index.html @@ -0,0 +1,52 @@ + + +
+ + +从 RTSP 拉取一帧截图,在图上标定 8 个格口和垃圾桶区域。
+