- targets.py 增加 label 字段(1ms 签到 / mliev 登录),下拉框、说明、历史表统一用友好名 - POST /api/jobs 支持 count(1~20):一次任务连打 N 发,发间自动按靶子频控节流、可取消 - 每发结果记入 stats.rounds,EVT_VERIFY 带 round,结果区展示「每发结果(通过 x/y)」彩色一览 - 滑块不再生成标注图(红圈+序号只表达点击顺序,对滑块是误导) - 观测台 README 补齐启动/后台运行/端口占用/自检说明
119 lines
5.4 KiB
Python
119 lines
5.4 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""展示层:把"点位"渲染成人能看懂的标注图,并给点位质量一个独立量尺。
|
||
|
||
两个能力都有两类调用方,所以放在**包根**而不是 `web/` 里(它不是 web 专属):
|
||
* `scripts/verify/send_campaign.py` —— CLI 批量实验落图
|
||
* `tcaptcha/web` —— 界面上的题目图 / 标注图
|
||
|
||
量尺原理(`icon_centroid_offset`):图标是近黑的线条画、背景是彩色的。在点击点周围
|
||
截一个窗口,用 Otsu 分出**暗色笔画**,取笔画质心当"图标视觉中心",报告点击点离它多远。
|
||
这条量尺**独立于求解器**(求解器用的是 YOLO box 中心),所以能回答"到底点偏了多少"。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Optional, Sequence, Tuple
|
||
|
||
import cv2
|
||
import numpy as np
|
||
|
||
# 标注样式
|
||
_RING_RADIUS = 18
|
||
_RING_COLOR = (0, 0, 255) # BGR 红
|
||
_IDX_COLOR = (0, 255, 0) # BGR 绿
|
||
_CENTROID_COLOR = (255, 128, 0) # BGR 蓝
|
||
|
||
|
||
def to_bgr(image: Any) -> np.ndarray:
|
||
"""bytes / ndarray / PIL.Image 统一转成 BGR ndarray。"""
|
||
if isinstance(image, (bytes, bytearray)):
|
||
arr = cv2.imdecode(np.frombuffer(bytes(image), np.uint8), cv2.IMREAD_COLOR)
|
||
if arr is None:
|
||
raise ValueError("无法解码图片字节")
|
||
return arr
|
||
if isinstance(image, np.ndarray):
|
||
return image if image.ndim == 3 else cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
||
# PIL.Image
|
||
rgb = np.array(image.convert("RGB"))
|
||
return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
|
||
|
||
|
||
def to_jpeg(image: Any, quality: int = 88) -> bytes:
|
||
"""任意输入 -> JPEG 字节(给 HTTP 响应用)。"""
|
||
bgr = to_bgr(image) if not isinstance(image, np.ndarray) else image
|
||
ok, buf = cv2.imencode(".jpg", bgr, [int(cv2.IMWRITE_JPEG_QUALITY), quality])
|
||
if not ok:
|
||
raise ValueError("JPEG 编码失败")
|
||
return buf.tobytes()
|
||
|
||
|
||
def icon_centroid_offset(bg: Any, x: int, y: int,
|
||
half: int = 32) -> Optional[Tuple[float, float, float]]:
|
||
"""点击点 vs 该处图标**暗色笔画质心**。
|
||
|
||
返回 `(质心x, 质心y, 离点击点的距离px)`;窗口里笔画太少(估不出中心)时返回 None。
|
||
"""
|
||
bgr = to_bgr(bg)
|
||
h, w = bgr.shape[:2]
|
||
x0, y0 = max(0, x - half), max(0, y - half)
|
||
x1, y1 = min(w, x + half), min(h, y + half)
|
||
patch = bgr[y0:y1, x0:x1]
|
||
if patch.size == 0:
|
||
return None
|
||
gray = cv2.cvtColor(patch, cv2.COLOR_BGR2GRAY)
|
||
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
||
ys, xs = np.nonzero(binary)
|
||
if len(xs) < 20:
|
||
return None
|
||
cx, cy = float(xs.mean()) + x0, float(ys.mean()) + y0
|
||
return (cx, cy, float(np.hypot(cx - x, cy - y)))
|
||
|
||
|
||
def annotate_points(bg: Any,
|
||
points: Sequence[Sequence[int]],
|
||
prompt: Any = None,
|
||
offsets: Optional[Sequence[Optional[float]]] = None,
|
||
centroids: Optional[Sequence[Optional[Sequence[float]]]] = None,
|
||
title: str = "",
|
||
embed_prompt: bool = True) -> bytes:
|
||
"""在底图上按**点击顺序**画编号红圈,可选贴提示图、标出质心与偏差。
|
||
|
||
参数:
|
||
points 点击点序列(背景图原始像素),**顺序即答案顺序**
|
||
prompt 提示图(图标/文字点选题)
|
||
offsets 每个点离图标笔画质心的距离(px),标在圈上
|
||
centroids 每个点的质心坐标,画一个蓝色十字(一眼看出偏没偏)
|
||
title 左上角标题(如 "第 1 题")
|
||
embed_prompt 是否把提示图贴进右上角。命令行/落盘单图查看时贴(默认 True);
|
||
**Web 界面传 False** —— 那边提示图是独立一张缩略图,
|
||
贴进去只会挡住右上角的图标(实测会遮住一个目标)。"""
|
||
bgr = to_bgr(bg)
|
||
for i, pt in enumerate(points):
|
||
x, y = int(pt[0]), int(pt[1])
|
||
cv2.circle(bgr, (x, y), _RING_RADIUS, _RING_COLOR, 2)
|
||
cv2.circle(bgr, (x, y), 2, _RING_COLOR, -1)
|
||
label = str(i + 1)
|
||
cv2.putText(bgr, label, (x - 8, y - _RING_RADIUS - 6),
|
||
cv2.FONT_HERSHEY_SIMPLEX, 0.85, _IDX_COLOR, 2)
|
||
if centroids and i < len(centroids) and centroids[i]:
|
||
cx, cy = int(centroids[i][0]), int(centroids[i][1])
|
||
cv2.drawMarker(bgr, (cx, cy), _CENTROID_COLOR, cv2.MARKER_CROSS, 12, 1)
|
||
if offsets and i < len(offsets) and offsets[i] is not None:
|
||
cv2.putText(bgr, "%.0fpx" % offsets[i], (x - 20, y + _RING_RADIUS + 16),
|
||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, _CENTROID_COLOR, 1)
|
||
if prompt is not None and embed_prompt:
|
||
try:
|
||
pr = to_bgr(prompt)
|
||
ph = max(1, bgr.shape[0] // 4)
|
||
pw = max(1, int(pr.shape[1] * ph / pr.shape[0]))
|
||
pr = cv2.resize(pr, (pw, ph))
|
||
x0, y0 = max(0, bgr.shape[1] - pw - 4), 4
|
||
bgr[y0:y0 + ph, x0:x0 + pw] = pr
|
||
cv2.rectangle(bgr, (x0, y0), (x0 + pw, y0 + ph), (255, 255, 255), 1)
|
||
except Exception: # noqa: BLE001 提示图坏了不影响主流程
|
||
pass
|
||
if title:
|
||
cv2.putText(bgr, title, (6, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 3)
|
||
cv2.putText(bgr, title, (6, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 1)
|
||
return to_jpeg(bgr)
|