"""
腾讯天御点选验证码 · 现代化半自动人机协同打标工作台 (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 = """
腾讯天御点选验证码 - 半自动打标工作台
🤖 AI 已预标注完毕 — 请核对,无误请按【空格/回车】确认入库!
Space / Enter 确认核准并切换
F 在线采集新样本
H 隐藏/显示标记框 (临时看原图)
U 切换仅看待审核
鼠标点击/拖动 拖动外框或准心微调
R 清空重标
P / N 上下翻阅
"""
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()