283 lines
7.3 KiB
JavaScript
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();
|