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

1280 lines
40 KiB
Python

"""
腾讯天御点选验证码 · 现代化半自动人机协同打标工作台 (Web Labeler v2.2)
功能特性:
1. 📥 在线按需采集: 支持单组采集 (快捷键 F) 或批量拉取 5 组样本
2. 🤖 AI 半自动预标注: 自动调用 Solver v2.1 端到端求解,直观呈现 1-2-3 预测点击点
3. 👁️ 零遮挡视觉设计: 采用相机外角取景框与外浮动序号徽标,图标本体 100% 清晰无遮挡,支持 H 键一键隐藏/预览原图
4. 🔍 待审核状态筛选: 支持一键筛选【仅看待审核】、【全部】、【已审核】,核准后自动流转下一组待审样本
5. 🔍 全新图标监测: 自动比对已知模板,发现未见过的全新小图类型即时高亮预警并支持一键收录
6. 📊 实时数据看板: 样本总量、已核准量、待审量、基准小图类型数量动态统计
"""
import argparse
import base64
import json
import logging
import random
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from io import BytesIO
from pathlib import Path
from urllib.parse import parse_qs, urlparse
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
import cv2
import numpy as np
from PIL import Image
from tcaptcha.collector import TencentCaptchaCollector
from tcaptcha.slicer import slice_prompt_image
from tcaptcha.solver import CaptchaSolver
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("web_labeler")
RAW_DIR = PROJECT_ROOT / "tcaptcha/data/raw"
LABEL_FILE = PROJECT_ROOT / "tcaptcha/data/labels.jsonl"
CROPS_DIR = PROJECT_ROOT / "tcaptcha/data/crops"
TEMPLATES_DIR = PROJECT_ROOT / "tcaptcha/data/templates"
GLOBAL_SOLVER = None
GLOBAL_COLLECTOR = None
def get_solver() -> CaptchaSolver:
global GLOBAL_SOLVER
if GLOBAL_SOLVER is None:
logger.info("正在加载 AI 求解推理引擎...")
GLOBAL_SOLVER = CaptchaSolver()
return GLOBAL_SOLVER
def get_collector() -> TencentCaptchaCollector:
global GLOBAL_COLLECTOR
if GLOBAL_COLLECTOR is None:
GLOBAL_COLLECTOR = TencentCaptchaCollector()
return GLOBAL_COLLECTOR
def get_labeled_records() -> dict:
if not LABEL_FILE.exists():
return {}
records = {}
with open(LABEL_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
try:
data = json.loads(line)
records[data["sample_id"]] = data
except Exception:
pass
return records
def get_all_sample_ids() -> list:
if not RAW_DIR.exists():
return []
meta_files = sorted(RAW_DIR.glob("*_meta.json"), key=lambda p: p.stat().st_mtime)
return [p.name.replace("_meta.json", "") for p in meta_files]
def get_known_templates():
if not TEMPLATES_DIR.exists():
return []
templates = []
for p in sorted(TEMPLATES_DIR.glob("class_*.png"), key=lambda x: int(x.stem.split("_")[1])):
cid = int(p.stem.split("_")[1])
img = Image.open(p).convert("L")
arr = np.array(img)
bg = np.median(arr)
bin_t = cv2.resize(((arr < bg - 20).astype(np.uint8) * 255), (48, 48), interpolation=cv2.INTER_AREA)
templates.append({"cid": cid, "path": str(p), "bin": bin_t})
return templates
def analyze_prompt_icons(prompt_path: Path):
templates = get_known_templates()
crops = slice_prompt_image(prompt_path)
analysis = []
for crop_img, _ in crops[:3]:
buf = BytesIO()
crop_img.save(buf, format="PNG")
b64 = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode('utf-8')}"
arr = np.array(crop_img.convert("L"))
bg = np.median(arr)
bin_c = cv2.resize(((arr < bg - 20).astype(np.uint8) * 255), (48, 48), interpolation=cv2.INTER_AREA)
best_score = -1.0
best_cid = -1
for t in templates:
score = float(cv2.matchTemplate(bin_c, t["bin"], cv2.TM_CCOEFF_NORMED)[0, 0])
if score > best_score:
best_score = score
best_cid = t["cid"]
is_new = best_score < 0.70
analysis.append({
"b64": b64,
"cid": best_cid if not is_new else None,
"score": round(best_score, 3),
"is_new": is_new,
})
return analysis
def get_sample_detail(sample_id: str, run_ai: bool = True):
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():
return None
instruction = "请依次点击:"
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
instruction = meta.get("instruction", instruction)
except Exception:
pass
bg_b64 = f"data:image/png;base64,{base64.b64encode(bg_path.read_bytes()).decode('utf-8')}"
prompt_b64 = f"data:image/png;base64,{base64.b64encode(prompt_path.read_bytes()).decode('utf-8')}"
prompt_icons = analyze_prompt_icons(prompt_path)
labeled_records = get_labeled_records()
is_labeled = sample_id in labeled_records
current_points = labeled_records[sample_id]["points"] if is_labeled else []
ai_points = []
ai_latency_ms = 0
if run_ai:
try:
solver = get_solver()
t0 = time.perf_counter()
pts = solver.solve(bg_path, prompt_path)
ai_latency_ms = round((time.perf_counter() - t0) * 1000, 1)
ai_points = [{"x": p[0], "y": p[1]} for p in pts]
except Exception as e:
logger.warning("AI 求解异常: %s", e)
return {
"sample_id": sample_id,
"instruction": instruction,
"bg_b64": bg_b64,
"prompt_b64": prompt_b64,
"prompt_icons": prompt_icons,
"is_labeled": is_labeled,
"current_points": current_points,
"ai_points": ai_points,
"ai_latency_ms": ai_latency_ms,
"has_new_icon": any(p["is_new"] for p in prompt_icons),
}
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-main: #090d16;
--bg-card: #131b2e;
--bg-hover: #1e293b;
--border: #23314d;
--text: #f8fafc;
--muted: #94a3b8;
--accent: #38bdf8;
--accent-glow: rgba(56, 189, 248, 0.25);
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--color-p1: #38bdf8;
--color-p2: #22c55e;
--color-p3: #f59e0b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg-main);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: 14px 20px;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
}
.header {
width: 100%;
max-width: 1100px;
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
margin-bottom: 14px;
}
.title-wrap h1 {
font-size: 19px;
font-weight: 700;
color: #fff;
display: flex;
align-items: center;
gap: 8px;
}
.badge-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
font-weight: 600;
background: rgba(56, 189, 248, 0.15);
color: var(--accent);
border: 1px solid rgba(56, 189, 248, 0.3);
}
/* 筛选分段器 */
.filter-tabs {
display: flex;
background: #0b1120;
padding: 3px;
border-radius: 8px;
border: 1px solid var(--border);
gap: 4px;
}
.tab-btn {
border: none;
background: transparent;
color: var(--muted);
padding: 5px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 6px;
}
.tab-btn:hover { color: #fff; background: var(--bg-hover); }
.tab-btn.active {
background: #0284c7;
color: #fff;
box-shadow: 0 2px 8px rgba(2, 132, 199, 0.4);
}
.tab-btn b {
font-size: 11px;
padding: 1px 5px;
border-radius: 999px;
background: rgba(0,0,0,0.3);
}
/* 主操作台卡片 */
.main-card {
width: 100%;
max-width: 1100px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
/* 顶栏操作区 */
.top-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
gap: 10px;
}
.btn-group { display: flex; gap: 8px; align-items: center; }
button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 13px;
border-radius: 6px;
border: 1px solid var(--border);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
background: var(--bg-hover);
color: var(--text);
}
button:hover { background: #334155; border-color: #475569; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: #0284c7; border-color: #0284c7; color: #fff; }
.btn-primary:hover { background: #0369a1; }
.btn-success { background: #16a34a; border-color: #16a34a; color: #fff; }
.btn-success:hover { background: #15803d; }
.btn-warning { background: #d97706; border-color: #d97706; color: #fff; }
.btn-warning:hover { background: #b45309; }
/* 提示目标展示区 */
.prompt-panel {
background: #0b1120;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 16px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.prompt-left {
display: flex;
align-items: center;
gap: 14px;
}
.instruction-text {
font-size: 13px;
font-weight: 600;
color: var(--muted);
}
.targets-container {
display: flex;
gap: 16px;
align-items: center;
}
.target-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
position: relative;
}
.target-badge {
font-size: 11px;
font-weight: 700;
padding: 2px 7px;
border-radius: 4px;
color: #fff;
}
.target-badge.p1 { background: #0284c7; }
.target-badge.p2 { background: #16a34a; }
.target-badge.p3 { background: #ea580c; }
.target-img-wrap {
height: 46px;
background: #cbd5e1;
border-radius: 6px;
padding: 2px;
border: 2px solid transparent;
transition: all 0.2s;
}
.target-img-wrap.new-type {
border-color: #f59e0b;
box-shadow: 0 0 10px rgba(245, 158, 11, 0.4);
}
.target-img-wrap img { height: 100%; display: block; }
.cls-desc {
font-size: 10px;
color: var(--muted);
font-family: monospace;
}
.new-tag {
font-size: 10px;
background: #f59e0b;
color: #000;
font-weight: 700;
padding: 1px 4px;
border-radius: 3px;
}
/* 核心画布交互区 */
.canvas-container {
position: relative;
width: 672px;
height: 480px;
margin: 0 auto;
border-radius: 8px;
overflow: hidden;
cursor: crosshair;
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
border: 1px solid var(--border);
user-select: none;
}
#bgCanvas {
position: absolute;
left: 0;
top: 0;
width: 672px;
height: 480px;
}
.coord-tip {
position: absolute;
bottom: 8px;
right: 12px;
background: rgba(0,0,0,0.7);
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
color: #cbd5e1;
font-family: monospace;
pointer-events: none;
}
/* 状态与核准横幅 */
.status-banner {
margin-top: 14px;
background: #0b1120;
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 14px;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-banner.ai-ready {
border-color: rgba(56, 189, 248, 0.4);
background: rgba(56, 189, 248, 0.05);
}
.status-banner.approved {
border-color: rgba(34, 197, 94, 0.4);
background: rgba(34, 197, 94, 0.05);
}
.banner-msg {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
}
.banner-actions { display: flex; gap: 8px; }
/* 快捷键 cheatsheet */
.shortcuts-bar {
width: 100%;
max-width: 1100px;
margin-top: 12px;
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 16px;
font-size: 12px;
color: var(--muted);
}
.kbd {
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 6px;
font-size: 11px;
color: var(--text);
font-weight: 600;
}
</style>
</head>
<body>
<div class="header">
<div class="title-wrap">
<h1>🎯 腾讯天御点选验证码 · 半自动人机协同打标工作台 <span class="badge-tag">v2.2 Zero-Occlusion</span></h1>
</div>
<!-- 筛选分段切换器 -->
<div class="filter-tabs">
<button id="tabUnlabeled" class="tab-btn active" onclick="switchFilter('unlabeled')">
<span>⏳ 待审核</span> <b id="cntUnlabeled">-</b>
</button>
<button id="tabAll" class="tab-btn" onclick="switchFilter('all')">
<span>📋 全部</span> <b id="cntAll">-</b>
</button>
<button id="tabLabeled" class="tab-btn" onclick="switchFilter('labeled')">
<span>✅ 已核准</span> <b id="cntLabeled">-</b>
</button>
</div>
</div>
<div class="main-card">
<!-- 顶部采集控制栏 -->
<div class="top-actions">
<div class="btn-group">
<button id="btnFetchOne" class="btn-primary" onclick="fetchNewSample(1)">
<span>📥 采集新样本</span> <span class="kbd">F</span>
</button>
<button id="btnFetchBatch" onclick="fetchNewSample(5)">
<span>⚡ 批量拉取 5 组</span>
</button>
<button id="btnToggleOverlay" onclick="toggleOverlay()">
<span id="overlayBtnText">👁️ 隐藏标记框</span> <span class="kbd">H</span>
</button>
<button id="btnRunAi" onclick="triggerAiPredict()">
<span>🤖 重新 AI 求解</span> <span class="kbd">A</span>
</button>
</div>
<div class="btn-group">
<button onclick="prevSample()"><span>⬅️ 上一组</span> <span class="kbd">P</span></button>
<button onclick="nextSample()"><span>➡️ 下一组</span> <span class="kbd">N</span></button>
<span id="sampleIdDisplay" style="font-size: 12px; font-family: monospace; color: var(--muted); margin-left: 8px;"></span>
</div>
</div>
<!-- 提示图与待点选图标切片展示 -->
<div class="prompt-panel">
<div class="prompt-left">
<div class="instruction-text" id="instructionText">请依次点击:</div>
<div class="targets-container" id="targetsContainer">
<!-- 动态渲染 3 个目标切片 -->
</div>
</div>
<div id="newIconAlert" style="display: none; align-items: center; gap: 8px;">
<span class="new-tag">发现全新未知小图!</span>
<button class="btn-warning" style="font-size: 11px; padding: 4px 8px;" onclick="registerNewTemplates()">
⭐ 收录到模板库
</button>
</div>
</div>
<!-- 背景大图与交互打标画布 (672x480) -->
<div class="canvas-container" id="canvasContainer">
<canvas id="bgCanvas" width="672" height="480"></canvas>
<div class="coord-tip" id="coordTip">X: 0, Y: 0</div>
</div>
<!-- 底部核准状态横幅 -->
<div class="status-banner ai-ready" id="statusBanner">
<div class="banner-msg" id="bannerMsg">
<span>🤖 AI 已预标注完毕 — 请核对,无误请按【空格/回车】确认入库!</span>
</div>
<div class="banner-actions">
<button onclick="resetPoints()"><span>清空重置</span> <span class="kbd">R</span></button>
<button onclick="undoPoint()"><span>撤销上一点</span> <span class="kbd">Z</span></button>
<button id="btnSubmit" class="btn-success" onclick="confirmAndSave()">
<span>✅ 核准确认并下一组</span> <span class="kbd">Space / Enter</span>
</button>
</div>
</div>
</div>
<div class="shortcuts-bar">
<span><span class="kbd">Space / Enter</span> 确认核准并切换</span>
<span><span class="kbd">F</span> 在线采集新样本</span>
<span><span class="kbd">H</span> 隐藏/显示标记框 (临时看原图)</span>
<span><span class="kbd">U</span> 切换仅看待审核</span>
<span><span class="kbd">鼠标点击/拖动</span> 拖动外框或准心微调</span>
<span><span class="kbd">R</span> 清空重标</span>
<span><span class="kbd">P / N</span> 上下翻阅</span>
</div>
<script>
let currentFilter = "unlabeled"; // "unlabeled" | "all" | "labeled"
let allSampleIds = [];
let unlabeledIds = [];
let labeledIds = [];
let activeSampleList = [];
let currentSampleIndex = 0;
let currentSample = null;
let clickedPoints = []; // [{x, y}, ...]
let isDragging = false;
let dragPointIndex = -1;
let hideAnnotations = false;
let bgImg = new Image();
const canvas = document.getElementById("bgCanvas");
const ctx = canvas.getContext("2d");
const coordTip = document.getElementById("coordTip");
// 高对比明亮色调: 1-青蓝, 2-翠绿, 3-亮橙
const themeColors = ["#38bdf8", "#22c55e", "#f59e0b"];
async function init() {
await refreshStats();
// 默认优先进入待审核模式
if (unlabeledIds.length > 0) {
switchFilter("unlabeled");
} else {
switchFilter("all");
}
}
async function refreshStats() {
try {
const res = await fetch("/api/stats");
const data = await res.json();
allSampleIds = data.sample_ids || [];
unlabeledIds = data.unlabeled_ids || [];
labeledIds = data.labeled_ids || [];
const elU = document.getElementById("cntUnlabeled");
if (elU) elU.innerText = unlabeledIds.length;
const elA = document.getElementById("cntAll");
if (elA) elA.innerText = allSampleIds.length;
const elL = document.getElementById("cntLabeled");
if (elL) elL.innerText = labeledIds.length;
} catch (err) {
console.error("refreshStats error:", err);
}
}
function switchFilter(filter) {
currentFilter = filter;
// 更新 Tab 按钮状态
document.getElementById("tabUnlabeled").className = "tab-btn" + (filter === "unlabeled" ? " active" : "");
document.getElementById("tabAll").className = "tab-btn" + (filter === "all" ? " active" : "");
document.getElementById("tabLabeled").className = "tab-btn" + (filter === "labeled" ? " active" : "");
if (filter === "unlabeled") {
activeSampleList = unlabeledIds;
} else if (filter === "labeled") {
activeSampleList = labeledIds;
} else {
activeSampleList = allSampleIds;
}
currentSampleIndex = 0;
if (activeSampleList.length > 0) {
loadSample(activeSampleList[0]);
} else {
showEmptyFilterNotice();
}
}
function showEmptyFilterNotice() {
currentSample = null;
clickedPoints = [];
ctx.clearRect(0, 0, 672, 480);
ctx.fillStyle = "#0b1120";
ctx.fillRect(0, 0, 672, 480);
ctx.fillStyle = "#94a3b8";
ctx.font = "16px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const msg = currentFilter === "unlabeled"
? "🎉 当前没有待审核的样本!按【F】键可在线采集新样本"
: "当前分类下暂无样本";
ctx.fillText(msg, 336, 240);
const elId = document.getElementById("sampleIdDisplay");
if (elId) elId.innerText = "[0/0] 无样本";
updateBannerStatus("normal", msg);
}
async function loadSample(sampleId) {
const res = await fetch(`/api/sample?id=${sampleId}`);
const data = await res.json();
if (!data || !data.sample_id) return;
currentSample = data;
currentSampleIndex = activeSampleList.indexOf(sampleId);
renderSampleUI();
}
function prevSample() {
if (activeSampleList.length === 0) return;
if (currentSampleIndex > 0) {
currentSampleIndex--;
loadSample(activeSampleList[currentSampleIndex]);
} else {
currentSampleIndex = activeSampleList.length - 1;
loadSample(activeSampleList[currentSampleIndex]);
}
}
function nextSample() {
if (activeSampleList.length === 0) return;
if (currentSampleIndex < activeSampleList.length - 1) {
currentSampleIndex++;
loadSample(activeSampleList[currentSampleIndex]);
} else {
// 若在待审核模式且已是最后一个
if (currentFilter === "unlabeled") {
refreshStats().then(() => {
if (unlabeledIds.length > 0) {
switchFilter("unlabeled");
} else {
switchFilter("all");
}
});
} else {
// 循环回第一个
currentSampleIndex = 0;
loadSample(activeSampleList[0]);
}
}
}
function renderSampleUI() {
const filterLabel = currentFilter === "unlabeled" ? "待审核" : (currentFilter === "labeled" ? "已核准" : "全部");
const elId = document.getElementById("sampleIdDisplay");
if (elId) elId.innerText = `[${filterLabel} ${currentSampleIndex + 1}/${activeSampleList.length}] ${currentSample.sample_id}`;
const elInst = document.getElementById("instructionText");
if (elInst) elInst.innerText = currentSample.instruction || "请依次点击:";
const tContainer = document.getElementById("targetsContainer");
tContainer.innerHTML = "";
let hasNew = false;
currentSample.prompt_icons.forEach((icon, i) => {
const wrap = document.createElement("div");
wrap.className = "target-box";
const badge = `<div class="target-badge p${i+1}">${i+1}</div>`;
const newClass = icon.is_new ? "new-type" : "";
if (icon.is_new) hasNew = true;
const imgWrap = `<div class="target-img-wrap ${newClass}"><img src="${icon.b64}"></div>`;
const desc = icon.is_new
? `<span class="new-tag">全新类型</span>`
: `<span class="cls-desc">Class ${icon.cid}</span>`;
wrap.innerHTML = badge + imgWrap + desc;
tContainer.appendChild(wrap);
});
const elAlert = document.getElementById("newIconAlert");
if (elAlert) elAlert.style.display = hasNew ? "flex" : "none";
if (currentSample.is_labeled && currentSample.current_points.length === 3) {
clickedPoints = JSON.parse(JSON.stringify(currentSample.current_points));
updateBannerStatus("approved", "🟢 该样本已完成人工核准!如需微调可直接拖动后按【空格/回车】更新");
} else if (currentSample.ai_points && currentSample.ai_points.length === 3) {
clickedPoints = JSON.parse(JSON.stringify(currentSample.ai_points));
updateBannerStatus("ai-ready", `🤖 AI 已自动预标注 (耗时 ${currentSample.ai_latency_ms}ms) — 识别框已就绪,按【空格/回车】一键确认入库!`);
} else {
clickedPoints = [];
updateBannerStatus("normal", "⚪ 暂无预测点,请人工依次在背景图中点击 3 个目标位置");
}
bgImg = new Image();
bgImg.onload = () => { drawCanvas(); };
bgImg.src = currentSample.bg_b64;
}
function updateBannerStatus(type, msg) {
const banner = document.getElementById("statusBanner");
if (banner) banner.className = "status-banner " + type;
const msgEl = document.getElementById("bannerMsg");
if (msgEl) msgEl.innerText = msg;
}
function toggleOverlay() {
hideAnnotations = !hideAnnotations;
const btn = document.getElementById("overlayBtnText");
if (btn) btn.innerText = hideAnnotations ? "👁️ 显示标记框" : "👁️ 隐藏标记框";
drawCanvas();
}
/**
* 核心画布绘制逻辑:完全避免数字、边框与图标重叠!
* 1. 图标内部 100% 保持纯净透明,不进行任何填色
* 2. 采用精巧相机取景框外角线 (Corner Brackets) 环绕 54x54 图标
* 3. 序号徽标 1-2-3 悬浮于图标外上方左侧,坐标文本悬浮于图标外下方
* 4. 中心仅保留极细微的 2px 准心圆点
*/
function drawCanvas() {
ctx.clearRect(0, 0, 672, 480);
if (bgImg.complete) {
ctx.drawImage(bgImg, 0, 0, 672, 480);
}
if (hideAnnotations) return;
const boxHalf = 27; // 图标大致 54x54
const cornerLen = 8; // 四角取景框长度
clickedPoints.forEach((pt, i) => {
const col = themeColors[i] || "#38bdf8";
const x1 = pt.x - boxHalf;
const y1 = pt.y - boxHalf;
const x2 = pt.x + boxHalf;
const y2 = pt.y + boxHalf;
// 1. 绘制四角取景框 (Corner Brackets, 绝不遮挡中间任何笔画)
ctx.lineWidth = 2.5;
ctx.strokeStyle = col;
ctx.shadowColor = "rgba(0,0,0,0.8)";
ctx.shadowBlur = 4;
ctx.beginPath();
// 左上角 ┌
ctx.moveTo(x1, y1 + cornerLen);
ctx.lineTo(x1, y1);
ctx.lineTo(x1 + cornerLen, y1);
// 右上角 ┐
ctx.moveTo(x2 - cornerLen, y1);
ctx.lineTo(x2, y1);
ctx.lineTo(x2, y1 + cornerLen);
// 左下角 └
ctx.moveTo(x1, y2 - cornerLen);
ctx.lineTo(x1, y2);
ctx.lineTo(x1 + cornerLen, y2);
// 右下角 ┘
ctx.moveTo(x2 - cornerLen, y2);
ctx.lineTo(x2, y2);
ctx.lineTo(x2, y2 - cornerLen);
ctx.stroke();
// 2. 细微虚线边框 (透明度极低,仅作对齐辅助)
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.strokeStyle = col + "66";
ctx.strokeRect(x1, y1, boxHalf * 2, boxHalf * 2);
ctx.setLineDash([]);
ctx.shadowBlur = 0;
// 3. 中心微型准心 (仅 2.5px,精确定位且不挡视线)
ctx.beginPath();
ctx.arc(pt.x, pt.y, 2.5, 0, Math.PI * 2);
ctx.fillStyle = "#ffffff";
ctx.fill();
ctx.strokeStyle = "#000000";
ctx.lineWidth = 1;
ctx.stroke();
// 4. 序号圆形徽标 —— 悬浮于图标左上方外侧 (绝不与图标重合!)
const badgeX = x1 - 4;
const badgeY = y1 - 8;
const badgeR = 9;
ctx.beginPath();
ctx.arc(badgeX, badgeY, badgeR, 0, Math.PI * 2);
ctx.fillStyle = col;
ctx.fill();
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = "#ffffff";
ctx.font = "bold 11px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText((i + 1).toString(), badgeX, badgeY);
// 5. 坐标微型胶囊 —— 悬浮于图标正下方外侧
const coordText = `(${pt.x},${pt.y})`;
ctx.font = "9px monospace";
const textWidth = ctx.measureText(coordText).width;
const pillW = textWidth + 8;
const pillH = 15;
const pillX = pt.x - pillW / 2;
const pillY = y2 + 6;
ctx.fillStyle = "rgba(15, 23, 42, 0.85)";
ctx.beginPath();
ctx.roundRect(pillX, pillY, pillW, pillH, 4);
ctx.fill();
ctx.strokeStyle = col + "99";
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = "#cbd5e1";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(coordText, pt.x, pillY + pillH / 2);
});
}
// 鼠标交互: 点击与拖拽
canvas.addEventListener("mousemove", (e) => {
const rect = canvas.getBoundingClientRect();
const x = Math.round(e.clientX - rect.left);
const y = Math.round(e.clientY - rect.top);
coordTip.innerText = `X: ${x}, Y: ${y}`;
if (isDragging && dragPointIndex !== -1) {
clickedPoints[dragPointIndex] = { x, y };
drawCanvas();
updateBannerStatus("normal", `✏️ 人工微调中: 目标 ${dragPointIndex + 1} 坐标 -> (${x}, ${y})`);
return;
}
// 鼠标悬停在点附近时变成抓手光标
let nearPoint = false;
for (let i = 0; i < clickedPoints.length; i++) {
if (Math.hypot(clickedPoints[i].x - x, clickedPoints[i].y - y) <= 30) {
nearPoint = true;
break;
}
}
canvas.style.cursor = nearPoint ? "grab" : "crosshair";
});
canvas.addEventListener("mousedown", (e) => {
const rect = canvas.getBoundingClientRect();
const x = Math.round(e.clientX - rect.left);
const y = Math.round(e.clientY - rect.top);
// 检查是否点中已有标记框进行拖拽
for (let i = 0; i < clickedPoints.length; i++) {
const d = Math.hypot(clickedPoints[i].x - x, clickedPoints[i].y - y);
if (d <= 30) {
isDragging = true;
dragPointIndex = i;
canvas.style.cursor = "grabbing";
return;
}
}
// 若点击空白处且点数不足 3 个,追加新点
if (clickedPoints.length < 3) {
clickedPoints.push({ x, y });
drawCanvas();
if (clickedPoints.length === 3) {
updateBannerStatus("normal", "✏️ 已完成 3 点标注,按【空格/回车】核准保存!");
}
}
});
window.addEventListener("mouseup", () => {
isDragging = false;
dragPointIndex = -1;
canvas.style.cursor = "crosshair";
});
function resetPoints() {
clickedPoints = [];
drawCanvas();
updateBannerStatus("normal", "⚪ 已清空所有标记,请重新依次点击 3 个目标位置");
}
function undoPoint() {
if (clickedPoints.length > 0) {
clickedPoints.pop();
drawCanvas();
}
}
async function confirmAndSave() {
if (!currentSample || clickedPoints.length !== 3) {
alert("请确保已标注完成 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) {
await refreshStats();
nextSample();
} else {
alert("保存失败,请检查服务终端输出!");
}
}
async function fetchNewSample(count = 1) {
const btn = document.getElementById("btnFetchOne");
if (btn) {
btn.disabled = true;
btn.innerText = "⏳ 采集中...";
}
try {
const res = await fetch("/api/fetch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ count })
});
const data = await res.json();
if (data.status === "ok" && data.fetched_ids.length > 0) {
await refreshStats();
// 自动切换至待审核模式并直接载入最新拉取的样本
currentFilter = "unlabeled";
document.getElementById("tabUnlabeled").className = "tab-btn active";
document.getElementById("tabAll").className = "tab-btn";
document.getElementById("tabLabeled").className = "tab-btn";
activeSampleList = unlabeledIds;
await loadSample(data.fetched_ids[0]);
} else {
alert("采集失败: " + (data.msg || "网络或接口异常"));
}
} catch (err) {
alert("请求接口出错: " + err);
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = `<span>📥 采集新样本</span> <span class="kbd">F</span>`;
}
}
}
async function triggerAiPredict() {
if (!currentSample) return;
const res = await fetch(`/api/sample?id=${currentSample.sample_id}&force_ai=1`);
const data = await res.json();
if (data.ai_points && data.ai_points.length === 3) {
clickedPoints = JSON.parse(JSON.stringify(data.ai_points));
drawCanvas();
updateBannerStatus("ai-ready", `🤖 AI 重新求解完成 (耗时 ${data.ai_latency_ms}ms),请核准!`);
}
}
async function registerNewTemplates() {
if (!currentSample) return;
try {
const res = await fetch("/api/register_template", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sample_id: currentSample.sample_id })
});
const data = await res.json();
if (data.status === "ok") {
alert(`已成功收录 ${data.added_count} 个新小图至基准模板库!当前模板总数: ${data.total_templates}`);
await refreshStats();
await loadSample(currentSample.sample_id);
} else {
alert("收录未完成: " + (data.msg || "未检测到符合条件的新图标"));
}
} catch (e) {
alert("收录模板请求出错: " + e);
}
}
window.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
if (e.key === "f" || e.key === "F") {
e.preventDefault();
fetchNewSample(1);
} else if (e.key === "h" || e.key === "H") {
e.preventDefault();
toggleOverlay();
} else if (e.key === "u" || e.key === "U") {
e.preventDefault();
switchFilter("unlabeled");
} else if (e.key === "a" || e.key === "A") {
e.preventDefault();
triggerAiPredict();
} else if (e.key === "r" || e.key === "R") {
e.preventDefault();
resetPoints();
} else if (e.key === "z" || e.key === "Z") {
e.preventDefault();
undoPoint();
} else if (e.key === "p" || e.key === "P") {
e.preventDefault();
prevSample();
} else if (e.key === "n" || e.key === "N") {
e.preventDefault();
nextSample();
} else if (e.key === " " || e.key === "Enter") {
e.preventDefault();
if (clickedPoints.length === 3) {
confirmAndSave();
}
}
});
init();
</script>
</body>
</html>
"""
class WebLabelerHandler(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/stats":
all_ids = get_all_sample_ids()
labeled_map = get_labeled_records()
templates = get_known_templates()
unlabeled_ids = [s for s in all_ids if s not in labeled_map]
labeled_ids = [s for s in all_ids if s in labeled_map]
resp = {
"total": len(all_ids),
"labeled": len(labeled_ids),
"unlabeled": len(unlabeled_ids),
"template_count": len(templates),
"sample_ids": all_ids,
"unlabeled_ids": unlabeled_ids,
"labeled_ids": 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).encode("utf-8"))
return
if url.path == "/api/sample":
params = parse_qs(url.query)
sid = params.get("id", [None])[0]
if not sid:
all_ids = get_all_sample_ids()
labeled_map = get_labeled_records()
unlabeled = [s for s in all_ids if s not in labeled_map]
sid = unlabeled[0] if unlabeled else (all_ids[0] if all_ids else None)
if not sid:
self.send_response(404)
self.end_headers()
return
detail = get_sample_detail(sid, run_ai=True)
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps(detail).encode("utf-8"))
return
self.send_response(404)
self.end_headers()
def do_POST(self):
url = urlparse(self.path)
if url.path == "/api/fetch":
content_len = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(content_len).decode("utf-8")) if content_len > 0 else {}
count = int(body.get("count", 1))
collector = get_collector()
fetched = []
for _ in range(count):
sid = collector.fetch_one()
if sid:
fetched.append(sid)
time.sleep(0.3)
resp = {"status": "ok", "fetched_ids": fetched, "count": len(fetched)}
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps(resp).encode("utf-8"))
return
if url.path == "/api/save":
content_len = int(self.headers.get("Content-Length", 0))
data = json.loads(self.rfile.read(content_len).decode("utf-8"))
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"]
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")
existing_records = get_labeled_records()
existing_records[sample_id] = {
"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, "w", encoding="utf-8") as f:
for rec in existing_records.values():
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
logger.info("✓ 样本核准保存: %s -> %s", sample_id, points)
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(b'{"status":"ok"}')
return
if url.path == "/api/register_template":
content_len = int(self.headers.get("Content-Length", 0))
data = json.loads(self.rfile.read(content_len).decode("utf-8")) if content_len > 0 else {}
sample_id = data.get("sample_id")
if not sample_id:
self.send_response(400)
self.end_headers()
return
prompt_path = RAW_DIR / f"{sample_id}_prompt.png"
if not prompt_path.exists():
self.send_response(404)
self.end_headers()
return
crops = slice_prompt_image(prompt_path)
templates = get_known_templates()
added_count = 0
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
for crop_img, _ in crops[:3]:
arr = np.array(crop_img.convert("L"))
bg = np.median(arr)
bin_c = cv2.resize(((arr < bg - 20).astype(np.uint8) * 255), (48, 48), interpolation=cv2.INTER_AREA)
best_score = -1.0
for t in templates:
score = float(cv2.matchTemplate(bin_c, t["bin"], cv2.TM_CCOEFF_NORMED)[0, 0])
if score > best_score:
best_score = score
if best_score < 0.70:
new_cid = len(templates)
p = TEMPLATES_DIR / f"class_{new_cid}.png"
crop_img.save(p)
templates.append({"cid": new_cid, "path": str(p), "bin": bin_c})
added_count += 1
logger.info("⭐ 成功收录全新基准图标模板: class_%d.png", new_cid)
global GLOBAL_SOLVER
GLOBAL_SOLVER = None
resp = {"status": "ok", "added_count": added_count, "total_templates": len(templates)}
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps(resp).encode("utf-8"))
return
self.send_response(404)
self.end_headers()
def main():
parser = argparse.ArgumentParser(description="腾讯点选验证码半自动人机协同打标工作台")
parser.add_argument("--port", type=int, default=8765, help="本地服务端口 (默认: 8765)")
args = parser.parse_args()
get_solver()
get_collector()
server = ThreadingHTTPServer(("127.0.0.1", args.port), WebLabelerHandler)
logger.info("=" * 60)
logger.info("🚀 腾讯天御点选验证码半自动打标工作台 (v2.2 Zero-Occlusion) 已启动!")
logger.info("👉 浏览器访问地址: http://127.0.0.1:%d", args.port)
logger.info("⌨️ 快捷操作: [F] 在线拉取新样本 | [Space/Enter] 一键核准 | [H] 隐藏标记看原图")
logger.info("=" * 60)
try:
server.serve_forever()
except KeyboardInterrupt:
logger.info("打标服务已安全退出")
if __name__ == "__main__":
main()