lze888lze's picture
Update captcha_recognizer/slider.py
0006acd verified
Raw
History Blame Contribute Delete
34.1 kB
"""
滑块验证码识别核心模块
========================
基于 YOLOv8-seg(实例分割)的 ONNX 模型,识别滑块验证码的滑块和缺口位置。
整体流程:
原始图片 → 预处理(letterbox+归一化) → ONNX模型推理 → 后处理(NMS+掩膜) → 滑块/缺口坐标
主要对外方法:
identify(source) → 返回 (gap_box, confidence) 旧版接口,只返回缺口
identify_both(source) → 返回 dict,包含滑块+缺口+偏移量 新版接口
identify_offset(source) → 返回 (offset, confidence) 只返回滑块x坐标
关键参数(可调,改这里调整识别灵敏度):
CONF_THRESHOLD = 0.5 置信度阈值,低于此值的目标会被丢弃
IOU_THRESHOLD = 0.8 NMS 用的 IoU 阈值
Y_IOU_THRESHOLD = 0.85 Y轴方向 IoU 阈值,用于区分滑块和缺口
"""
import base64
import os
import random
import time
from pathlib import Path
from typing import List, Tuple, Union
import cv2 # OpenCV:图片读写、缩放、绘制
import numpy as np # 数值计算
import onnxruntime as ort # ONNX 模型推理引擎
from shapely.geometry import Polygon # 多边形几何计算(计算IoU用)
# ============================================================
# 全局阈值参数(改这里可以调整识别的灵敏度)
# ============================================================
CONF_THRESHOLD = 0.5 # 置信度阈值:模型认为"这可能是缺口"的最低分数
# 调低 → 识别更多目标(可能误报增多)
# 调高 → 只保留高置信度结果(可能漏报)
IOU_THRESHOLD = 0.8 # NMS(非极大值抑制)的 IoU 阈值
# 两个框重叠度超过此值,只保留分数更高的那个
Y_IOU_THRESHOLD = 0.85 # Y轴方向 IoU 阈值,用于 pick_out_mask
# 判断两个目标是否在"同一水平线"上(滑块和缺口通常y位置接近)
class Slider:
def __init__(self):
"""
初始化:加载 ONNX 模型文件
模型路径: captcha_recognizer/models/slider.onnx
"""
root_dir = os.path.dirname(os.path.dirname(__file__))
slider_model_path = os.path.join(root_dir, 'captcha_recognizer', 'models', 'slider.onnx')
# 根据是否有 GPU 选择推理设备
# HF Spaces 免费层没有 GPU,所以通常走 CPUExecutionProvider
self.session = ort.InferenceSession(
slider_model_path,
providers=["CUDAExecutionProvider", "CPUExecutionProvider"] if ort.get_device() == 'GPU' else [
"CPUExecutionProvider"],
)
# 模型类别:只有一类 's'(slider/缺口)
# 如果以后换成多类别模型,这里要加
self.classes = {0: 's'}
# ============================================================
# 核心推理流程
# ============================================================
def predict(self, img: np.ndarray, conf: float = 0.25, iou: float = 0.7,
imgsz: Union[int, Tuple[int, int]] = 640) -> List:
"""
完整推理流程:预处理 → 模型推理 → 后处理
参数:
img: 原始图片(BGR格式的numpy数组)
conf: 置信度阈值(传给NMS)
iou: IoU阈值(传给NMS)
imgsz: 模型输入尺寸,默认640x640
↑ 更大=更准但更慢,更小=更快但可能不准
↑ 常见值: 320, 640, 1280
"""
imgsz = (imgsz, imgsz) if isinstance(imgsz, int) else imgsz
prep_img = self.preprocess(img, imgsz)
outs = self.session.run(None, {self.session.get_inputs()[0].name: prep_img})
return self.postprocess(img, prep_img, outs, conf=conf, iou=iou)
@staticmethod
def letterbox(img: np.ndarray, new_shape: Tuple[int, int] = (640, 640)) -> np.ndarray:
"""
Letterbox 缩放:保持宽高比缩放图片,不足部分用灰色(114,114,114)填充
为什么不直接 resize?因为直接拉伸会变形,影响识别准确率。
Letterbox 相当于"等比缩放 + 补边",是 YOLO 系列模型的标准做法。
示例:原图 300x200,目标 640x640
→ 等比缩放到 640x427
→ 上下各补 106 像素灰色边,最终 640x640
"""
shape = img.shape[:2] # 当前尺寸 [height, width]
# 计算缩放比例(取宽高比中较小的,保证整个图片都能放下)
r = min(new_shape[0] / shape[0], new_shape[1] / shape[1])
new_unpad = int(round(shape[1] * r)), int(round(shape[0] * r))
# 确保尺寸不越界
new_unpad = (max(1, min(new_unpad[0], new_shape[1])),
max(1, min(new_unpad[1], new_shape[0])))
# 缩放
if shape[::-1] != new_unpad:
img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR)
# 计算需要填充的像素数
dw, dh = new_shape[1] - new_unpad[0], new_shape[0] - new_unpad[1]
dw, dh = float(dw), float(dh)
# 上下、左右各填一半
top, bottom = int(round(dh / 2)), int(round(dh / 2))
left, right = int(round(dw / 2)), int(round(dw / 2))
# 填充灰色边
img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=(114, 114, 114))
# 最终确保精确尺寸(防止四舍五入导致差1像素)
if img.shape[0] != new_shape[0] or img.shape[1] != new_shape[1]:
img = cv2.resize(img, new_shape, interpolation=cv2.INTER_LINEAR)
return img
def preprocess(self, img: np.ndarray, new_shape: Tuple[int, int]) -> np.ndarray:
"""
图片预处理:letterbox缩放 → BGR转RGB → 转置 → 归一化
处理后格式: (1, 3, 640, 640) float32,值域 [0, 1]
这是 YOLO 模型的标准输入格式
"""
img = self.letterbox(img, new_shape) # 等比缩放+填充
img = img[..., ::-1].transpose([2, 0, 1])[None] # BGR→RGB, HWC→CHW, 加batch维度
img = np.ascontiguousarray(img) # 确保内存连续
img = img.astype(np.float32) / 255 # 归一化到 0~1
return img
def postprocess(self, img: np.ndarray, prep_img: np.ndarray, outs: List, conf: float = 0.25,
iou: float = 0.7) -> List:
"""
后处理:模型输出 → 有意义的检测框和掩膜
模型输出两个部分:
preds: 检测框 + 类别 + 置信度
protos: 掩膜原型(用于生成分割掩膜)
"""
preds, protos = outs
preds = self.non_max_suppression(preds, conf, iou, nc=len(self.classes))
results = []
for i, pred in enumerate(preds):
if len(pred) == 0:
results.append([pred, None])
continue
# 把检测框从模型输入坐标映射回原图坐标
pred[:, :4] = self.scale_boxes(prep_img.shape[2:], pred[:, :4], img.shape)
# 用掩膜原型 + 检测框系数 → 生成分割掩膜
masks = self.process_mask(protos[i], pred[:, 6:], pred[:, :4], img.shape[:2])
results.append([pred[:, :6], masks])
return results
# ============================================================
# 掩膜处理相关
# ============================================================
def process_mask(self, protos: np.ndarray, masks_in: np.ndarray, bboxes: np.ndarray,
shape: Tuple[int, int]) -> np.ndarray:
"""
从掩膜原型生成最终的二值掩膜
原理:掩膜系数 × 掩膜原型 = 每个目标的分割掩膜
"""
c, mh, mw = protos.shape
masks = (masks_in @ protos.reshape(c, -1)).reshape(-1, mh, mw)
masks = self.scale_masks(masks, shape)
masks = self.crop_mask(masks, bboxes)
return masks > 0.0
@staticmethod
def masks_to_segments(masks: Union[np.ndarray,], strategy: str = "largest") -> List[np.ndarray]:
"""
将二值掩膜转换为多边形轮廓点
为什么需要这个?因为后面要计算两个多边形的 IoU
来判断哪个是滑块、哪个是缺口
strategy:
'largest' - 只保留最大轮廓(默认,适合大多数情况)
'all' - 合并所有轮廓
'none' - 保留所有轮廓不合并
"""
masks_np = masks.astype("uint8")
if masks_np.ndim == 2:
masks_np = masks_np[np.newaxis, ...]
segments = []
for mask in masks_np:
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
segments.append(np.zeros((0, 2), dtype=np.float32))
continue
if strategy == "all" and len(contours) > 1:
contour = np.concatenate([x.reshape(-1, 2) for x in contours])
elif strategy == "largest":
contour = max(contours, key=lambda x: cv2.arcLength(x, closed=True))
contour = contour.reshape(-1, 2)
else:
contour = contours[0].reshape(-1, 2)
segments.append(contour.astype(np.float32))
return segments[0] if masks_np.shape[0] == 1 else segments
@staticmethod
def draw_segments(image, boxes, masks,
mask_alpha=0.5, box_thickness=2, draw_labels=True):
"""
在图片上绘制检测框和掩膜(调试用,API服务中不会调用)
"""
output = image.copy()
if boxes is None and masks is None:
return output
if masks is not None:
color_mask = np.zeros_like(image)
for i, mask in enumerate(masks):
color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
mask = mask.astype(bool)
color_mask[mask] = color
output = cv2.addWeighted(output, 1, color_mask, mask_alpha, 0)
if boxes is not None:
for box in boxes:
x1, y1, x2, y2, score, class_id = box[:6]
color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
cv2.rectangle(output, (int(x1), int(y1)), (int(x2), int(y2)), color, box_thickness)
if draw_labels:
label = f"{int(class_id)}: {score:.2f}"
(label_width, label_height), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
cv2.rectangle(output, (int(x1), int(y1) - label_height - 5),
(int(x1) + label_width, int(y1)), color, -1)
cv2.putText(output, label, (int(x1), int(y1) - 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1, cv2.LINE_AA)
return output
# ============================================================
# 图片输入处理
# ============================================================
@staticmethod
def image_to_array(source: Union[str, Path, bytes, np.ndarray] = None):
"""
把各种格式的输入统一转成 OpenCV 图片(numpy数组)
支持的输入:
- base64 字符串(data:image/png;base64,...)
- 文件路径
- 字节流
- 已经是 numpy 数组的(直接返回)
"""
if isinstance(source, str) and source.startswith('data:image'):
header, encoded = source.split(',', 1)
data = base64.b64decode(encoded)
np_arr = np.frombuffer(data, np.uint8)
return cv2.imdecode(np_arr, cv2.IMREAD_COLOR)
elif isinstance(source, (str, Path)):
return cv2.imread(str(source))
elif isinstance(source, bytes):
np_arr = np.frombuffer(source, np.uint8)
return cv2.imdecode(np_arr, cv2.IMREAD_COLOR)
elif isinstance(source, np.ndarray):
return source
else:
raise TypeError("Unsupported source type. Only str, Path, bytes, or numpy.ndarray are supported.")
# ============================================================
# IoU 计算相关(用于区分滑块和缺口)
# ============================================================
@staticmethod
def normalize_points(points):
"""将多边形点集归一化到以质心为原点(用于形状比较,消除位置影响)"""
centroid = np.mean(points, axis=0)
normalized_points = points - centroid
return normalized_points
@staticmethod
def y_iou(segment1, segment2):
"""
计算 Y 轴方向的一维 IoU
用途:判断两个目标是否在同一水平线上
滑块和缺口通常 y 坐标接近(在同一高度),而背景干扰物可能在不同高度
"""
start = max(segment1[0], segment2[0])
end = min(segment1[1], segment2[1])
intersection = max(0, end - start)
len1 = segment1[1] - segment1[0]
len2 = segment2[1] - segment2[0]
union = len1 + len2 - intersection
iou = intersection / union if union != 0 else 0
return iou
def polygon_iou(self, poly1, poly2):
"""
计算两个多边形的形状 IoU(先归一化位置,只比较形状相似度)
用途:滑块的形状和缺口的形状通常相似(都是拼图块形状)
通过比较形状 IoU 来判断哪个目标是缺口
"""
p1 = self.normalize_points(poly1)
p2 = self.normalize_points(poly2)
poly1 = Polygon(p1).buffer(0) # buffer(0) 修复自相交等无效多边形
poly2 = Polygon(p2).buffer(0)
intersect = poly1.intersection(poly2).area
union = poly1.union(poly2).area
iou = intersect / union if union > 0 else 0.0
return iou
# ============================================================
# 核心:从多个检测目标中区分滑块和缺口
# ============================================================
def pick_out_mask(self, boxes: list, segments):
"""
从多个检测目标中区分滑块和缺口
返回: (slider_box, gap_box)
slider_box: 滑块(x最小的检测框,通常在图片左侧)
gap_box: 缺口(与滑块形状最相似的目标,通常在图片右侧)
策略:
1. 找 x 坐标最小的目标 → 这通常是滑块(在图片左侧)
2. 在剩余目标中,找 y 位置与滑块接近的(同一水平线)
3. 在同水平线的目标中,找形状与滑块最相似的 → 这就是缺口
为什么这样判断?
- 滑块验证码的布局:滑块在左,缺口在右
- 滑块和缺口形状相同(都是拼图块),但位置不同
- 背景干扰物形状不同,且可能不在同一水平线
"""
# 第一步:找 x 最小的 = 滑块
box_slider = min(boxes, key=lambda x: x[0])
box_slider_index = boxes.index(box_slider)
segment_slider = segments[box_slider_index]
# 剩余目标(排除滑块)
box_sample = boxes[:box_slider_index] + boxes[box_slider_index + 1:]
segment_sample = segments[:box_slider_index] + segments[box_slider_index + 1:]
# 第二步:Y轴方向 IoU 过滤(只保留同一水平线的目标)
box_filtered = []
segment_filtered = []
for index, box in enumerate(box_sample):
if self.y_iou([box_slider[1], box_slider[3]], [box[1], box[3]]) > Y_IOU_THRESHOLD:
box_filtered.append(box)
segment_filtered.append(segment_sample[index])
# 如果Y轴过滤没有保留任何目标,退回到全部候选
if not box_filtered:
box_filtered = box_sample
segment_filtered = segment_sample
if len(box_filtered) == 1:
return box_slider, box_filtered[0]
# 第三步:找形状与滑块最相似的 = 缺口
iou_flag = 0
iou_index = 0
for index, segment in enumerate(segment_filtered):
segment_iou = self.polygon_iou(segment_slider, segment)
if segment_iou > iou_flag:
iou_flag = segment_iou
iou_index = index
return box_slider, box_filtered[iou_index]
# ============================================================
# 对外接口:识别缺口
# ============================================================
def identify(self, source: Union[str, Path, bytes, np.ndarray], conf=CONF_THRESHOLD, iou=IOU_THRESHOLD, show=False):
"""
识别滑块验证码缺口位置(兼容旧接口)
返回:
(gap_box, confidence)
gap_box = [x1, y1, x2, y2] 缺口的左上角和右下角坐标
confidence = 0~1 置信度
如果没检测到缺口:返回 ([], 0.0)
"""
result = self.identify_both(source, conf=conf, iou=iou, show=show)
return result['gap'], result['gap_confidence']
def identify_both(self, source: Union[str, Path, bytes, np.ndarray], conf=CONF_THRESHOLD, iou=IOU_THRESHOLD, show=False):
"""
同时识别滑块和缺口位置(新版接口)
参数:
source: 图片输入(路径/base64/字节/numpy数组)
conf: 置信度阈值(默认0.5)
iou: NMS IoU阈值(默认0.8)
show: 是否显示识别结果(调试用,服务器上别开)
返回 dict:
slider: [x1, y1, x2, y2] 滑块坐标(空列表=未检测到)
slider_confidence: float 滑块置信度
gap: [x1, y1, x2, y2] 缺口坐标(空列表=未检测到)
gap_confidence: float 缺口置信度
offset: int 滑动距离 = 缺口x1 - 滑块x1(0表示无法计算)
"""
slider_box_list = []
gap_box_list = []
original_image: np.ndarray = self.image_to_array(source)
results = self.predict(original_image, conf=conf, iou=iou, imgsz=640)
if results:
boxes, masks = results[0]
if len(boxes) == 0:
pass # 没检测到任何目标
elif len(boxes) == 1:
# 只检测到一个目标,无法区分滑块/缺口,当作缺口
gap_box_list = boxes[0].tolist()
else:
# 多目标:区分滑块和缺口
segments = self.masks_to_segments(masks)
slider_box_list, gap_box_list = self.pick_out_mask(boxes.tolist(), segments)
# 调试用:显示识别结果
if show:
draw_boxes = []
draw_masks = []
boxes_np, masks_np = results[0] if results else (np.zeros((0, 6)), None)
if gap_box_list and masks_np is not None:
gap_idx = boxes_np.tolist().index(gap_box_list) if gap_box_list in boxes_np.tolist() else -1
if gap_idx >= 0:
draw_boxes.append(gap_box_list)
draw_masks.append(masks_np[gap_idx])
if slider_box_list and masks_np is not None:
slider_idx = boxes_np.tolist().index(slider_box_list) if slider_box_list in boxes_np.tolist() else -1
if slider_idx >= 0:
draw_boxes.append(slider_box_list)
draw_masks.append(masks_np[slider_idx])
if draw_boxes:
sample = self.draw_segments(original_image, draw_boxes, draw_masks)
cv2.imshow('result', sample)
cv2.waitKey(0)
cv2.destroyAllWindows()
# 提取坐标和置信度
slider = [int(x) for x in slider_box_list[:4]] if slider_box_list else []
slider_conf = float(slider_box_list[4]) if slider_box_list else 0.0
gap = [int(x) for x in gap_box_list[:4]] if gap_box_list else []
gap_conf = float(gap_box_list[4]) if gap_box_list else 0.0
# 计算滑动距离:缺口x1 - 滑块x1
# 这就是滑块需要从当前位置滑动到缺口的距离
if slider and gap:
offset = gap[0] - slider[0]
else:
offset = 0
return {
'slider': slider,
'slider_confidence': slider_conf,
'gap': gap,
'gap_confidence': gap_conf,
'offset': offset,
}
def identify_offset(self, source: Union[str, Path, bytes, np.ndarray], conf=CONF_THRESHOLD, iou=IOU_THRESHOLD,
show=False):
"""
识别缺口并直接返回偏移量(滑块初始x坐标)
注意:这里返回的 offset 是滑块自身的 x1 坐标,
不是滑动距离。要算滑动距离请用 identify_both() 的 offset 字段。
用途:某些验证码的滑块有固定偏移量,可用此方法获取
"""
box_list = []
mask_ndarray = None
original_image: np.ndarray = self.image_to_array(source)
results = self.predict(original_image, conf=conf, iou=iou, imgsz=640)
if results:
boxes, masks = results[0]
if len(boxes) == 0:
pass
elif len(boxes) == 1:
box_list = boxes[0].tolist()
mask_ndarray = masks[0]
else:
# 多目标时选 x 最小的(最左边的 = 滑块位置)
box_left = min(boxes, key=lambda x: x[0])
box_list = box_left.tolist()
mask_ndarray = masks[boxes.tolist().index(box_list)]
if show and box_list and mask_ndarray is not None:
sample = self.draw_segments(original_image, [box_list, ], [mask_ndarray, ])
cv2.imshow('result', sample)
cv2.waitKey(0)
cv2.destroyAllWindows()
if box_list:
box = box_list[:4]
box_conf = float(box_list[4])
offset = box[0] # 缺口/滑块的 x1 坐标
else:
offset = 0
box_conf = 0.0
return offset, box_conf
# ============================================================
# 以下是 YOLO 后处理的标准工具方法
# 一般不需要修改,除非换模型
# ============================================================
def scale_boxes(self, img1_shape, boxes, img0_shape, ratio_pad=None, padding=True, xywh=False):
"""将检测框从模型输入坐标映射回原图坐标(逆letterbox)"""
if ratio_pad is None:
gain = min(img1_shape[0] / img0_shape[0], img1_shape[1] / img0_shape[1])
pad = (
round((img1_shape[1] - img0_shape[1] * gain) / 2),
round((img1_shape[0] - img0_shape[0] * gain) / 2),
)
else:
gain = ratio_pad[0][0]
pad = ratio_pad[1]
if padding:
boxes[..., 0] -= pad[0]
boxes[..., 1] -= pad[1]
if not xywh:
boxes[..., 2] -= pad[0]
boxes[..., 3] -= pad[1]
boxes[..., :4] /= gain
return self.clip_boxes(boxes, img0_shape)
@staticmethod
def get_covariance_matrix(boxes: np.ndarray):
"""从旋转边界框生成协方差矩阵(用于旋转框NMS)"""
gbbs = np.concatenate((np.power(boxes[:, 2:4], 2) / 12, boxes[:, 4:]), axis=-1)
a, b, c = np.split(gbbs, [1, 2], axis=-1)
cos = np.cos(c)
sin = np.sin(c)
cos2 = np.power(cos, 2)
sin2 = np.power(sin, 2)
return a * cos2 + b * sin2, a * sin2 + b * cos2, (a - b) * cos * sin
def batch_probiou(self, obb1, obb2, eps=1e-7):
"""计算旋转边界框的概率IoU"""
x1, y1 = np.split(obb1[..., :2], 2, axis=-1)
x2, y2 = (np.expand_dims(x.squeeze(-1), 0) for x in np.split(obb2[..., :2], 2, axis=-1))
a1, b1, c1 = self.get_covariance_matrix(obb1)
a2, b2, c2 = (np.expand_dims(x.squeeze(-1), 0) for x in self.get_covariance_matrix(obb2))
t1 = (
((a1 + a2) * np.power(y1 - y2, 2) + (b1 + b2) * np.power(x1 - x2, 2)) / (
(a1 + a2) * (b1 + b2) - np.power(c1 + c2, 2) + eps)
) * 0.25
t2 = (((c1 + c2) * (x2 - x1) * (y1 - y2)) / ((a1 + a2) * (b1 + b2) - np.power(c1 + c2, 2) + eps)) * 0.5
term1_log = (a1 * b1 - np.power(c1, 2)).clip(0)
term2_log = (a2 * b2 - np.power(c2, 2)).clip(0)
denominator = 4 * np.sqrt(term1_log * term2_log) + eps
t3_numerator = (a1 + a2) * (b1 + b2) - np.power(c1 + c2, 2)
t3_arg = np.clip(t3_numerator / denominator + eps, eps, None)
t3 = np.log(t3_arg) * 0.5
bd = (t1 + t2 + t3).clip(eps, 100.0)
hd = np.sqrt(1.0 - np.exp(-bd) + eps)
return 1 - hd
def nms_rotated(self, boxes, scores, threshold=0.45):
"""旋转边界框的NMS"""
sorted_idx = np.argsort(scores)[::-1]
boxes = boxes[sorted_idx]
ious = self.batch_probiou(boxes, boxes)
n = boxes.shape[0]
ious[np.tril_indices(n)] = 0
pick = np.where((ious >= threshold).sum(axis=0) <= 0)[0]
return sorted_idx[pick]
def clip_boxes(self, boxes, shape):
"""将检测框裁剪到图片范围内(防止越界)"""
boxes[..., [0, 2]] = np.clip(boxes[..., [0, 2]], 0, shape[1])
boxes[..., [1, 3]] = np.clip(boxes[..., [1, 3]], 0, shape[0])
return boxes
@staticmethod
def xywh2xyxy(x):
"""坐标格式转换:中心点+宽高 → 左上角+右下角"""
assert x.shape[-1] == 4, f"input shape last dimension expected 4 but input shape is {x.shape}"
y = np.empty_like(x, dtype=np.float32)
xy = x[..., :2]
wh = x[..., 2:] / 2
y[..., :2] = xy - wh
y[..., 2:] = xy + wh
return y
@staticmethod
def crop_mask(masks, boxes):
"""将掩膜裁剪到检测框范围内(框外的掩膜置零)"""
_, h, w = masks.shape
boxes = boxes[:, :, None] if boxes.ndim == 2 else boxes
x1, y1, x2, y2 = np.split(boxes, 4, axis=1)
r = np.arange(w, dtype=x1.dtype)[None, None, :]
c = np.arange(h, dtype=x1.dtype)[None, :, None]
return masks * ((r >= x1) * (r < x2) * (c >= y1) * (c < y2))
def process_mask_np(self, protos, masks_in, bboxes, shape, upsample=False):
"""另一版本的掩膜处理(带下采样坐标映射)"""
c, mh, mw = protos.shape
ih, iw = shape
masks = (masks_in @ protos.reshape(c, -1)).reshape(-1, mh, mw)
width_ratio = mw / iw
height_ratio = mh / ih
downsampled_bboxes = bboxes.copy()
downsampled_bboxes[:, 0] *= width_ratio
downsampled_bboxes[:, 2] *= width_ratio
downsampled_bboxes[:, 3] *= height_ratio
downsampled_bboxes[:, 1] *= height_ratio
masks = self.crop_mask(masks, downsampled_bboxes)
if upsample:
masks = cv2.resize(masks.transpose((1, 2, 0)),
(shape[1], shape[0]),
interpolation=cv2.INTER_LINEAR).transpose((2, 0, 1))
return masks > 0.0
@staticmethod
def scale_masks(masks, shape, padding=True):
"""将掩膜从模型输出尺寸缩放到原图尺寸"""
mh, mw = masks.shape[1:]
gain = min(mh / shape[0], mw / shape[1])
pad = [mw - shape[1] * gain, mh - shape[0] * gain]
if padding:
pad[0] /= 2
pad[1] /= 2
top, left = (int(round(pad[1])), int(round(pad[0]))) if padding else (0, 0)
bottom, right = (mh - int(round(pad[1])), mw - int(round(pad[0])))
masks_cropped = masks[:, top:bottom, left:right]
resized_masks = np.zeros((masks_cropped.shape[0], shape[0], shape[1]), dtype=masks_cropped.dtype)
for i, mask in enumerate(masks_cropped):
resized_masks[i] = cv2.resize(mask, (shape[1], shape[0]), interpolation=cv2.INTER_LINEAR)
return resized_masks
def non_max_suppression(self, prediction, conf_thres=0.25, iou_thres=0.45,
classes=None, agnostic=False, multi_label=False, labels=(),
max_det=300, nc=0, max_time_img=0.05, max_nms=30000,
max_wh=7680, in_place=True, rotated=False, end2end=False,
return_idxs=False):
"""
非极大值抑制(NMS)
作用:模型可能对同一个目标检测出多个重叠的框,
NMS 会保留分数最高的,去掉与它重叠太多的其他框。
一般不需要改这里的参数,调 CONF_THRESHOLD 和 IOU_THRESHOLD 就够了
"""
assert 0 <= conf_thres <= 1, f"Invalid Confidence threshold {conf_thres}"
assert 0 <= iou_thres <= 1, f"Invalid IoU {iou_thres}"
if isinstance(prediction, (list, tuple)):
prediction = prediction[0]
if classes is not None:
classes = np.array(classes)
if prediction.shape[-1] == 6 or end2end:
output = [pred[pred[:, 4] > conf_thres][:max_det] for pred in prediction]
if classes is not None:
output = [pred[np.any(pred[:, 5:6] == classes, axis=1)] for pred in output]
return output
bs = prediction.shape[0]
nc = nc or (prediction.shape[1] - 4)
extra = prediction.shape[1] - nc - 4
mi = 4 + nc
xc = np.amax(prediction[:, 4:mi], axis=1) > conf_thres
xinds = np.stack([np.arange(len(i)) for i in xc])[..., None]
time_limit = 2.0 + max_time_img * bs
multi_label &= nc > 1
prediction = np.transpose(prediction, (0, 2, 1))
if not rotated:
if in_place:
prediction[..., :4] = self.xywh2xyxy(prediction[..., :4])
else:
prediction = np.concatenate((self.xywh2xyxy(prediction[..., :4]), prediction[..., 4:]), axis=-1)
t = time.time()
output = [np.zeros((0, 6 + extra), dtype=np.float32)] * bs
keepi = [np.zeros((0, 1), dtype=np.int64)] * bs
for xi, (x, xk) in enumerate(zip(prediction, xinds)):
filt = xc[xi]
x, xk = x[filt], xk[filt]
if labels and len(labels) > xi and len(labels[xi]) and not rotated:
lb = np.array(labels[xi])
if lb.size > 0:
v = np.zeros((len(lb), nc + extra + 4), dtype=np.float32)
v[:, :4] = self.xywh2xyxy(lb[:, 1:5])
v[range(len(lb)), lb[:, 0].astype(np.int64) + 4] = 1.0
x = np.concatenate((x, v), axis=0)
if not x.shape[0]:
continue
box, cls, mask = np.split(x, [4, 4 + nc], axis=1)
if multi_label:
i, j = np.where(cls > conf_thres)
x = np.concatenate((box[i], x[i, 4 + j, None], j[:, None].astype(np.float32), mask[i]), axis=1)
xk = xk[i]
else:
conf = np.amax(cls, axis=1, keepdims=True)
j = np.argmax(cls, axis=1, keepdims=True)
filt = conf.squeeze(-1) > conf_thres
x = np.concatenate((box, conf, j.astype(np.float32), mask), axis=1)[filt]
xk = xk[filt]
if classes is not None:
filt = np.any(x[:, 5:6] == classes, axis=1)
x, xk = x[filt], xk[filt]
n = x.shape[0]
if not n:
continue
if n > max_nms:
filt = np.argsort(x[:, 4])[::-1][:max_nms]
x, xk = x[filt], xk[filt]
c = x[:, 5:6] * (0 if agnostic else max_wh)
scores = x[:, 4]
if rotated:
boxes = np.concatenate((x[:, :2] + c, x[:, 2:4], x[:, -1:]), axis=-1)
i = self.nms_rotated(boxes, scores, iou_thres)
else:
boxes = x[:, :4] + c
i = []
if boxes.shape[0] > 0:
y1, x1, y2, x2 = boxes[:, 1], boxes[:, 0], boxes[:, 3], boxes[:, 2]
area = (x2 - x1) * (y2 - y1)
order = scores.argsort()[::-1]
while order.size > 0:
idx = order[0]
i.append(idx)
xx1 = np.maximum(x1[idx], x1[order[1:]])
yy1 = np.maximum(y1[idx], y1[order[1:]])
xx2 = np.minimum(x2[idx], x2[order[1:]])
yy2 = np.minimum(y2[idx], y2[order[1:]])
w = np.maximum(0.0, xx2 - xx1)
h = np.maximum(0.0, yy2 - yy1)
inter = w * h
iou = inter / (area[idx] + area[order[1:]] - inter)
order = order[np.where(iou <= iou_thres)[0] + 1]
i = np.array(i)
i = i[:max_det]
output[xi], keepi[xi] = x[i], xk[i].reshape(-1)
if (time.time() - t) > time_limit:
break
return (output, keepi) if return_idxs else output
if __name__ == "__main__":
"""本地测试:直接运行识别单张图片"""
model = Slider()
# 测试新版接口(同时返回滑块和缺口)
res = model.identify_both(source='img_example.png', show=True)
print(f'滑块: {res["slider"]}, 置信度: {res["slider_confidence"]}')
print(f'缺口: {res["gap"]}, 置信度: {res["gap_confidence"]}')
print(f'滑动距离: {res["offset"]}')