Files
hello_js_reverse_skill/tcaptcha/web_labeler.py
T
2026-09-11 22:09:35 +08:00

494 lines
14 KiB
Python

"""
极简本地 Web 点选验证码打标工具
启动方式: uv run python tcaptcha/web_labeler.py --port 8765
访问地址: http://127.0.0.1:8765
"""
import argparse
import base64
import json
import logging
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from io import BytesIO
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from PIL import Image
try:
from tcaptcha.slicer import slice_prompt_image
except ImportError:
from slicer import slice_prompt_image
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("web_labeler")
RAW_DIR = Path("tcaptcha/data/raw")
LABEL_FILE = Path("tcaptcha/data/labels.jsonl")
CROPS_DIR = Path("tcaptcha/data/crops")
def get_labeled_sample_ids() -> set:
if not LABEL_FILE.exists():
return set()
labeled = set()
with open(LABEL_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
try:
record = json.loads(line)
labeled.add(record.get("sample_id"))
except Exception:
pass
return labeled
def get_all_samples() -> list:
if not RAW_DIR.exists():
return []
meta_files = sorted(RAW_DIR.glob("*_meta.json"))
samples = []
for mf in meta_files:
try:
meta = json.loads(mf.read_text(encoding="utf-8"))
samples.append(meta)
except Exception:
pass
return samples
def get_sample_data(sample_id: str):
bg_path = RAW_DIR / f"{sample_id}_bg.png"
prompt_path = RAW_DIR / f"{sample_id}_prompt.png"
meta_path = RAW_DIR / f"{sample_id}_meta.json"
if not bg_path.exists() or not prompt_path.exists() or not meta_path.exists():
return None
meta = json.loads(meta_path.read_text(encoding="utf-8"))
# 切割提示图标
crops = slice_prompt_image(prompt_path)
sliced_b64 = []
for idx, (crop_img, _) in enumerate(crops):
buf = BytesIO()
crop_img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
sliced_b64.append(f"data:image/png;base64,{b64}")
bg_b64 = base64.b64encode(bg_path.read_bytes()).decode("utf-8")
prompt_b64 = base64.b64encode(prompt_path.read_bytes()).decode("utf-8")
return {
"sample_id": sample_id,
"instruction": meta.get("instruction", "请依次点击:"),
"bg_b64": f"data:image/png;base64,{bg_b64}",
"prompt_b64": f"data:image/png;base64,{prompt_b64}",
"sliced_prompts": sliced_b64,
}
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>腾讯天御点选验证码 - 标注工作台</title>
<style>
:root {
--bg: #0f172a;
--card: #1e293b;
--border: #334155;
--text: #f8fafc;
--muted: #94a3b8;
--accent: #38bdf8;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
}
.header {
width: 720px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.title { font-size: 20px; font-weight: 700; color: var(--accent); }
.stats { font-size: 14px; color: var(--muted); }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
width: 720px;
box-shadow: 0 10px 25px -5px rgba(0,0,0,0.5);
}
.prompt-section {
display: flex;
align-items: center;
justify-content: space-between;
background: #090d16;
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 18px;
margin-bottom: 16px;
}
.prompt-label { font-size: 15px; font-weight: 600; color: #e2e8f0; }
.targets-container {
display: flex;
gap: 16px;
align-items: center;
}
.target-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.target-badge {
font-size: 12px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
background: #334155;
color: #cbd5e1;
}
.target-item:nth-child(1) .target-badge { background: #0284c7; color: #fff; }
.target-item:nth-child(2) .target-badge { background: #16a34a; color: #fff; }
.target-item:nth-child(3) .target-badge { background: #d97706; color: #fff; }
.target-img {
height: 48px;
background: #cbd5e1;
border-radius: 4px;
padding: 2px;
}
.canvas-wrapper {
position: relative;
width: 672px;
height: 480px;
margin: 0 auto;
border-radius: 8px;
overflow: hidden;
cursor: crosshair;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
#bgCanvas {
position: absolute;
left: 0;
top: 0;
width: 672px;
height: 480px;
}
.toolbar {
margin-top: 18px;
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-group { display: flex; gap: 10px; }
button {
padding: 8px 16px;
border-radius: 6px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-primary { background: #0284c7; color: white; }
.btn-primary:hover { background: #0369a1; }
.btn-primary:disabled { background: #334155; cursor: not-allowed; opacity: 0.6; }
.btn-secondary { background: #334155; color: #cbd5e1; }
.btn-secondary:hover { background: #475569; }
.btn-danger { background: #dc2626; color: white; }
.btn-danger:hover { background: #b91c1c; }
.points-display {
font-size: 13px;
color: var(--muted);
font-family: monospace;
}
.shortcut-tip {
font-size: 12px;
color: #64748b;
margin-top: 12px;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<div class="title">🎯 腾讯天御点选验证码 · 标注工具</div>
<div class="stats" id="statsInfo">加载中...</div>
</div>
<div class="card" id="mainCard">
<div class="prompt-section">
<div class="prompt-label" id="instructionText">请依次点击:</div>
<div class="targets-container" id="targetsBox"></div>
</div>
<div class="canvas-wrapper" id="canvasWrapper">
<canvas id="bgCanvas" width="672" height="480"></canvas>
</div>
<div class="toolbar">
<div class="points-display" id="pointsText">已标记: 0 / 3 点</div>
<div class="btn-group">
<button class="btn-secondary" onclick="undoPoint()">撤销 (Z)</button>
<button class="btn-secondary" onclick="resetPoints()">重选 (R)</button>
<button class="btn-secondary" onclick="skipSample()">跳过 (S)</button>
<button class="btn-primary" id="saveBtn" onclick="submitLabel()" disabled>保存并下一题 (Enter)</button>
</div>
</div>
<div class="shortcut-tip">快捷键: 点击大图标记坐标 | [Z] 撤销 | [R] 重置 | [S] 跳过 | [Enter/Space] 保存</div>
</div>
<script>
let currentSample = null;
let clickedPoints = [];
const canvas = document.getElementById("bgCanvas");
const ctx = canvas.getContext("2d");
let bgImage = new Image();
const COLORS = ["#0284c7", "#16a34a", "#d97706"];
async function loadNext() {
const res = await fetch("/api/next");
const data = await res.json();
if (data.finished) {
alert("🎉 所有已采集样本标注完成!");
return;
}
currentSample = data.sample;
document.getElementById("statsInfo").innerText = `已标注: ${data.stats.labeled} / 总样本: ${data.stats.total}`;
document.getElementById("instructionText").innerText = currentSample.instruction || "请依次点击:";
// 渲染提示图标
const container = document.getElementById("targetsBox");
container.innerHTML = "";
currentSample.sliced_prompts.forEach((src, i) => {
const item = document.createElement("div");
item.className = "target-item";
item.innerHTML = `
<span class="target-badge">目标 ${i+1}</span>
<img class="target-img" src="${src}">
`;
container.appendChild(item);
});
// 渲染背景图
clickedPoints = [];
updateDisplay();
bgImage.onload = () => drawCanvas();
bgImage.src = currentSample.bg_b64;
}
function drawCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(bgImage, 0, 0, canvas.width, canvas.height);
// 绘制点击点
clickedPoints.forEach((pt, i) => {
const color = COLORS[i] || "#ef4444";
// 外圆
ctx.beginPath();
ctx.arc(pt.x, pt.y, 16, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = "#ffffff";
ctx.stroke();
// 内部数字
ctx.fillStyle = "#ffffff";
ctx.font = "bold 13px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(`${i+1}`, pt.x, pt.y);
});
}
canvas.addEventListener("click", (e) => {
if (!currentSample || clickedPoints.length >= 3) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = Math.round((e.clientX - rect.left) * scaleX);
const y = Math.round((e.clientY - rect.top) * scaleY);
clickedPoints.push({ x, y });
drawCanvas();
updateDisplay();
});
function updateDisplay() {
const saveBtn = document.getElementById("saveBtn");
document.getElementById("pointsText").innerText = `已标记: ${clickedPoints.length} / 3 点 ${JSON.stringify(clickedPoints.map(p => [p.x, p.y]))}`;
saveBtn.disabled = clickedPoints.length !== 3;
}
function undoPoint() {
if (clickedPoints.length > 0) {
clickedPoints.pop();
drawCanvas();
updateDisplay();
}
}
function resetPoints() {
clickedPoints = [];
drawCanvas();
updateDisplay();
}
function skipSample() {
loadNext();
}
async function submitLabel() {
if (clickedPoints.length !== 3) return;
const payload = {
sample_id: currentSample.sample_id,
points: clickedPoints,
};
const res = await fetch("/api/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (res.ok) {
loadNext();
} else {
alert("保存失败,请检查终端日志");
}
}
window.addEventListener("keydown", (e) => {
if (e.key === "z" || e.key === "Z") undoPoint();
if (e.key === "r" || e.key === "R") resetPoints();
if (e.key === "s" || e.key === "S") skipSample();
if (e.key === "Enter" || e.key === " ") {
if (clickedPoints.length === 3) submitLabel();
}
});
loadNext();
</script>
</body>
</html>
"""
class LabelHandler(BaseHTTPRequestHandler):
def do_GET(self):
url = urlparse(self.path)
if url.path in ["/", "/index.html"]:
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(HTML_TEMPLATE.encode("utf-8"))
return
if url.path == "/api/next":
all_samples = get_all_samples()
labeled_ids = get_labeled_sample_ids()
unlabeled = [s for s in all_samples if s.get("sample_id") not in labeled_ids]
if not unlabeled:
resp_data = {"finished": True, "stats": {"total": len(all_samples), "labeled": len(labeled_ids)}}
else:
next_sample = unlabeled[0]
sample_data = get_sample_data(next_sample["sample_id"])
resp_data = {
"finished": False,
"sample": sample_data,
"stats": {"total": len(all_samples), "labeled": len(labeled_ids)},
}
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps(resp_data).encode("utf-8"))
return
self.send_response(404)
self.end_headers()
def do_POST(self):
url = urlparse(self.path)
if url.path == "/api/save":
content_len = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_len).decode("utf-8")
data = json.loads(body)
sample_id = data.get("sample_id")
points = data.get("points", [])
if not sample_id or len(points) != 3:
self.send_response(400)
self.end_headers()
return
LABEL_FILE.parent.mkdir(parents=True, exist_ok=True)
CROPS_DIR.mkdir(parents=True, exist_ok=True)
# 保存切片作为正样本
bg_path = RAW_DIR / f"{sample_id}_bg.png"
if bg_path.exists():
bg_img = Image.open(bg_path).convert("RGBA")
for i, pt in enumerate(points):
cx, cy = pt["x"], pt["y"]
# 裁剪 54x54 小图块
crop_box = (max(0, cx - 27), max(0, cy - 27), min(672, cx + 27), min(480, cy + 27))
crop_patch = bg_img.crop(crop_box)
crop_patch.save(CROPS_DIR / f"{sample_id}_target_{i+1}.png")
# 写入 labels.jsonl
record = {
"sample_id": sample_id,
"points": points,
"ans_format": [{"elem_id": i + 1, "type": "DynAnswerType_POS", "data": f"{p['x']},{p['y']}"} for i, p in enumerate(points)],
}
with open(LABEL_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
logger.info("✓ 成功保存样本标注: %s -> %s", sample_id, points)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"status":"ok"}')
return
self.send_response(404)
self.end_headers()
def main():
parser = argparse.ArgumentParser(description="腾讯点选验证码轻量 Web 打标工具")
parser.add_argument("--port", type=int, default=8765, help="本地服务端口 (默认: 8765)")
args = parser.parse_args()
server = ThreadingHTTPServer(("127.0.0.1", args.port), LabelHandler)
logger.info("🚀 打标工作台已启动: http://127.0.0.1:%d", args.port)
logger.info("在浏览器打开上述地址即可开始打标,按 Ctrl+C 停止")
try:
server.serve_forever()
except KeyboardInterrupt:
logger.info("打标服务已停止")
if __name__ == "__main__":
main()