card_no_leniency.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. # -*- coding: utf-8 -*-
  2. """card_no rec 后处理:单次宽容政策 + 合法格式判定。
  3. 规则见 data_all/卡牌区域/卡牌区域OCR_rec宽容政策.md
  4. 合法格式见 data_all/卡牌区域/卡牌区域OCR内容指南.md
  5. """
  6. from __future__ import annotations
  7. import re
  8. # 稀有度后缀,按长度从长到短匹配
  9. RARITY_SUFFIXES = ["RRR", "SSR", "SAR", "CHR", "CSR", "UR", "HR", "SR", "RR", "AR",
  10. "MR", "MA", "PR", "TR"]
  11. # 末尾单符号(可带前导空格)
  12. TAIL_CHARS = ["*", "★", "$", "C"]
  13. # 纯数字固定位置:长度 → 0-based 索引
  14. DIGIT_SLASH_INDEX = {7: 3, 6: 2, 5: 2, 4: 1}
  15. RE_DIGIT_SPACE_SLASH = re.compile(r"^[\d /]+$")
  16. RE_DIGIT_DOT = re.compile(r"^\d+\.\d+$")
  17. RE_NO_MO = re.compile(r"^[NnmMm][AaOo]\.?\s*(\d{1,4})$")
  18. # 合法格式(老五种 + 补全:斜杠1~5、促销更长、中点·、长码≥3)
  19. RE_VALID_SLASH = re.compile(
  20. r"^[A-Za-z0-9]{1,5}/"
  21. r"(?:"
  22. r"[A-Za-z0-9]{1,5}" # 普通两侧 1~5
  23. r"|[A-Za-z0-9]{1,6}-[A-Za-z0-9]{1,3}" # 连字符促销 如 SM-P / 30th-P / S-P
  24. r"|[A-Za-z0-9]{1,6}·[A-Za-z0-9]{1,3}" # 中点促销 如 SM·P
  25. r")$"
  26. )
  27. RE_VALID_NO = re.compile(r"^No\.\d{1,4}$")
  28. RE_VALID_LONG = re.compile(r"^[A-Z][A-Z0-9]{2,}$") # 总长≥3,字母开头,含数字在 is_valid 再查
  29. RE_VALID_SHORT_NUM = re.compile(r"^\d{1,3}$")
  30. RE_VALID_HASH = re.compile(r"^[A-Za-z]{3,4}#\d+$")
  31. CARD_NO_BLACKLIST = {"999", "000"}
  32. # 空结果原因(与“无检测框”区分)
  33. EMPTY_NO_DETECTION = "no_detection" # yolo26s conf<0.3,卡面本身无编号
  34. EMPTY_BLUR_UNREADABLE = "blur_unreadable" # 有检测框,但 rec 低分且不合规(图模糊)
  35. EMPTY_REC_EMPTY = "rec_empty" # 有检测框,rec 直接空文本
  36. def apply_leniency(text: str) -> str:
  37. """对 rec 识别文本应用宽容政策,返回最终 card_no。仅执行一次,不递归。"""
  38. if text is None:
  39. return ""
  40. text = str(text).strip()
  41. if not text:
  42. return ""
  43. # 1. 末尾稀有度后缀 / 单符号剥离(允许前导空格)
  44. for suffix in RARITY_SUFFIXES:
  45. if text.endswith(" " + suffix):
  46. return text[: -(len(suffix) + 1)].rstrip()
  47. if text.endswith(suffix):
  48. return text[: -len(suffix)].rstrip()
  49. for ch in TAIL_CHARS:
  50. if text.endswith(" " + ch):
  51. return text[:-2].rstrip()
  52. if text.endswith(ch):
  53. return text[:-1].rstrip()
  54. # 2. 末尾 '-' 补 'P'(如 001/SM- → 001/SM-P)
  55. if text.endswith("-"):
  56. return text + "P"
  57. # 3. 纯数字固定位置 1/7 → /
  58. if text.isdigit() and len(text) in DIGIT_SLASH_INDEX:
  59. idx = DIGIT_SLASH_INDEX[len(text)]
  60. if text[idx] in "17":
  61. return text[:idx] + "/" + text[idx + 1 :]
  62. # 4. 纯数字或数字+/ 中间空格去除
  63. if RE_DIGIT_SPACE_SLASH.fullmatch(text) and " " in text:
  64. return text.replace(" ", "")
  65. # 5. 纯数字中间点号 → /
  66. if RE_DIGIT_DOT.fullmatch(text):
  67. return text.replace(".", "/")
  68. # 6. No./mo./Na./ma. 等 → No.数字
  69. m = RE_NO_MO.fullmatch(text)
  70. if m:
  71. return f"No.{m.group(1)}"
  72. return text
  73. def is_valid_card_no_format(text: str) -> bool:
  74. """是否命中合法 card_no 格式(指南老格式 + 四种补全形态)。"""
  75. if text is None:
  76. return False
  77. t = str(text).strip()
  78. if not t:
  79. return False
  80. if t in CARD_NO_BLACKLIST:
  81. return False
  82. if RE_VALID_SLASH.fullmatch(t):
  83. return True
  84. if RE_VALID_NO.fullmatch(t):
  85. return True
  86. if RE_VALID_LONG.fullmatch(t) and re.search(r"\d", t) and re.search(r"[A-Z]", t):
  87. return True
  88. if RE_VALID_SHORT_NUM.fullmatch(t):
  89. return True
  90. if RE_VALID_HASH.fullmatch(t):
  91. return True
  92. return False
  93. def finalize_card_no(rec_text: str, rec_score: float, score_threshold: float = 0.9) -> dict:
  94. """宽容 + 低分不合规置空。
  95. 返回:
  96. card_no, leniency_applied, empty_reason
  97. empty_reason:
  98. None — 有有效编号
  99. rec_empty — rec 原文为空
  100. blur_unreadable — rec_score≤阈值 且宽容后仍不合规(图模糊)
  101. 注意:无检测框的 no_detection 由上层在缺检测记录时写入,不在此函数判定。
  102. """
  103. rec_text = (rec_text or "").strip()
  104. if not rec_text:
  105. return {
  106. "card_no": "",
  107. "leniency_applied": False,
  108. "empty_reason": EMPTY_REC_EMPTY,
  109. }
  110. final = apply_leniency(rec_text)
  111. applied = final != rec_text
  112. score = float(rec_score or 0.0)
  113. # rec_score > 0.9:人眼已确认基本正确,保留(即使偶发形态偏怪)
  114. if score > score_threshold:
  115. return {
  116. "card_no": final,
  117. "leniency_applied": applied,
  118. "empty_reason": None,
  119. }
  120. # rec_score ≤ 0.9:必须命中合法格式,否则视为模糊不可读 → 置空
  121. if is_valid_card_no_format(final):
  122. return {
  123. "card_no": final,
  124. "leniency_applied": applied,
  125. "empty_reason": None,
  126. }
  127. return {
  128. "card_no": "",
  129. "leniency_applied": applied,
  130. "empty_reason": EMPTY_BLUR_UNREADABLE,
  131. }