- tcaptcha/web/:FastAPI + SSE + 零构建单页。把同步阻塞的 solve() 包成可观测 后台任务(工作线程 + 全局串行,因 onnxruntime session 非线程安全),用 logging.Handler 零侵入把 client 日志变成事件流;按靶子实测 min_interval 强制节流。 - tcaptcha/render.py:标注图(点击顺序编号 + 质心偏差)与点位量尺,CLI 与 web 共用。 - tcaptcha/targets.py:靶子表提为单一来源(含实测频控间隔 1ms=60s / mliev=3s)。 - fastapi/uvicorn 走 optional extra `web`,核心包零 web 依赖(带回归断言)。 - 修两个真 bug:hook 求解器时 args[0] 是实例自身(真入参从 args[1] 起); client 默认每次新建 solver,预热实例未被复用(每发重载 56MB ONNX)。 - 复验:icon/text/slider 三题型各拿到 errorCode=0;selftest 全绿;依赖隔离通过。 用法:uv run --extra web python -m tcaptcha.web.app
116 lines
5.0 KiB
Python
116 lines
5.0 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 = "") -> bytes:
|
||
"""在底图上按**点击顺序**画编号红圈,可选贴提示图、标出质心与偏差。
|
||
|
||
参数:
|
||
points 点击点序列(背景图原始像素),**顺序即答案顺序**
|
||
prompt 提示图(图标/文字点选题),会缩放到右上角
|
||
offsets 每个点离图标笔画质心的距离(px),标在圈上
|
||
centroids 每个点的质心坐标,画一个蓝色十字(一眼看出偏没偏)
|
||
title 左上角标题(如 "icon_click ec=0")
|
||
"""
|
||
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:
|
||
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)
|