""" 腾讯天御点选验证码 · 现代化半自动人机协同打标工作台 (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 = """ 腾讯天御点选验证码 - 半自动打标工作台

🎯 腾讯天御点选验证码 · 半自动人机协同打标工作台 v2.2 Zero-Occlusion

请依次点击:
X: 0, Y: 0
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()