Files
hello_js_reverse_skill/tcaptcha/render.py
T
yml2213 99565f65f0 feat(web): 新增 Web 观测台(形态 A)+ 抽出 render/targets 共用层
- 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
2026-10-14 19:10:54 +08:00

116 lines
5.0 KiB
Python
Raw 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 = "") -> 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)