Files
yml2213 5c4ff8c4a1 feat(web): 靶子显示友好名 + 支持连打次数
- targets.py 增加 label 字段(1ms 签到 / mliev 登录),下拉框、说明、历史表统一用友好名
- POST /api/jobs 支持 count(1~20):一次任务连打 N 发,发间自动按靶子频控节流、可取消
- 每发结果记入 stats.rounds,EVT_VERIFY 带 round,结果区展示「每发结果(通过 x/y)」彩色一览
- 滑块不再生成标注图(红圈+序号只表达点击顺序,对滑块是误导)
- 观测台 README 补齐启动/后台运行/端口占用/自检说明
2026-10-15 11:20:24 +08:00

119 lines
5.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)