lze888lze commited on
Commit
b842a1d
·
1 Parent(s): 4838a22
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .git
5
+ .gitignore
6
+ README.md
7
+ Dockerfile
8
+ .dockerignore
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /home/user/app
4
+
5
+ # 安装系统依赖(OpenCV需要)
6
+ RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
7
+
8
+ # 先复制依赖文件,利用Docker缓存
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ # 复制应用代码
13
+ COPY . .
14
+
15
+ EXPOSE 7860
16
+
17
+ CMD ["python", "-u", "main.py"]
README.md CHANGED
@@ -1,10 +1,49 @@
1
  ---
2
- title: HF API
3
- emoji: 🏃
4
- colorFrom: gray
5
- colorTo: pink
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Slider Captcha API
3
+ emoji: 🔍
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
+ app_port: 7860
8
  ---
9
 
10
+ # 滑块验证码识别 API
11
+
12
+ 基于 FastAPI + ONNX 的滑块验证码识别服务。
13
+
14
+ ## 接口
15
+
16
+ | 方法 | 路径 | 说明 |
17
+ |------|------|------|
18
+ | GET | / | 健康检查 |
19
+ | GET | /health | 健康检查 |
20
+ | POST | /captcha | 文件上传识别 |
21
+ | POST | /captcha/base64 | Base64图片识别 |
22
+
23
+ ## 调用示例
24
+
25
+ ### 文件上传
26
+ ```bash
27
+ curl -X POST https://your-space.hf.space/captcha \
28
+ -F "file=@captcha.png"
29
+ ```
30
+
31
+ ### Base64(懒人精灵)
32
+ ```lua
33
+ local http = require("http")
34
+ local json = require("json")
35
+ local base64 = require("base64")
36
+
37
+ local f = io.open("/sdcard/captcha.png", "rb")
38
+ local img = f:read("*a")
39
+ f:close()
40
+
41
+ local resp = http.post("https://your-domain.com/captcha/base64", {
42
+ headers = {["Content-Type"] = "application/json"},
43
+ body = '{"image":"' .. base64.encode(img) .. '"}'
44
+ })
45
+
46
+ local result = json.decode(resp.body)
47
+ -- result.box = [x1, y1, x2, y2]
48
+ -- result.confidence = 0.95
49
+ ```
captcha_recognizer/__init__.py ADDED
File without changes
captcha_recognizer/models/slider.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:caabd60274af1748d9bc8d5c3b2231521000f4d3c10608485ce251503bb97d9a
3
+ size 40533656
captcha_recognizer/slider.py ADDED
@@ -0,0 +1,809 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import os
3
+ import random
4
+ import time
5
+ from pathlib import Path
6
+ from typing import List, Tuple, Union
7
+
8
+ import cv2
9
+ import numpy as np
10
+ import onnxruntime as ort
11
+ from shapely.geometry import Polygon
12
+
13
+ CONF_THRESHOLD = 0.5
14
+
15
+ IOU_THRESHOLD = 0.8
16
+
17
+ Y_IOU_THRESHOLD = 0.85
18
+
19
+
20
+ class Slider:
21
+
22
+ def __init__(self):
23
+ """
24
+ Initialize the instance segmentation model using an ONNX model.
25
+ """
26
+ root_dir = os.path.dirname(os.path.dirname(__file__))
27
+ slider_model_path = os.path.join(root_dir, 'captcha_recognizer', 'models', 'slider.onnx')
28
+
29
+ self.session = ort.InferenceSession(
30
+ slider_model_path,
31
+ providers=["CUDAExecutionProvider", "CPUExecutionProvider"] if ort.get_device() == 'GPU' else [
32
+ "CPUExecutionProvider"],
33
+ )
34
+
35
+ self.classes = {0: 's'}
36
+
37
+ def predict(self, img: np.ndarray, conf: float = 0.25, iou: float = 0.7,
38
+ imgsz: Union[int, Tuple[int, int]] = 640) -> List:
39
+ """
40
+ Run inference on the input image using the ONNX model.
41
+ """
42
+ imgsz = (imgsz, imgsz) if isinstance(imgsz, int) else imgsz
43
+ prep_img = self.preprocess(img, imgsz)
44
+ outs = self.session.run(None, {self.session.get_inputs()[0].name: prep_img})
45
+ return self.postprocess(img, prep_img, outs, conf=conf, iou=iou)
46
+
47
+ @staticmethod
48
+ def letterbox(img: np.ndarray, new_shape: Tuple[int, int] = (640, 640)) -> np.ndarray:
49
+ """
50
+ Resize and pad image while maintaining aspect ratio.
51
+ Returns exactly new_shape sized image.
52
+ """
53
+ shape = img.shape[:2] # current shape [height, width]
54
+
55
+ # Calculate ratio and new dimensions
56
+ r = min(new_shape[0] / shape[0], new_shape[1] / shape[1])
57
+ new_unpad = int(round(shape[1] * r)), int(round(shape[0] * r))
58
+
59
+ # Ensure new dimensions are at least 1 and not larger than target
60
+ new_unpad = (max(1, min(new_unpad[0], new_shape[1])),
61
+ max(1, min(new_unpad[1], new_shape[0])))
62
+
63
+ # Resize if needed
64
+ if shape[::-1] != new_unpad:
65
+ img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR)
66
+
67
+ # Calculate padding
68
+ dw, dh = new_shape[1] - new_unpad[0], new_shape[0] - new_unpad[1]
69
+ dw, dh = float(dw), float(dh)
70
+
71
+ # Divide padding into 2 sides
72
+ top, bottom = int(round(dh / 2)), int(round(dh / 2))
73
+ left, right = int(round(dw / 2)), int(round(dw / 2))
74
+
75
+ # Add padding
76
+ img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=(114, 114, 114))
77
+
78
+ # Final check to ensure exact size (might need crop if rounding caused overflow)
79
+ if img.shape[0] != new_shape[0] or img.shape[1] != new_shape[1]:
80
+ img = cv2.resize(img, new_shape, interpolation=cv2.INTER_LINEAR)
81
+
82
+ return img
83
+
84
+ def preprocess(self, img: np.ndarray, new_shape: Tuple[int, int]) -> np.ndarray:
85
+ """
86
+ Preprocess the input image before feeding it into the model.
87
+ """
88
+ img = self.letterbox(img, new_shape)
89
+ img = img[..., ::-1].transpose([2, 0, 1])[None]
90
+ img = np.ascontiguousarray(img)
91
+ img = img.astype(np.float32) / 255
92
+ return img
93
+
94
+ def postprocess(self, img: np.ndarray, prep_img: np.ndarray, outs: List, conf: float = 0.25,
95
+ iou: float = 0.7) -> List:
96
+ """
97
+ Post-process model predictions to extract meaningful results.
98
+ """
99
+ preds, protos = outs
100
+ preds = self.non_max_suppression(preds, conf, iou, nc=len(self.classes))
101
+
102
+ results = []
103
+ for i, pred in enumerate(preds):
104
+ pred[:, :4] = self.scale_boxes(prep_img.shape[2:], pred[:, :4], img.shape)
105
+ masks = self.process_mask(protos[i], pred[:, 6:], pred[:, :4], img.shape[:2])
106
+ results.append([pred[:, :6], masks])
107
+
108
+ return results
109
+
110
+ def process_mask(self, protos: np.ndarray, masks_in: np.ndarray, bboxes: np.ndarray,
111
+ shape: Tuple[int, int]) -> np.ndarray:
112
+ c, mh, mw = protos.shape
113
+ masks = (masks_in @ protos.reshape(c, -1)).reshape(-1, mh, mw)
114
+ masks = self.scale_masks(masks, shape)
115
+ masks = self.crop_mask(masks, bboxes)
116
+ return masks > 0.0
117
+
118
+ @staticmethod
119
+ def masks_to_segments(masks: Union[np.ndarray,], strategy: str = "largest") -> List[np.ndarray]:
120
+ """
121
+ 将二值Mask转换为多边形边界点(segments),不使用多边形简化
122
+
123
+ 参数:
124
+ masks: 输入的二值Mask,可以是numpy数组或torch张量
125
+ 形状为(batch_size, height, width)或(height, width)
126
+ strategy: 处理多个轮廓的策略:
127
+ 'all' - 合并所有轮廓
128
+ 'largest' - 只保留最大轮廓
129
+ 'none' - 返回所有轮廓不合并
130
+
131
+ 返回:
132
+ 包含多边形点集的列表,每个元素是(N,2)的numpy数组
133
+ """
134
+ # 转换输入为numpy数组
135
+
136
+ masks_np = masks.astype("uint8")
137
+
138
+ # 处理单张mask的情况
139
+ if masks_np.ndim == 2:
140
+ masks_np = masks_np[np.newaxis, ...]
141
+
142
+ segments = []
143
+
144
+ for mask in masks_np:
145
+ # 查找轮廓 (OpenCV 4.x返回格式)
146
+ contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
147
+
148
+ if not contours: # 没有找到轮廓
149
+ segments.append(np.zeros((0, 2), dtype=np.float32))
150
+ continue
151
+
152
+ # 根据策略处理多个轮廓
153
+ if strategy == "all" and len(contours) > 1:
154
+ # 合并所有轮廓,保留所有点
155
+ contour = np.concatenate([x.reshape(-1, 2) for x in contours])
156
+ elif strategy == "largest":
157
+ # 选择最长的轮廓,保留所有点
158
+ contour = max(contours, key=lambda x: cv2.arcLength(x, closed=True))
159
+ contour = contour.reshape(-1, 2)
160
+ else: # 'none'策略或其他情况
161
+ # 不合并轮廓,保留所有点
162
+ contour = contours[0].reshape(-1, 2)
163
+
164
+ segments.append(contour.astype(np.float32))
165
+
166
+ return segments[0] if masks_np.shape[0] == 1 else segments
167
+
168
+ @staticmethod
169
+ def draw_segments(image, boxes, masks,
170
+ mask_alpha=0.5, box_thickness=2, draw_labels=True):
171
+
172
+ """
173
+ 在图像上绘制预测框和掩膜
174
+
175
+ 参数:
176
+ image: 原始图像 (numpy数组, BGR格式)
177
+ boxes: 预测框列表, 格式为 [[x1, y1, x2, y2, score, class_id], ...]
178
+ masks: 掩膜列表, 每个掩膜为二值图像 (0或255)
179
+ box_color: 框的颜色 (BGR格式), 如果为None则随机生成
180
+ mask_alpha: 掩膜透明度 (0-1)
181
+ box_thickness: 框的线宽
182
+ draw_labels: 是否绘制类别和置信度标签
183
+
184
+ 返回:
185
+ 绘制后的图像
186
+ """
187
+ # 创建输出图像的副本
188
+ output = image.copy()
189
+
190
+ # 如果没有提供boxes和masks,直接返回原图
191
+ if boxes is None and masks is None:
192
+ return output
193
+
194
+ # 绘制masks
195
+ if masks is not None:
196
+ # 创建一个空的彩色掩膜图像
197
+ color_mask = np.zeros_like(image)
198
+
199
+ for i, mask in enumerate(masks):
200
+ # 为每个mask生成随机颜色或使用指定颜色
201
+
202
+ color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
203
+ # 将二值mask转换为彩色mask
204
+ mask = mask.astype(bool)
205
+ color_mask[mask] = color
206
+
207
+ # 将彩色掩膜与原始图像混合
208
+ output = cv2.addWeighted(output, 1, color_mask, mask_alpha, 0)
209
+
210
+ # 绘制boxes
211
+ if boxes is not None:
212
+ for box in boxes:
213
+ x1, y1, x2, y2, score, class_id = box[:6] # 只取前6个值,兼容不同格式
214
+
215
+ # 为每个box生成随机颜色或使用指定颜色
216
+
217
+ color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
218
+
219
+ # 绘制矩形框
220
+ cv2.rectangle(output, (int(x1), int(y1)), (int(x2), int(y2)), color, box_thickness)
221
+
222
+ # 绘制标签
223
+ if draw_labels:
224
+ label = f"{int(class_id)}: {score:.2f}"
225
+ (label_width, label_height), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
226
+
227
+ # 绘制标签背景
228
+ cv2.rectangle(output, (int(x1), int(y1) - label_height - 5),
229
+ (int(x1) + label_width, int(y1)), color, -1)
230
+ # 绘制标签文本
231
+ cv2.putText(output, label, (int(x1), int(y1) - 5),
232
+ cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1, cv2.LINE_AA)
233
+
234
+ return output
235
+
236
+ @staticmethod
237
+ def image_to_array(source: Union[str, Path, bytes, np.ndarray] = None):
238
+ if isinstance(source, str) and source.startswith('data:image'):
239
+ # 从Base64字符串读取
240
+ header, encoded = source.split(',', 1)
241
+ data = base64.b64decode(encoded)
242
+ np_arr = np.frombuffer(data, np.uint8)
243
+ return cv2.imdecode(np_arr, cv2.IMREAD_COLOR)
244
+ elif isinstance(source, (str, Path)):
245
+ # 从文件路径读取
246
+ return cv2.imread(str(source))
247
+ elif isinstance(source, bytes):
248
+ # 从字节流读取
249
+ np_arr = np.frombuffer(source, np.uint8)
250
+ return cv2.imdecode(np_arr, cv2.IMREAD_COLOR)
251
+ elif isinstance(source, np.ndarray):
252
+ # 如果已经是 numpy 数组,直接使用
253
+ return source
254
+ else:
255
+ raise TypeError("Unsupported source type. Only str, Path, bytes, or numpy.ndarray are supported.")
256
+
257
+ @staticmethod
258
+ def normalize_points(points):
259
+ """
260
+ 将点集归一化到以原点为中心
261
+ :param points: 点集
262
+ :return: 归一化后的点集
263
+ """
264
+ # 计算质心
265
+ centroid = np.mean(points, axis=0)
266
+ # 将质心移到原点
267
+ normalized_points = points - centroid
268
+ return normalized_points
269
+
270
+ @staticmethod
271
+ def y_iou(segment1, segment2):
272
+ # 计算交集
273
+ start = max(segment1[0], segment2[0])
274
+ end = min(segment1[1], segment2[1])
275
+ intersection = max(0, end - start) # 确保没有负值(无重叠时返回0)
276
+
277
+ # 计算并集
278
+ len1 = segment1[1] - segment1[0]
279
+ len2 = segment2[1] - segment2[0]
280
+ union = len1 + len2 - intersection
281
+
282
+ # 计算 IoU
283
+ iou = intersection / union if union != 0 else 0 # 避免除以0
284
+ return iou
285
+
286
+ def polygon_iou(self, poly1, poly2):
287
+ """
288
+ 计算两个多边形的 IoU
289
+ :param poly1: 多边形1的顶点坐标,格式为 [[x1,y1], [x2,y2], ..., [xn,yn]]
290
+ :param poly2: 多边形2的顶点坐标,格式同上
291
+ :return: IoU 值(范围 [0, 1])
292
+ """
293
+ # 归一化处理到原点
294
+ p1 = self.normalize_points(poly1)
295
+ p2 = self.normalize_points(poly2)
296
+
297
+ poly1 = Polygon(p1).buffer(0) # buffer(0) 修复无效多边形(如自相交)
298
+ poly2 = Polygon(p2).buffer(0)
299
+ # poly2 = Polygon(normalize_points(poly2))
300
+
301
+ # if not poly1.is_valid or not poly2.is_valid:
302
+ # return 0.0 # 无效多边形(如面积为零)
303
+
304
+ # 计算交集和并集面积
305
+ intersect = poly1.intersection(poly2).area
306
+ union = poly1.union(poly2).area
307
+
308
+ # 计算 IoU
309
+ iou = intersect / union if union > 0 else 0.0
310
+ return iou
311
+
312
+ def pick_out_mask(self, boxes: list, segments):
313
+ # boxes, masks 为两个列表,找出box值最小的一个
314
+ box_slider = min(boxes, key=lambda x: x[0])
315
+ box_slider_index = boxes.index(box_slider)
316
+ segment_slider = segments[box_slider_index]
317
+
318
+ box_sample = boxes[:box_slider_index] + boxes[box_slider_index + 1:]
319
+ segment_sample = segments[:box_slider_index] + segments[box_slider_index + 1:]
320
+
321
+ # 先按照y值iou过滤
322
+ box_filtered = []
323
+ segment_filtered = []
324
+
325
+ for index, box in enumerate(box_sample):
326
+ if self.y_iou([box_slider[1], box_slider[3]], [box[1], box[3]]) > Y_IOU_THRESHOLD:
327
+ box_filtered.append(box)
328
+ segment_filtered.append(segment_sample[index])
329
+ # 如果通过y轴iou没有过滤掉有效值,则从所有box中选择iou最大的一个
330
+ if not box_filtered:
331
+ box_filtered = box_sample
332
+ segment_filtered = segment_sample
333
+
334
+ if len(box_filtered) == 1:
335
+ return box_filtered[0], segment_filtered[0]
336
+
337
+ iou_flag = 0
338
+ iou_index = 0
339
+ for index, segment in enumerate(segment_filtered):
340
+ segment_iou = self.polygon_iou(segment_slider, segment)
341
+ if segment_iou > iou_flag:
342
+ iou_flag = segment_iou
343
+ iou_index = index
344
+
345
+ return box_filtered[iou_index], segment_filtered[iou_index]
346
+
347
+ def identify(self, source: Union[str, Path, bytes, np.ndarray], conf=CONF_THRESHOLD, iou=IOU_THRESHOLD, show=False):
348
+ box_list = []
349
+ mask_ndarray = None
350
+
351
+ original_image: np.ndarray = self.image_to_array(source)
352
+ results = self.predict(original_image, conf=conf, iou=iou, imgsz=640)
353
+
354
+ if results:
355
+ boxes, masks = results[0]
356
+ if len(boxes) == 0:
357
+ pass
358
+ elif len(boxes) == 1:
359
+ box_list = boxes[0].tolist()
360
+ mask_ndarray = masks[0]
361
+
362
+ else:
363
+ segments = self.masks_to_segments(masks)
364
+ box_list, _ = self.pick_out_mask(boxes.tolist(), segments)
365
+ mask_ndarray = masks[boxes.tolist().index(box_list)]
366
+
367
+ # 仅展示目标缺口
368
+ if show and box_list and mask_ndarray is not None:
369
+ sample = self.draw_segments(original_image, [box_list, ], [mask_ndarray, ])
370
+ cv2.imshow('result', sample)
371
+ cv2.waitKey(0)
372
+ cv2.destroyAllWindows()
373
+
374
+ if box_list:
375
+ box = box_list[:4]
376
+ box_conf = float(box_list[4])
377
+ else:
378
+ box = []
379
+ box_conf = 0.0
380
+ return box, box_conf
381
+
382
+ def identify_offset(self, source: Union[str, Path, bytes, np.ndarray], conf=CONF_THRESHOLD, iou=IOU_THRESHOLD,
383
+ show=False):
384
+ """
385
+ 通过滑块图或者全图获取offset
386
+ """
387
+ box_list = []
388
+ mask_ndarray = None
389
+
390
+ original_image: np.ndarray = self.image_to_array(source)
391
+ results = self.predict(original_image, conf=conf, iou=iou, imgsz=640)
392
+
393
+ if results:
394
+ boxes, masks = results[0]
395
+ if len(boxes) == 0:
396
+ pass
397
+ elif len(boxes) == 1:
398
+ box_list = boxes[0].tolist()
399
+ mask_ndarray = masks[0]
400
+
401
+ else:
402
+ # 如果有多个目标,则选择X值最小的目标
403
+ box_left = min(boxes, key=lambda x: x[0])
404
+ box_list = box_left.tolist()
405
+ mask_ndarray = masks[boxes.tolist().index(box_list)]
406
+
407
+ # 仅展示目标缺口
408
+ if show and box_list and mask_ndarray is not None:
409
+ sample = self.draw_segments(original_image, [box_list, ], [mask_ndarray, ])
410
+ cv2.imshow('result', sample)
411
+ cv2.waitKey(0)
412
+ cv2.destroyAllWindows()
413
+
414
+ if box_list:
415
+ box = box_list[:4]
416
+ box_conf = float(box_list[4])
417
+ offset = box[0]
418
+ else:
419
+ offset = 0
420
+ box_conf = 0.0
421
+
422
+ return offset, box_conf
423
+
424
+ def scale_boxes(self, img1_shape: Tuple[int, int], boxes: np.ndarray, img0_shape: Tuple[int, int],
425
+ ratio_pad: Union[Tuple, None] = None, padding: bool = True, xywh: bool = False):
426
+ """
427
+ Rescale bounding boxes from one image shape to another.
428
+
429
+ Rescales bounding boxes from img1_shape to img0_shape, accounting for padding and aspect ratio changes.
430
+ Supports both xyxy and xywh box formats.
431
+
432
+ Args:
433
+ img1_shape (tuple): Shape of the source image (height, width).
434
+ boxes (np.ndarray): Bounding boxes to rescale in format (N, 4).
435
+ img0_shape (tuple): Shape of the target image (height, width).
436
+ ratio_pad (tuple, optional): Tuple of (ratio, pad) for scaling. If None, calculated from image shapes.
437
+ padding (bool): Whether boxes are based on YOLO-style augmented images with padding.
438
+ xywh (bool): Whether box format is xywh (True) or xyxy (False).
439
+
440
+ Returns:
441
+ (np.ndarray): Rescaled bounding boxes in the same format as input.
442
+ """
443
+ if ratio_pad is None:
444
+ gain = min(img1_shape[0] / img0_shape[0], img1_shape[1] / img0_shape[1])
445
+ pad = (
446
+ round((img1_shape[1] - img0_shape[1] * gain) / 2),
447
+ round((img1_shape[0] - img0_shape[0] * gain) / 2),
448
+ )
449
+ else:
450
+ gain = ratio_pad[0][0]
451
+ pad = ratio_pad[1]
452
+
453
+ if padding:
454
+ boxes[..., 0] -= pad[0]
455
+ boxes[..., 1] -= pad[1]
456
+ if not xywh:
457
+ boxes[..., 2] -= pad[0]
458
+ boxes[..., 3] -= pad[1]
459
+ boxes[..., :4] /= gain
460
+ return self.clip_boxes(boxes, img0_shape)
461
+
462
+ @staticmethod
463
+ def get_covariance_matrix(boxes: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
464
+ """
465
+ Generate covariance matrix from oriented bounding boxes.
466
+
467
+ Args:
468
+ boxes (np.ndarray): A tensor of shape (N, 5) representing rotated bounding boxes, with xywhr format.
469
+
470
+ Returns:
471
+ (np.ndarray): Covariance matrices corresponding to original rotated bounding boxes.
472
+ """
473
+ gbbs = np.concatenate((np.power(boxes[:, 2:4], 2) / 12, boxes[:, 4:]), axis=-1)
474
+ a, b, c = np.split(gbbs, [1, 2], axis=-1)
475
+ cos = np.cos(c)
476
+ sin = np.sin(c)
477
+ cos2 = np.power(cos, 2)
478
+ sin2 = np.power(sin, 2)
479
+ return a * cos2 + b * sin2, a * sin2 + b * cos2, (a - b) * cos * sin
480
+
481
+ def batch_probiou(self, obb1: np.ndarray, obb2: np.ndarray, eps: float = 1e-7) -> np.ndarray:
482
+ """
483
+ Calculate the probabilistic IoU between oriented bounding boxes.
484
+
485
+ Args:
486
+ obb1 (np.ndarray): A tensor of shape (N, 5) representing ground truth obbs, with xywhr format.
487
+ obb2 (np.ndarray): A tensor of shape (M, 5) representing predicted obbs, with xywhr format.
488
+ eps (float, optional): A small value to avoid division by zero.
489
+
490
+ Returns:
491
+ (np.ndarray): A tensor of shape (N, M) representing obb similarities.
492
+ """
493
+ x1, y1 = np.split(obb1[..., :2], 2, axis=-1)
494
+ x2, y2 = (np.expand_dims(x.squeeze(-1), 0) for x in np.split(obb2[..., :2], 2, axis=-1))
495
+ a1, b1, c1 = self.get_covariance_matrix(obb1)
496
+ a2, b2, c2 = (np.expand_dims(x.squeeze(-1), 0) for x in self.get_covariance_matrix(obb2))
497
+
498
+ t1 = (
499
+ ((a1 + a2) * np.power(y1 - y2, 2) + (b1 + b2) * np.power(x1 - x2, 2)) / (
500
+ (a1 + a2) * (b1 + b2) - np.power(c1 + c2, 2) + eps)
501
+ ) * 0.25
502
+ t2 = (((c1 + c2) * (x2 - x1) * (y1 - y2)) / ((a1 + a2) * (b1 + b2) - np.power(c1 + c2, 2) + eps)) * 0.5
503
+
504
+ term1_log = (a1 * b1 - np.power(c1, 2)).clip(0)
505
+ term2_log = (a2 * b2 - np.power(c2, 2)).clip(0)
506
+
507
+ denominator = 4 * np.sqrt(term1_log * term2_log) + eps
508
+ t3_numerator = (a1 + a2) * (b1 + b2) - np.power(c1 + c2, 2)
509
+ # 确保 log 的输入为正值
510
+ t3_arg = np.clip(t3_numerator / denominator + eps, eps, None)
511
+ t3 = np.log(t3_arg) * 0.5
512
+
513
+ bd = (t1 + t2 + t3).clip(eps, 100.0)
514
+ hd = np.sqrt(1.0 - np.exp(-bd) + eps)
515
+ return 1 - hd
516
+
517
+ def nms_rotated(self, boxes: np.ndarray, scores: np.ndarray, threshold: float = 0.45):
518
+ """
519
+ Perform NMS on oriented bounding boxes using probiou and fast-nms.
520
+
521
+ Args:
522
+ boxes (np.ndarray): Rotated bounding boxes with shape (N, 5) in xywhr format.
523
+ scores (np.ndarray): Confidence scores with shape (N,).
524
+ threshold (float): IoU threshold for NMS.
525
+
526
+ Returns:
527
+ (np.ndarray): Indices of boxes to keep after NMS.
528
+ """
529
+ sorted_idx = np.argsort(scores)[::-1]
530
+ boxes = boxes[sorted_idx]
531
+ ious = self.batch_probiou(boxes, boxes)
532
+
533
+ # 使用更高效的方式创建上三角矩阵
534
+ n = boxes.shape[0]
535
+ ious[np.tril_indices(n)] = 0 # 将下三角和对角线置零
536
+
537
+ pick = np.where((ious >= threshold).sum(axis=0) <= 0)[0]
538
+ return sorted_idx[pick]
539
+
540
+ def clip_boxes(self, boxes: np.ndarray, shape: Tuple[int, int]):
541
+ """
542
+ Clip bounding boxes to image boundaries.
543
+
544
+ Args:
545
+ boxes (np.ndarray): Bounding boxes to clip.
546
+ shape (tuple): Image shape as (height, width).
547
+
548
+ Returns:
549
+ (np.ndarray): Clipped bounding boxes.
550
+ """
551
+ boxes[..., [0, 2]] = np.clip(boxes[..., [0, 2]], 0, shape[1])
552
+ boxes[..., [1, 3]] = np.clip(boxes[..., [1, 3]], 0, shape[0])
553
+ return boxes
554
+
555
+ @staticmethod
556
+ def xywh2xyxy(x: np.ndarray):
557
+ """
558
+ Convert bounding box coordinates from (x, y, width, height) format to (x1, y1, x2, y2) format.
559
+
560
+ Args:
561
+ x (np.ndarray): Input bounding box coordinates in (x, y, width, height) format.
562
+
563
+ Returns:
564
+ (np.ndarray): Bounding box coordinates in (x1, y1, x2, y2) format.
565
+ """
566
+ assert x.shape[-1] == 4, f"input shape last dimension expected 4 but input shape is {x.shape}"
567
+ y = np.empty_like(x, dtype=np.float32)
568
+ xy = x[..., :2]
569
+ wh = x[..., 2:] / 2
570
+ y[..., :2] = xy - wh
571
+ y[..., 2:] = xy + wh
572
+ return y
573
+
574
+ @staticmethod
575
+ def crop_mask(masks: np.ndarray, boxes: np.ndarray):
576
+ """
577
+ Crop masks to bounding box regions.
578
+
579
+ Args:
580
+ masks (np.ndarray): Masks with shape (N, H, W).
581
+ boxes (np.ndarray): Bounding box coordinates with shape (N, 4) in relative point form.
582
+
583
+ Returns:
584
+ (np.ndarray): Cropped masks.
585
+ """
586
+ _, h, w = masks.shape
587
+ # 确保 boxes 的维度正确
588
+ boxes = boxes[:, :, None] if boxes.ndim == 2 else boxes
589
+ x1, y1, x2, y2 = np.split(boxes, 4, axis=1)
590
+ r = np.arange(w, dtype=x1.dtype)[None, None, :]
591
+ c = np.arange(h, dtype=x1.dtype)[None, :, None]
592
+
593
+ return masks * ((r >= x1) * (r < x2) * (c >= y1) * (c < y2))
594
+
595
+ def process_mask_np(self, protos: np.ndarray, masks_in: np.ndarray, bboxes: np.ndarray, shape: Tuple[int, int],
596
+ upsample: bool = False):
597
+ """
598
+ Apply masks to bounding boxes using mask head output.
599
+
600
+ Args:
601
+ protos (np.ndarray): Mask prototypes with shape (mask_dim, mask_h, mask_w).
602
+ masks_in (np.ndarray): Mask coefficients with shape (N, mask_dim) where N is number of masks after NMS.
603
+ bboxes (np.ndarray): Bounding boxes with shape (N, 4) where N is number of masks after NMS.
604
+ shape (tuple): Input image size as (height, width).
605
+ upsample (bool): Whether to upsample masks to original image size.
606
+
607
+ Returns:
608
+ (np.ndarray): A binary mask array of shape [n, h, w], where n is the number of masks after NMS, and h and w
609
+ are the height and width of the input image. The mask is applied to the bounding boxes.
610
+ """
611
+ c, mh, mw = protos.shape
612
+ ih, iw = shape
613
+
614
+ masks = (masks_in @ protos.reshape(c, -1)).reshape(-1, mh, mw)
615
+ width_ratio = mw / iw
616
+ height_ratio = mh / ih
617
+
618
+ downsampled_bboxes = bboxes.copy()
619
+ downsampled_bboxes[:, 0] *= width_ratio
620
+ downsampled_bboxes[:, 2] *= width_ratio
621
+ downsampled_bboxes[:, 3] *= height_ratio
622
+ downsampled_bboxes[:, 1] *= height_ratio
623
+
624
+ masks = self.crop_mask(masks, downsampled_bboxes)
625
+ if upsample:
626
+ masks = cv2.resize(masks.transpose((1, 2, 0)),
627
+ (shape[1], shape[0]),
628
+ interpolation=cv2.INTER_LINEAR).transpose((2, 0, 1))
629
+
630
+ return masks > 0.0
631
+
632
+ @staticmethod
633
+ def scale_masks(masks: np.ndarray, shape: Tuple[int, int], padding: bool = True):
634
+ """
635
+ Rescale segment masks to target shape.
636
+ Args:
637
+ masks (np.ndarray): Masks with shape (N, H, W).
638
+ shape (tuple): Target height and width as (height, width).
639
+ padding (bool): Whether masks are based on YOLO-style augmented images with padding.
640
+ Returns:
641
+ (np.ndarray): Rescaled masks with shape (N, H_new, W_new).
642
+ """
643
+ mh, mw = masks.shape[1:]
644
+ gain = min(mh / shape[0], mw / shape[1])
645
+ pad = [mw - shape[1] * gain, mh - shape[0] * gain]
646
+
647
+ if padding:
648
+ pad[0] /= 2
649
+ pad[1] /= 2
650
+
651
+ top, left = (int(round(pad[1])), int(round(pad[0]))) if padding else (0, 0)
652
+ bottom, right = (
653
+ mh - int(round(pad[1])),
654
+ mw - int(round(pad[0])),
655
+ )
656
+
657
+ # Crop the masks first
658
+ masks_cropped = masks[:, top:bottom, left:right]
659
+
660
+ # 向量化 resize 操作
661
+ resized_masks = np.zeros((masks_cropped.shape[0], shape[0], shape[1]), dtype=masks_cropped.dtype)
662
+ for i, mask in enumerate(masks_cropped):
663
+ resized_masks[i] = cv2.resize(mask, (shape[1], shape[0]), interpolation=cv2.INTER_LINEAR)
664
+
665
+ return resized_masks
666
+
667
+ def non_max_suppression(
668
+ self,
669
+ prediction: np.ndarray,
670
+ conf_thres: float = 0.25,
671
+ iou_thres: float = 0.45,
672
+ classes=None,
673
+ agnostic: bool = False,
674
+ multi_label: bool = False,
675
+ labels=(),
676
+ max_det: int = 300,
677
+ nc: int = 0,
678
+ max_time_img: float = 0.05,
679
+ max_nms: int = 30000,
680
+ max_wh: int = 7680,
681
+ in_place: bool = True,
682
+ rotated: bool = False,
683
+ end2end: bool = False,
684
+ return_idxs: bool = False,
685
+ ):
686
+ """
687
+ Perform non-maximum suppression (NMS) on prediction results.
688
+ """
689
+ assert 0 <= conf_thres <= 1, f"Invalid Confidence threshold {conf_thres}, valid values are between 0.0 and 1.0"
690
+ assert 0 <= iou_thres <= 1, f"Invalid IoU {iou_thres}, valid values are between 0.0 and 1.0"
691
+
692
+ if isinstance(prediction, (list, tuple)):
693
+ prediction = prediction[0]
694
+ if classes is not None:
695
+ classes = np.array(classes)
696
+
697
+ if prediction.shape[-1] == 6 or end2end:
698
+ output = [pred[pred[:, 4] > conf_thres][:max_det] for pred in prediction]
699
+ if classes is not None:
700
+ output = [pred[np.any(pred[:, 5:6] == classes, axis=1)] for pred in output]
701
+ return output
702
+
703
+ bs = prediction.shape[0]
704
+ nc = nc or (prediction.shape[1] - 4)
705
+ extra = prediction.shape[1] - nc - 4
706
+ mi = 4 + nc
707
+ xc = np.amax(prediction[:, 4:mi], axis=1) > conf_thres
708
+ xinds = np.stack([np.arange(len(i)) for i in xc])[..., None]
709
+
710
+ time_limit = 2.0 + max_time_img * bs
711
+ multi_label &= nc > 1
712
+
713
+ prediction = np.transpose(prediction, (0, 2, 1))
714
+ if not rotated:
715
+ if in_place:
716
+ prediction[..., :4] = self.xywh2xyxy(prediction[..., :4])
717
+ else:
718
+ prediction = np.concatenate((self.xywh2xyxy(prediction[..., :4]), prediction[..., 4:]), axis=-1)
719
+
720
+ t = time.time()
721
+ output = [np.zeros((0, 6 + extra), dtype=np.float32)] * bs
722
+ keepi = [np.zeros((0, 1), dtype=np.int64)] * bs
723
+ for xi, (x, xk) in enumerate(zip(prediction, xinds)):
724
+ filt = xc[xi]
725
+ x, xk = x[filt], xk[filt]
726
+
727
+ # 增强 labels 的健壮性
728
+ if labels and len(labels) > xi and len(labels[xi]) and not rotated:
729
+ lb = np.array(labels[xi])
730
+ if lb.size > 0:
731
+ v = np.zeros((len(lb), nc + extra + 4), dtype=np.float32)
732
+ v[:, :4] = self.xywh2xyxy(lb[:, 1:5])
733
+ v[range(len(lb)), lb[:, 0].astype(np.int64) + 4] = 1.0
734
+ x = np.concatenate((x, v), axis=0)
735
+
736
+ if not x.shape[0]:
737
+ continue
738
+
739
+ box, cls, mask = np.split(x, [4, 4 + nc], axis=1)
740
+
741
+ if multi_label:
742
+ i, j = np.where(cls > conf_thres)
743
+ x = np.concatenate((box[i], x[i, 4 + j, None], j[:, None].astype(np.float32), mask[i]), axis=1)
744
+ xk = xk[i]
745
+ else:
746
+ conf = np.amax(cls, axis=1, keepdims=True)
747
+ j = np.argmax(cls, axis=1, keepdims=True)
748
+ filt = conf.squeeze(-1) > conf_thres
749
+ x = np.concatenate((box, conf, j.astype(np.float32), mask), axis=1)[filt]
750
+ xk = xk[filt]
751
+
752
+ if classes is not None:
753
+ filt = np.any(x[:, 5:6] == classes, axis=1)
754
+ x, xk = x[filt], xk[filt]
755
+
756
+ n = x.shape[0]
757
+ if not n:
758
+ continue
759
+ if n > max_nms:
760
+ filt = np.argsort(x[:, 4])[::-1][:max_nms]
761
+ x, xk = x[filt], xk[filt]
762
+
763
+ c = x[:, 5:6] * (0 if agnostic else max_wh)
764
+ scores = x[:, 4]
765
+
766
+ if rotated:
767
+ boxes = np.concatenate((x[:, :2] + c, x[:, 2:4], x[:, -1:]), axis=-1)
768
+ i = self.nms_rotated(boxes, scores, iou_thres)
769
+ else:
770
+ boxes = x[:, :4] + c
771
+ # Custom NMS for numpy
772
+ i = []
773
+ if boxes.shape[0] > 0:
774
+ y1, x1, y2, x2 = boxes[:, 1], boxes[:, 0], boxes[:, 3], boxes[:, 2]
775
+ area = (x2 - x1) * (y2 - y1)
776
+ order = scores.argsort()[::-1]
777
+ while order.size > 0:
778
+ idx = order[0]
779
+ i.append(idx)
780
+ xx1 = np.maximum(x1[idx], x1[order[1:]])
781
+ yy1 = np.maximum(y1[idx], y1[order[1:]])
782
+ xx2 = np.minimum(x2[idx], x2[order[1:]])
783
+ yy2 = np.minimum(y2[idx], y2[order[1:]])
784
+ w = np.maximum(0.0, xx2 - xx1)
785
+ h = np.maximum(0.0, yy2 - yy1)
786
+ inter = w * h
787
+ iou = inter / (area[idx] + area[order[1:]] - inter)
788
+ order = order[np.where(iou <= iou_thres)[0] + 1]
789
+ i = np.array(i)
790
+
791
+ i = i[:max_det]
792
+
793
+ output[xi], keepi[xi] = x[i], xk[i].reshape(-1)
794
+ if (time.time() - t) > time_limit:
795
+ break
796
+
797
+ return (output, keepi) if return_idxs else output
798
+
799
+
800
+ if __name__ == "__main__":
801
+ """
802
+ 单缺口
803
+ """
804
+ model = Slider()
805
+ # base64 图片测试
806
+ # base64_image = 'xxx'
807
+ # res = model.identify(source=base64_image, show=True)
808
+ res = model.identify(source='img_example.png', show=True)
809
+ print('results', res)
main.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gc
2
+ import os
3
+ from typing import List
4
+
5
+ import cv2
6
+ import numpy as np
7
+ from captcha_recognizer.slider import Slider
8
+ from fastapi import FastAPI, File, UploadFile
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from pydantic import BaseModel
11
+
12
+ app = FastAPI(
13
+ docs_url=None,
14
+ redoc_url=None,
15
+ )
16
+
17
+ # 允许跨域
18
+ app.add_middleware(
19
+ CORSMiddleware,
20
+ allow_origins=["*"],
21
+ allow_methods=["*"],
22
+ allow_headers=["*"],
23
+ )
24
+
25
+ # 启动时只加载一次模型
26
+ slider = Slider()
27
+
28
+
29
+ @app.get("/")
30
+ def hello_captcha():
31
+ return {"Hello": "Captcha", "status": "running"}
32
+
33
+
34
+ @app.get("/health")
35
+ def health():
36
+ return {"status": "ok"}
37
+
38
+
39
+ class DetectionResult(BaseModel):
40
+ box: List[int] # [x1, y1, x2, y2]
41
+ confidence: float
42
+ message: str = None
43
+
44
+
45
+ @app.post("/captcha", response_model=DetectionResult)
46
+ async def captcha(file: UploadFile = File(...)):
47
+ contents = await file.read()
48
+
49
+ nparr = np.frombuffer(contents, np.uint8)
50
+ image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
51
+
52
+ if image is None:
53
+ return DetectionResult(box=[], confidence=0, message="不支持的图片")
54
+
55
+ box, confidence = slider.identify(source=image)
56
+ box = [int(x) for x in box]
57
+
58
+ del image, nparr, contents
59
+ gc.collect()
60
+
61
+ return DetectionResult(box=box, confidence=confidence)
62
+
63
+
64
+ @app.post("/captcha/base64", response_model=DetectionResult)
65
+ async def captcha_base64(data: dict):
66
+ """支持base64图片上传,用于懒人精灵调用"""
67
+ import base64
68
+
69
+ image_b64 = data.get("image", "")
70
+ if not image_b64:
71
+ return DetectionResult(box=[], confidence=0, message="缺少image参数")
72
+
73
+ # 去掉base64头(如果有)
74
+ if "," in image_b64:
75
+ image_b64 = image_b64.split(",")[1]
76
+
77
+ try:
78
+ image_bytes = base64.b64decode(image_b64)
79
+ nparr = np.frombuffer(image_bytes, np.uint8)
80
+ image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
81
+ except Exception as e:
82
+ return DetectionResult(box=[], confidence=0, message=f"base64解码失败: {str(e)}")
83
+
84
+ if image is None:
85
+ return DetectionResult(box=[], confidence=0, message="不支持的图片")
86
+
87
+ box, confidence = slider.identify(source=image)
88
+ box = [int(x) for x in box]
89
+
90
+ del image, nparr, image_bytes
91
+ gc.collect()
92
+
93
+ return DetectionResult(box=box, confidence=confidence)
94
+
95
+
96
+ if __name__ == "__main__":
97
+ import uvicorn
98
+ port = int(os.environ.get("PORT", 7860))
99
+ uvicorn.run(app, host="0.0.0.0", port=port)
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ opencv-python-headless
2
+ shapely
3
+ onnxruntime
4
+ fastapi
5
+ uvicorn
6
+ python-multipart
7
+ pydantic