基础打标控制台
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
腾讯天御点选验证码 (Tencent Turing Captcha) 样本纯协议采集器
|
||||
通过模拟前端两步 HTTP 接口直接拉取高清原图:
|
||||
1. GET /cap_union_prehandle 获取初始会话 sess
|
||||
2. POST /cap_union_new_getsig 获取题目配置与背景/提示图 URL
|
||||
3. 并行下载 672x480 背景大图与 170x50 提示图标图
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("tcaptcha_collector")
|
||||
|
||||
|
||||
class TencentCaptchaCollector:
|
||||
BASE_URL = "https://turing.captcha.qcloud.com"
|
||||
DEFAULT_AID = "2039519451"
|
||||
|
||||
def __init__(self, aid: str = DEFAULT_AID, output_dir: str = "tcaptcha/data/raw", timeout: float = 10.0):
|
||||
self.aid = aid
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.index_file = self.output_dir / "index.jsonl"
|
||||
self.timeout = timeout
|
||||
|
||||
self.client = httpx.Client(
|
||||
base_url=self.BASE_URL,
|
||||
trust_env=False,
|
||||
headers={
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Referer": "https://turing.captcha.qcloud.com/",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
},
|
||||
timeout=self.timeout,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
def _fetch_session(self) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""第一步:预检请求获取 sess"""
|
||||
cb_name = f"_aq_{random.randint(100000, 999999)}"
|
||||
tkid = str(random.randint(100000000, 999999999))
|
||||
ua_b64 = "TW96aWxsYS81LjAgKE1hY2ludG9zaDsgSW50ZWwgTWFjIE9TIFggMTBfMTVfNykgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzE1Mi4wLjAuMCBTYWZhcmkvNTM3LjM2"
|
||||
|
||||
params = {
|
||||
"aid": self.aid,
|
||||
"protocol": "https",
|
||||
"accver": "1",
|
||||
"showtype": "popup",
|
||||
"ua": ua_b64,
|
||||
"noheader": "1",
|
||||
"fb": "1",
|
||||
"grayscale": "1",
|
||||
"clientype": "2",
|
||||
"lang": "zh-cn",
|
||||
"js": "/tcaptcha-frame.ba387dd1.js",
|
||||
"wb": "2",
|
||||
"tkid": tkid,
|
||||
"subsid": "1",
|
||||
"callback": cb_name,
|
||||
}
|
||||
|
||||
resp = self.client.get("/cap_union_prehandle", params=params)
|
||||
if resp.status_code != 200:
|
||||
logger.error("prehandle failed with status %d", resp.status_code)
|
||||
return None, None
|
||||
|
||||
match = re.search(rf"{cb_name}\((.*)\)", resp.text)
|
||||
if not match:
|
||||
logger.error("Failed to parse prehandle response: %s", resp.text[:100])
|
||||
return None, None
|
||||
|
||||
try:
|
||||
data = json.loads(match.group(1))
|
||||
sess = data.get("sess")
|
||||
return sess, tkid
|
||||
except Exception as e:
|
||||
logger.error("Failed to decode JSON from prehandle: %s", e)
|
||||
return None, None
|
||||
|
||||
def _fetch_sig_and_images(self, sess: str, tkid: str) -> Optional[dict]:
|
||||
"""第二步:请求题目信息并下载图片对"""
|
||||
resp = self.client.post(
|
||||
"/cap_union_new_getsig",
|
||||
data={"sess": sess, "tkid": tkid},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error("getsig failed with status %d", resp.status_code)
|
||||
return None
|
||||
|
||||
try:
|
||||
res_json = resp.json()
|
||||
except Exception as e:
|
||||
logger.error("Failed to decode getsig JSON: %s", e)
|
||||
return None
|
||||
|
||||
if str(res_json.get("ret")) != "0":
|
||||
logger.warning("getsig returned non-zero ret: %s", res_json)
|
||||
return None
|
||||
|
||||
cfg = res_json.get("data", {})
|
||||
instruction = cfg.get("instruction", "请依次点击:")
|
||||
bg_cfg = cfg.get("bg_elem_cfg", {})
|
||||
img_url_bg = bg_cfg.get("img_url", "")
|
||||
|
||||
if not img_url_bg:
|
||||
logger.warning("No img_url found in getsig response")
|
||||
return None
|
||||
|
||||
img_url_prompt = img_url_bg.replace("img_index=1", "img_index=0")
|
||||
|
||||
# 下载背景大图 (672x480) 与 提示图 (170x50)
|
||||
bg_resp = self.client.get(img_url_bg)
|
||||
prompt_resp = self.client.get(img_url_prompt)
|
||||
|
||||
if bg_resp.status_code != 200 or prompt_resp.status_code != 200:
|
||||
logger.error("Failed to download image pairs")
|
||||
return None
|
||||
|
||||
return {
|
||||
"instruction": instruction,
|
||||
"bg_bytes": bg_resp.content,
|
||||
"prompt_bytes": prompt_resp.content,
|
||||
"img_url_bg": img_url_bg,
|
||||
"img_url_prompt": img_url_prompt,
|
||||
"sess": sess,
|
||||
"tkid": tkid,
|
||||
}
|
||||
|
||||
def fetch_one(self) -> Optional[str]:
|
||||
"""采集单组样本并保存到本地"""
|
||||
sess, tkid = self._fetch_session()
|
||||
if not sess or not tkid:
|
||||
return None
|
||||
|
||||
data = self._fetch_sig_and_images(sess, tkid)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
sample_id = f"{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
|
||||
bg_filename = f"{sample_id}_bg.png"
|
||||
prompt_filename = f"{sample_id}_prompt.png"
|
||||
meta_filename = f"{sample_id}_meta.json"
|
||||
|
||||
bg_path = self.output_dir / bg_filename
|
||||
prompt_path = self.output_dir / prompt_filename
|
||||
meta_path = self.output_dir / meta_filename
|
||||
|
||||
bg_path.write_bytes(data["bg_bytes"])
|
||||
prompt_path.write_bytes(data["prompt_bytes"])
|
||||
|
||||
meta_info = {
|
||||
"sample_id": sample_id,
|
||||
"timestamp": time.time(),
|
||||
"aid": self.aid,
|
||||
"instruction": data["instruction"],
|
||||
"bg_file": bg_filename,
|
||||
"prompt_file": prompt_filename,
|
||||
"bg_size": len(data["bg_bytes"]),
|
||||
"prompt_size": len(data["prompt_bytes"]),
|
||||
"sess": data["sess"],
|
||||
"tkid": data["tkid"],
|
||||
}
|
||||
|
||||
meta_path.write_text(json.dumps(meta_info, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
# 写入 index.jsonl
|
||||
with open(self.index_file, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(meta_info, ensure_ascii=False) + "\n")
|
||||
|
||||
logger.info(
|
||||
"✓ 采集成功 [%s]: 大图 %d KB, 提示图 %d KB | 提示: %s",
|
||||
sample_id,
|
||||
len(data["bg_bytes"]) // 1024,
|
||||
len(data["prompt_bytes"]) // 1024,
|
||||
data["instruction"],
|
||||
)
|
||||
return sample_id
|
||||
|
||||
def collect_batch(self, count: int = 10, delay: float = 0.8) -> int:
|
||||
"""批量采集样本"""
|
||||
logger.info("开始批量采集点选验证码样本,目标数量: %d, 间隔: %.2fs", count, delay)
|
||||
success_count = 0
|
||||
|
||||
for i in range(count):
|
||||
try:
|
||||
sid = self.fetch_one()
|
||||
if sid:
|
||||
success_count += 1
|
||||
else:
|
||||
logger.warning("第 %d/%d 个样本采集失败,准备重试", i + 1, count)
|
||||
except Exception as e:
|
||||
logger.error("采集异常: %s", e)
|
||||
|
||||
if i < count - 1 and delay > 0:
|
||||
# 随机微弱浮动,模拟正常采集
|
||||
actual_delay = delay + random.uniform(-0.2, 0.3)
|
||||
time.sleep(max(0.2, actual_delay))
|
||||
|
||||
logger.info("批量采集完成: 成功 %d/%d (存储目录: %s)", success_count, count, self.output_dir)
|
||||
return success_count
|
||||
|
||||
def close(self):
|
||||
self.client.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="腾讯天御点选验证码纯协议批量采集器")
|
||||
parser.add_argument("--count", type=int, default=10, help="采集样本数量 (默认: 10)")
|
||||
parser.add_argument("--delay", type=float, default=0.8, help="每次请求间隔秒数 (默认: 0.8)")
|
||||
parser.add_argument("--aid", type=str, default=TencentCaptchaCollector.DEFAULT_AID, help="业务 AppID")
|
||||
parser.add_argument("--output-dir", type=str, default="tcaptcha/data/raw", help="保存路径")
|
||||
|
||||
args = parser.parse_args()
|
||||
collector = TencentCaptchaCollector(aid=args.aid, output_dir=args.output_dir)
|
||||
try:
|
||||
collector.collect_batch(count=args.count, delay=args.delay)
|
||||
finally:
|
||||
collector.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
提示词图像自动切割模块
|
||||
将 170x50 的提示图自动切分为 3 个独立的图标小图
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def slice_prompt_image(prompt_img_path: str | Path, padding: int = 2) -> List[Tuple[Image.Image, Tuple[int, int, int, int]]]:
|
||||
"""
|
||||
自动识别并切割提示图中的 3 个字符/图标
|
||||
返回: [(PIL_Image, (x1, y1, x2, y2)), ...]
|
||||
"""
|
||||
img = Image.open(prompt_img_path).convert("L")
|
||||
arr = np.array(img)
|
||||
h, w = arr.shape
|
||||
|
||||
# 背景灰度基准 (通常取左上角像素)
|
||||
bg_val = int(arr[0, 0])
|
||||
|
||||
# 提取暗色笔画像素(排除灰色背景)
|
||||
mask = arr < (bg_val - 25)
|
||||
|
||||
# 水平方向投影,找出连续包含笔画的列区间
|
||||
cols_has_dark = mask.any(axis=0)
|
||||
|
||||
segments: List[Tuple[int, int]] = []
|
||||
in_seg = False
|
||||
start = 0
|
||||
|
||||
for i, val in enumerate(cols_has_dark):
|
||||
if val and not in_seg:
|
||||
in_seg = True
|
||||
start = i
|
||||
elif not val and in_seg:
|
||||
in_seg = False
|
||||
if i - start >= 5: # 过滤过窄杂点
|
||||
segments.append((start, i))
|
||||
if in_seg and (w - start >= 5):
|
||||
segments.append((start, w))
|
||||
|
||||
# 若投影分割不足 3 段(极罕见),按固定 3 等分兜底
|
||||
if len(segments) != 3:
|
||||
step = w // 3
|
||||
segments = [(i * step + 5, (i + 1) * step - 5) for i in range(3)]
|
||||
|
||||
# 原始彩色图用于切片
|
||||
orig_img = Image.open(prompt_img_path).convert("RGBA")
|
||||
crops = []
|
||||
|
||||
for seg_start, seg_end in segments[:3]:
|
||||
# 纵向精确裁剪
|
||||
sub_mask = mask[:, seg_start:seg_end]
|
||||
rows_has_dark = sub_mask.any(axis=1)
|
||||
row_indices = np.where(rows_has_dark)[0]
|
||||
|
||||
if len(row_indices) > 0:
|
||||
top = max(0, int(row_indices[0]) - padding)
|
||||
bottom = min(h, int(row_indices[-1]) + 1 + padding)
|
||||
else:
|
||||
top, bottom = 0, h
|
||||
|
||||
left = max(0, seg_start - padding)
|
||||
right = min(w, seg_end + padding)
|
||||
|
||||
box = (left, top, right, bottom)
|
||||
crop = orig_img.crop(box)
|
||||
crops.append((crop, box))
|
||||
|
||||
return crops
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sample = sys.argv[1] if len(sys.argv) > 1 else "tcaptcha/data/raw/1789135559041_4170_prompt.png"
|
||||
if Path(sample).exists():
|
||||
results = slice_prompt_image(sample)
|
||||
print(f"切片完成,切出 {len(results)} 个目标:")
|
||||
for idx, (c, b) in enumerate(results):
|
||||
print(f" [{idx+1}] 尺寸: {c.size}, 边界框: {b}")
|
||||
else:
|
||||
print(f"样本文件不存在: {sample}")
|
||||
@@ -0,0 +1,493 @@
|
||||
"""
|
||||
极简本地 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()
|
||||
Reference in New Issue
Block a user