1280 lines
40 KiB
Python
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()
|