| """ |
| 滑块验证码识别核心模块 |
| ======================== |
| 基于 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 |
| import numpy as np |
| import onnxruntime as ort |
| from shapely.geometry import Polygon |
|
|
|
|
| |
| |
| |
|
|
| CONF_THRESHOLD = 0.5 |
| |
| |
|
|
| IOU_THRESHOLD = 0.8 |
| |
|
|
| Y_IOU_THRESHOLD = 0.85 |
| |
|
|
|
|
| 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') |
|
|
| |
| |
| self.session = ort.InferenceSession( |
| slider_model_path, |
| providers=["CUDAExecutionProvider", "CPUExecutionProvider"] if ort.get_device() == 'GPU' else [ |
| "CPUExecutionProvider"], |
| ) |
|
|
| |
| |
| 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] |
|
|
| |
| 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)) |
|
|
| |
| 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] |
| img = np.ascontiguousarray(img) |
| img = img.astype(np.float32) / 255 |
| 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.") |
|
|
| |
| |
| |
|
|
| @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) |
| 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. 在同水平线的目标中,找形状与滑块最相似的 → 这就是缺口 |
| |
| 为什么这样判断? |
| - 滑块验证码的布局:滑块在左,缺口在右 |
| - 滑块和缺口形状相同(都是拼图块),但位置不同 |
| - 背景干扰物形状不同,且可能不在同一水平线 |
| """ |
| |
| 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:] |
|
|
| |
| 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]) |
| |
| 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 |
|
|
| |
| |
| 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: |
| |
| 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] |
| else: |
| offset = 0 |
| box_conf = 0.0 |
|
|
| return offset, box_conf |
|
|
| |
| |
| |
| |
|
|
| 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"]}') |
|
|