# -*- 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)