Files
cold_display_guard/tools/calibrator/app.js

283 lines
7.3 KiB
JavaScript

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();