rectifier.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. """
  2. 卡牌透视矫正模块 (rectifier)
  3. 作用:把 YOLO 检出的卡牌区域,用分割 mask 的 4 角点做透视拉正(梯形→矩形),
  4. 并按 PaddleOCR 给出的方向 angle 把卡牌转到正立竖向。
  5. 设计要点:
  6. - 透视拉正只用 YOLO mask(凸包→approxPolyDP→4角点),与 OCR 无关;
  7. - 正立(防180°倒置)用 PaddleOCR doc_preprocessor_res.angle(0/90/180/270),
  8. 该 angle 由 paddleocr 环境的语种脚本一并产出,主流程读 JSON 获得;
  9. - mask 可用时直接在原图 warpPerspective(裁切+去透视一步到位);
  10. mask 不可用时回退到 YOLO bbox 矩形裁切;再不行用原图。
  11. - 只影响下游 DINOv2 匹配,不影响 OCR / 语种判断。
  12. 纯 cv2 + numpy,运行在 pytorch 环境,无 OCR 依赖。
  13. """
  14. import cv2
  15. import numpy as np
  16. # 卡牌标准宽高比(宝可梦≈0.716,球星卡≈0.714),用于约束 warp 输出比例,避免 mask 抖动导致畸变
  17. CARD_ASPECT = 0.714
  18. # PaddleOCR angle = 输入图相对正立顺时针旋转的角度;要转正就反向旋转
  19. # 90 → 输入顺时针倒 → 逆时针转90° (ROTATE_90_COUNTERCLOCKWISE)
  20. # 180 → 转180°
  21. # 270 → 输入逆时针倒 → 顺时针转90° (ROTATE_90_CLOCKWISE)
  22. _ANGLE_TO_CV = {
  23. 0: None,
  24. 90: cv2.ROTATE_90_COUNTERCLOCKWISE,
  25. 180: cv2.ROTATE_180,
  26. 270: cv2.ROTATE_90_CLOCKWISE,
  27. }
  28. def mask_to_corners(mask, min_area=30.0):
  29. """
  30. 二值 mask → 凸包 → approxPolyDP 取 4 角点。
  31. Args:
  32. mask: uint8 HxW,非零区域为卡牌
  33. Returns:
  34. corners (4,2) float32;失败返回 None
  35. """
  36. if mask is None:
  37. return None
  38. m = (mask > 0).astype(np.uint8)
  39. cnts, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
  40. if not cnts:
  41. return None
  42. c = max(cnts, key=cv2.contourArea)
  43. if cv2.contourArea(c) < min_area:
  44. return None
  45. hull = cv2.convexHull(c)
  46. peri = cv2.arcLength(hull, True)
  47. for eps in (0.01, 0.02, 0.03, 0.04, 0.06, 0.08, 0.10, 0.15):
  48. approx = cv2.approxPolyDP(hull, eps * peri, True)
  49. if len(approx) == 4:
  50. return approx.reshape(4, 2).astype(np.float32)
  51. # 兜底:最小外接矩形 4 角点
  52. box = cv2.boxPoints(cv2.minAreaRect(hull))
  53. return box.astype(np.float32)
  54. def order_corners(pts):
  55. """
  56. 4 点排序为 TL, TR, BR, BL(按图像坐标,与卡牌真实上下无关;上下由 angle 后续修正)。
  57. """
  58. pts = np.asarray(pts, dtype=np.float32).reshape(4, 2)
  59. rect = np.zeros((4, 2), dtype=np.float32)
  60. s = pts.sum(axis=1)
  61. d = np.diff(pts, axis=1) # y - x
  62. rect[0] = pts[np.argmin(s)] # TL: 最小 x+y
  63. rect[2] = pts[np.argmax(s)] # BR: 最大 x+y
  64. rect[1] = pts[np.argmin(d)] # TR: 最小 y-x
  65. rect[3] = pts[np.argmax(d)] # BL: 最大 y-x
  66. return rect
  67. def _angle_at(a, b, c):
  68. """角 abc 的度数"""
  69. v1, v2 = a - b, c - b
  70. n = np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-9
  71. return float(np.degrees(np.arccos(np.clip(np.dot(v1, v2) / n, -1, 1))))
  72. def valid_quad(ordered, angle_tol=40.0, edge_ratio_max=3.0):
  73. """4 角点四边形是否合理:每个内角近 90°、边长比不极端。
  74. 用于挡住 mask 抖动/角点崩坏(如 demo 第6张那种被标偏的情况),异常则回退 bbox。
  75. 阈值宽松:内角 50~130°、边长 max/min<3。第6张(87-93°,1.32)轻松通过。"""
  76. tl, tr, br, bl = ordered
  77. quad = [tl, tr, br, bl]
  78. for i in range(4):
  79. if abs(_angle_at(quad[i - 1], quad[i], quad[(i + 1) % 4]) - 90) > angle_tol:
  80. return False
  81. edges = [np.linalg.norm(tr - tl), np.linalg.norm(br - tr),
  82. np.linalg.norm(bl - br), np.linalg.norm(tl - bl)]
  83. emin = min(edges)
  84. if emin < 1 or max(edges) / emin > edge_ratio_max:
  85. return False
  86. return True
  87. def has_perspective(ordered, trap_thresh=0.10, maxdev_thresh=3.5):
  88. """四边形是否有"值得矫正"的真实透视形变。
  89. 两个指标:
  90. trap = 上下边长差的相对量(梯形度,0=对边完全平行);
  91. maxdev = 四个内角偏离 90° 的最大值(倾斜/剪切)。
  92. 任一超阈值才算有透视,否则视为"本就端正"——这种卡 warp 只会引入重采样噪声
  93. (实测 tx13/25/86 透视极小,硬 warp 反而掉 0.06~0.36 相似度)。
  94. 阈值由 tx13/25/86(无,≤0.083/≤2.4) 与 tx22/55/2(有,≥0.18 或 ≥4.3) 的间隙标定。"""
  95. tl, tr, br, bl = ordered
  96. wt, wb = np.linalg.norm(tr - tl), np.linalg.norm(br - bl)
  97. trap = abs(wt - wb) / (min(wt, wb) + 1e-9)
  98. angs = [_angle_at(ordered[(i - 1) % 4], ordered[i], ordered[(i + 1) % 4]) for i in range(4)]
  99. maxdev = max(abs(a - 90) for a in angs)
  100. return trap > trap_thresh or maxdev > maxdev_thresh
  101. def angle_geometry_disagree(mask, angle):
  102. """OCR 文字方向(angle) 与 卡牌几何方向(mask) 是否不一致。
  103. 不一致 = 模型对"卡牌到底正不正"没把握 → 应走人工判断(manual_judgment)。
  104. 判定:
  105. 几何:mask 4角点的 W/H,W>H 视为"侧放"(landscape);
  106. OCR:angle∈{90,270} 视为"侧放"。
  107. 两者不一致(一个说侧放、一个说正立) → 返回 True。
  108. 实例:tx86(mask横向+angle0)、tx38/65(mask竖向+angle90) 均不一致→人工;
  109. tx2(mask横向+angle90) 一致→自动转正。"""
  110. if mask is None:
  111. return False
  112. corners = mask_to_corners(mask)
  113. if corners is None:
  114. return False
  115. o = order_corners(corners)
  116. tl, tr, br, bl = o
  117. W = (np.linalg.norm(tr - tl) + np.linalg.norm(br - bl)) / 2.0
  118. H = (np.linalg.norm(bl - tl) + np.linalg.norm(br - tr)) / 2.0
  119. if W < 1 or H < 1:
  120. return False
  121. geo_landscape = W > H
  122. angle_sideways = int(angle or 0) % 360 in (90, 270)
  123. return geo_landscape != angle_sideways
  124. def _dst_size(ordered):
  125. """由角点边长定 warp 输出尺寸(忠实保留实测比例,不强制 CARD_ASPECT)。
  126. 不强制比例的原因:侧放卡的 mask 是横向(w>h),必须忠实保留,后续 rotate_to_portrait
  127. 才能靠几何判断出"侧放"。若强制 0.714 会把侧放卡压成方形/竖向,破坏方向判断。"""
  128. tl, tr, br, bl = ordered
  129. w_top = np.linalg.norm(tr - tl)
  130. w_bot = np.linalg.norm(br - bl)
  131. h_left = np.linalg.norm(bl - tl)
  132. h_right = np.linalg.norm(br - tr)
  133. W = max(int(round((w_top + w_bot) / 2.0)), 1)
  134. H = max(int(round((h_left + h_right) / 2.0)), 1)
  135. return W, H
  136. def warp_perspective(img, corners):
  137. """
  138. 用 4 角点把卡牌拉正为竖向矩形(裁切+去透视)。
  139. Returns: 拉正后的图(与输入同通道顺序);角点无效返回 None。
  140. """
  141. ordered = order_corners(corners)
  142. size = _dst_size(ordered)
  143. if size is None:
  144. return None
  145. W_out, H_out = size
  146. dst = np.array([[0, 0], [W_out - 1, 0], [W_out - 1, H_out - 1], [0, H_out - 1]],
  147. dtype=np.float32)
  148. M = cv2.getPerspectiveTransform(ordered, dst)
  149. return cv2.warpPerspective(img, M, (W_out, H_out))
  150. def rotate_upright(img, angle):
  151. """按 PaddleOCR angle 把图转到正立(angle=输入相对正立顺时针的旋转度数)。"""
  152. code = _ANGLE_TO_CV.get(int(angle) % 360)
  153. if code is None:
  154. return img
  155. return cv2.rotate(img, code)
  156. def ensure_portrait(img):
  157. """保证竖向:宽>高则逆时针转90°(仅当无 angle 信息或兜底时生效)。"""
  158. h, w = img.shape[:2]
  159. if w > h:
  160. return cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE)
  161. return img
  162. def rotate_to_portrait(img, angle=0):
  163. """正立旋转:只在 OCR 明确判 90/270(侧放且给了方向)且几何确为横向时才转。
  164. - 已竖向(w<=h):不转。覆盖 angle=180 不可靠情况,及 tx38 这类 angle=90 但实际竖向的卡。
  165. - 横向(w>h):仅当 angle∈{90,270} 才按方向转;angle=0/180 一律不转——
  166. 实测 tx86(angle=0 但横向) 盲转会倒置(0.45),而不转保持横向反而(0.86)。
  167. 即:方向不明时不赌,保持原样交给 DINOv2。"""
  168. h, w = img.shape[:2]
  169. if w <= h:
  170. return img
  171. a = int(angle or 0) % 360
  172. if a == 90:
  173. return cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE)
  174. if a == 270:
  175. return cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE)
  176. return img
  177. def crop_bbox(img, box):
  178. """按 YOLO xyxy 框裁切(兜底路径)。box=(x1,y1,x2,y2)。"""
  179. if box is None:
  180. return img
  181. h, w = img.shape[:2]
  182. x1, y1, x2, y2 = box
  183. x1, y1 = max(0, int(x1)), max(0, int(y1))
  184. x2, y2 = min(w, int(x2)), min(h, int(y2))
  185. if x2 <= x1 or y2 <= y1:
  186. return img
  187. return img[y1:y2, x1:x2]
  188. class CardRectifier:
  189. """
  190. 卡牌矫正器。rectify() 输入原图 + (mask 或 box) + angle,输出正立竖向卡牌图。
  191. """
  192. def __init__(self, use_perspective=True):
  193. self.use_perspective = use_perspective
  194. def rectify(self, img, mask=None, box=None, angle=0):
  195. """
  196. Args:
  197. img: ndarray 原图(BGR 或 RGB,本模块不转换颜色,原样返回同顺序)
  198. mask: 卡牌二值 mask,与 img 同坐标(来自 YOLO masks,已缩放到原图尺寸)
  199. box: (x1,y1,x2,y2) YOLO bbox,mask 不可用时兜底
  200. angle: PaddleOCR doc orientation angle (0/90/180/270)
  201. Returns:
  202. 矫正后的 ndarray(正立竖向);任何失败回退到 ensure_portrait(原图)
  203. """
  204. out = None
  205. if self.use_perspective:
  206. corners = mask_to_corners(mask) if mask is not None else None
  207. if corners is not None:
  208. ordered = order_corners(corners)
  209. # 同时满足:四边形合理 + 确有透视形变,才 warp;否则用 bbox(安全)
  210. if valid_quad(ordered) and has_perspective(ordered):
  211. warped = warp_perspective(img, corners)
  212. if warped is not None and warped.size > 0:
  213. out = warped
  214. if out is None and box is not None:
  215. out = crop_bbox(img, box)
  216. if out is None:
  217. out = img
  218. # 正立:几何(横向)主判 + angle(90/270)选方向;竖向卡(含误报180)不碰
  219. out = rotate_to_portrait(out, angle)
  220. return out