完成初版选点提取坐标
This commit is contained in:
416
web_page_2/coordinate.html
Normal file
416
web_page_2/coordinate.html
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>坐标提取工具</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body { width: 100%; height: 100%; overflow: hidden; background: #111827; color: #e5e7eb; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; }
|
||||||
|
|
||||||
|
.left-panel {
|
||||||
|
width: 320px; min-width: 320px; background: #020617; border-right: 1px solid #1f2937;
|
||||||
|
display: flex; flex-direction: column; height: 100vh;
|
||||||
|
}
|
||||||
|
.left-header {
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid #1f2937; font-size: 14px; color: #9ca3af;
|
||||||
|
font-weight: 500; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.coord-list {
|
||||||
|
flex: 1; overflow-y: auto; padding: 8px;
|
||||||
|
}
|
||||||
|
.coord-group {
|
||||||
|
margin-bottom: 10px; background: #0f172a; border: 1px solid #1f2937; border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.coord-group-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 8px 12px; border-bottom: 1px solid #1f2937; font-size: 13px;
|
||||||
|
}
|
||||||
|
.coord-group-color {
|
||||||
|
display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 8px;
|
||||||
|
}
|
||||||
|
.coord-group-actions { display: flex; gap: 6px; }
|
||||||
|
.coord-group-actions button {
|
||||||
|
background: none; border: none; cursor: pointer; font-size: 13px; padding: 2px 4px;
|
||||||
|
border-radius: 3px; transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.coord-group-actions .copy-btn { color: #60a5fa; }
|
||||||
|
.coord-group-actions .copy-btn:hover { background: rgba(96,165,250,0.15); }
|
||||||
|
.coord-group-actions .del-btn { color: #f87171; }
|
||||||
|
.coord-group-actions .del-btn:hover { background: rgba(248,113,113,0.15); }
|
||||||
|
.coord-group pre {
|
||||||
|
margin: 0; padding: 8px 12px; font-family: 'Courier New', monospace; font-size: 12px;
|
||||||
|
color: #d1d5db; white-space: pre; overflow-x: auto; line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-area {
|
||||||
|
flex: 1; display: flex; flex-direction: column; min-width: 0;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
padding: 10px 16px; background: #0f172a; border-bottom: 1px solid #1f2937;
|
||||||
|
display: flex; align-items: center; gap: 12px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.upload-btn {
|
||||||
|
padding: 6px 16px; background: #3b82f6; color: #fff; border: none; border-radius: 4px;
|
||||||
|
font-size: 13px; cursor: pointer; transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.upload-btn:hover { background: #2563eb; }
|
||||||
|
.toolbar-info { font-size: 12px; color: #9ca3af; }
|
||||||
|
.toolbar-hint {
|
||||||
|
margin-left: auto; font-size: 12px; color: #6b7280;
|
||||||
|
}
|
||||||
|
.toolbar-hint kbd {
|
||||||
|
padding: 1px 5px; background: #1f2937; border: 1px solid #374151;
|
||||||
|
border-radius: 3px; font-size: 11px; font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-area {
|
||||||
|
flex: 1; position: relative; overflow: hidden; background: #000;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
#imageCanvas { cursor: crosshair; }
|
||||||
|
.upload-placeholder {
|
||||||
|
position: absolute; display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 12px; color: #6b7280; font-size: 14px;
|
||||||
|
}
|
||||||
|
.upload-placeholder svg { opacity: 0.3; }
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed; top: 20px; right: 20px; padding: 10px 18px;
|
||||||
|
background: #10b981; color: #fff; border-radius: 6px; font-size: 13px;
|
||||||
|
opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 999;
|
||||||
|
}
|
||||||
|
.toast.show { opacity: 1; }
|
||||||
|
|
||||||
|
#fileInput { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<aside class="left-panel">
|
||||||
|
<div class="left-header">
|
||||||
|
<span>坐标数据</span>
|
||||||
|
<button id="copyAllBtn" class="copy-btn" style="background:none;border:none;color:#60a5fa;cursor:pointer;font-size:12px;">复制全部</button>
|
||||||
|
</div>
|
||||||
|
<div id="coordList" class="coord-list"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="main-area">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="upload-btn" id="uploadBtn">上传图片</button>
|
||||||
|
<input type="file" id="fileInput" accept=".jpg,.jpeg,.png" />
|
||||||
|
<span class="toolbar-info" id="imageInfo"></span>
|
||||||
|
<span class="toolbar-hint">
|
||||||
|
<kbd>点击</kbd> 标记点
|
||||||
|
<kbd>Backspace</kbd> 撤销
|
||||||
|
<kbd>Enter</kbd> 完成当前组
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="canvas-area" id="canvasArea">
|
||||||
|
<canvas id="imageCanvas"></canvas>
|
||||||
|
<div class="upload-placeholder" id="placeholder">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
<span>点击「上传图片」开始</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const GROUP_COLORS = [
|
||||||
|
'#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6',
|
||||||
|
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6'
|
||||||
|
];
|
||||||
|
|
||||||
|
let groups = []; // [{points: [[x,y], ...]}, ...]
|
||||||
|
let currentPoints = []; // current group being edited
|
||||||
|
let image = null; // HTMLImageElement
|
||||||
|
let canvasScale = 1;
|
||||||
|
let canvasOffsetX = 0;
|
||||||
|
let canvasOffsetY = 0;
|
||||||
|
|
||||||
|
const canvas = document.getElementById('imageCanvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const canvasArea = document.getElementById('canvasArea');
|
||||||
|
const placeholder = document.getElementById('placeholder');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const imageInfo = document.getElementById('imageInfo');
|
||||||
|
const coordList = document.getElementById('coordList');
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
|
||||||
|
// --- Upload ---
|
||||||
|
document.getElementById('uploadBtn').addEventListener('click', () => fileInput.click());
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const ext = file.name.toLowerCase().split('.').pop();
|
||||||
|
if (!['jpg', 'jpeg', 'png'].includes(ext)) {
|
||||||
|
showToast('仅支持 JPG/PNG 格式', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
image = new Image();
|
||||||
|
image.onload = () => {
|
||||||
|
imageInfo.textContent = `${image.width} x ${image.height}`;
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
fitCanvas();
|
||||||
|
redraw();
|
||||||
|
};
|
||||||
|
image.src = ev.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
// Reset groups on new image
|
||||||
|
groups = [];
|
||||||
|
currentPoints = [];
|
||||||
|
renderCoordList();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Canvas sizing ---
|
||||||
|
function fitCanvas() {
|
||||||
|
if (!image) return;
|
||||||
|
const areaW = canvasArea.clientWidth;
|
||||||
|
const areaH = canvasArea.clientHeight;
|
||||||
|
const imgAspect = image.width / image.height;
|
||||||
|
const areaAspect = areaW / areaH;
|
||||||
|
|
||||||
|
let drawW, drawH;
|
||||||
|
if (imgAspect > areaAspect) {
|
||||||
|
drawW = areaW;
|
||||||
|
drawH = areaW / imgAspect;
|
||||||
|
} else {
|
||||||
|
drawH = areaH;
|
||||||
|
drawW = areaH * imgAspect;
|
||||||
|
}
|
||||||
|
canvasScale = drawW / image.width;
|
||||||
|
canvasOffsetX = (areaW - drawW) / 2;
|
||||||
|
canvasOffsetY = (areaH - drawH) / 2;
|
||||||
|
|
||||||
|
canvas.width = areaW;
|
||||||
|
canvas.height = areaH;
|
||||||
|
canvas.style.width = areaW + 'px';
|
||||||
|
canvas.style.height = areaH + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => { fitCanvas(); redraw(); });
|
||||||
|
|
||||||
|
// --- Drawing ---
|
||||||
|
function redraw() {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
// Draw image
|
||||||
|
ctx.drawImage(image, canvasOffsetX, canvasOffsetY, image.width * canvasScale, image.height * canvasScale);
|
||||||
|
|
||||||
|
// Draw completed groups
|
||||||
|
groups.forEach((group, gi) => {
|
||||||
|
const color = GROUP_COLORS[gi % GROUP_COLORS.length];
|
||||||
|
drawGroup(group.points, color);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw current group
|
||||||
|
if (currentPoints.length > 0) {
|
||||||
|
const color = GROUP_COLORS[groups.length % GROUP_COLORS.length];
|
||||||
|
drawGroup(currentPoints, color, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGroup(points, color, isCurrent) {
|
||||||
|
if (points.length === 0) return;
|
||||||
|
|
||||||
|
// Draw lines
|
||||||
|
if (points.length > 1) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
const p0 = toCanvas(points[0]);
|
||||||
|
ctx.moveTo(p0.x, p0.y);
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
const p = toCanvas(points[i]);
|
||||||
|
ctx.lineTo(p.x, p.y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw points
|
||||||
|
points.forEach((pt, i) => {
|
||||||
|
const p = toCanvas(pt);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Label
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.font = '11px sans-serif';
|
||||||
|
ctx.fillText((i + 1).toString(), p.x + 7, p.y - 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// "editing" indicator for current group
|
||||||
|
if (isCurrent) {
|
||||||
|
const last = toCanvas(points[points.length - 1]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(last.x, last.y, 10, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.setLineDash([3, 3]);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCanvas(pt) {
|
||||||
|
return {
|
||||||
|
x: pt[0] * image.width * canvasScale + canvasOffsetX,
|
||||||
|
y: pt[1] * image.height * canvasScale + canvasOffsetY
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNorm(cx, cy) {
|
||||||
|
return [
|
||||||
|
Math.round(((cx - canvasOffsetX) / (image.width * canvasScale)) * 1000) / 1000,
|
||||||
|
Math.round(((cy - canvasOffsetY) / (image.height * canvasScale)) * 1000) / 1000
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Click ---
|
||||||
|
canvas.addEventListener('click', (e) => {
|
||||||
|
if (!image) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const cx = e.clientX - rect.left;
|
||||||
|
const cy = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// Check within image bounds
|
||||||
|
const nx = (cx - canvasOffsetX) / (image.width * canvasScale);
|
||||||
|
const ny = (cy - canvasOffsetY) / (image.height * canvasScale);
|
||||||
|
if (nx < 0 || nx > 1 || ny < 0 || ny > 1) return;
|
||||||
|
|
||||||
|
const pt = toNorm(cx, cy);
|
||||||
|
currentPoints.push(pt);
|
||||||
|
redraw();
|
||||||
|
renderCoordList();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Keyboard ---
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
if (currentPoints.length === 0) return;
|
||||||
|
groups.push({ points: [...currentPoints] });
|
||||||
|
currentPoints = [];
|
||||||
|
redraw();
|
||||||
|
renderCoordList();
|
||||||
|
showToast(`第 ${groups.length} 组坐标已完成`);
|
||||||
|
} else if (e.key === 'Backspace') {
|
||||||
|
if (currentPoints.length > 0) {
|
||||||
|
currentPoints.pop();
|
||||||
|
redraw();
|
||||||
|
renderCoordList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Coord list ---
|
||||||
|
function renderCoordList() {
|
||||||
|
coordList.innerHTML = '';
|
||||||
|
if (groups.length === 0 && currentPoints.length === 0) {
|
||||||
|
coordList.innerHTML = '<div style="padding:16px;color:#4b5563;font-size:13px;text-align:center;">暂无坐标数据</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.forEach((group, gi) => {
|
||||||
|
const color = GROUP_COLORS[gi % GROUP_COLORS.length];
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'coord-group';
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="coord-group-header">
|
||||||
|
<span><span class="coord-group-color" style="background:${color}"></span>第 ${gi + 1} 组 (${group.points.length} 点)</span>
|
||||||
|
<div class="coord-group-actions">
|
||||||
|
<button class="copy-btn" data-group="${gi}" title="复制">复制</button>
|
||||||
|
<button class="del-btn" data-group="${gi}" title="删除">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre>${formatYAML(group.points)}</pre>
|
||||||
|
`;
|
||||||
|
coordList.appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Current editing group
|
||||||
|
if (currentPoints.length > 0) {
|
||||||
|
const gi = groups.length;
|
||||||
|
const color = GROUP_COLORS[gi % GROUP_COLORS.length];
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'coord-group';
|
||||||
|
div.style.borderColor = color;
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="coord-group-header">
|
||||||
|
<span><span class="coord-group-color" style="background:${color}"></span>第 ${gi + 1} 组 (编辑中, ${currentPoints.length} 点)</span>
|
||||||
|
</div>
|
||||||
|
<pre>${formatYAML(currentPoints)}</pre>
|
||||||
|
`;
|
||||||
|
coordList.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind copy/delete
|
||||||
|
coordList.querySelectorAll('.copy-btn[data-group]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const gi = parseInt(btn.dataset.group);
|
||||||
|
copyText(formatYAML(groups[gi].points));
|
||||||
|
showToast('已复制到剪贴板');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
coordList.querySelectorAll('.del-btn[data-group]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const gi = parseInt(btn.dataset.group);
|
||||||
|
groups.splice(gi, 1);
|
||||||
|
redraw();
|
||||||
|
renderCoordList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatYAML(points) {
|
||||||
|
return points.map(pt => `- [${pt[0]}, ${pt[1]}]`).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Copy all ---
|
||||||
|
document.getElementById('copyAllBtn').addEventListener('click', () => {
|
||||||
|
if (groups.length === 0) { showToast('暂无数据', true); return; }
|
||||||
|
const all = groups.map((g, i) => `# 第 ${i + 1} 组\n${formatYAML(g.points)}`).join('\n\n');
|
||||||
|
copyText(all);
|
||||||
|
showToast('已复制全部坐标');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Utils ---
|
||||||
|
function copyText(text) {
|
||||||
|
navigator.clipboard.writeText(text).catch(() => {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let toastTimer = null;
|
||||||
|
function showToast(msg, isError) {
|
||||||
|
toast.textContent = msg;
|
||||||
|
toast.style.background = isError ? '#ef4444' : '#10b981';
|
||||||
|
toast.classList.add('show');
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => toast.classList.remove('show'), 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial render
|
||||||
|
renderCoordList();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -48,6 +48,8 @@ class APIHandler(SimpleHTTPRequestHandler):
|
|||||||
elif path == '/' or path == '/index.html':
|
elif path == '/' or path == '/index.html':
|
||||||
# 默认访问使用 api=1
|
# 默认访问使用 api=1
|
||||||
self.serve_file('index.html', query='api=1')
|
self.serve_file('index.html', query='api=1')
|
||||||
|
elif path == '/coords' or path == '/coordinate.html':
|
||||||
|
self.serve_file('coordinate.html')
|
||||||
else:
|
else:
|
||||||
# 处理静态文件请求
|
# 处理静态文件请求
|
||||||
# 移除开头的 /
|
# 移除开头的 /
|
||||||
@@ -130,6 +132,7 @@ def run():
|
|||||||
httpd = ThreadingHTTPServer(server_address, APIHandler)
|
httpd = ThreadingHTTPServer(server_address, APIHandler)
|
||||||
print(f'Server running on http://localhost:{port}')
|
print(f'Server running on http://localhost:{port}')
|
||||||
print(f'支持的接口: /, /api/1, /api/2, /api/3, /api/4, /api/5, /api/6, /api/7, /api/11-16')
|
print(f'支持的接口: /, /api/1, /api/2, /api/3, /api/4, /api/5, /api/6, /api/7, /api/11-16')
|
||||||
|
print(f'坐标提取工具: /coords')
|
||||||
print('按 Ctrl+C 停止服务器')
|
print('按 Ctrl+C 停止服务器')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user