# -*- coding: utf-8 -*- """card_no rec 后处理:单次宽容政策 + 合法格式判定。 规则见 data_all/卡牌区域/卡牌区域OCR_rec宽容政策.md 合法格式见 data_all/卡牌区域/卡牌区域OCR内容指南.md """ from __future__ import annotations import re # 稀有度后缀,按长度从长到短匹配 RARITY_SUFFIXES = ["RRR", "SSR", "SAR", "CHR", "CSR", "UR", "HR", "SR", "RR", "AR", "MR", "MA", "PR", "TR"] # 末尾单符号(可带前导空格) TAIL_CHARS = ["*", "★", "$", "C"] # 纯数字固定位置:长度 → 0-based 索引 DIGIT_SLASH_INDEX = {7: 3, 6: 2, 5: 2, 4: 1} RE_DIGIT_SPACE_SLASH = re.compile(r"^[\d /]+$") RE_DIGIT_DOT = re.compile(r"^\d+\.\d+$") RE_NO_MO = re.compile(r"^[NnmMm][AaOo]\.?\s*(\d{1,4})$") # 合法格式(老五种 + 补全:斜杠1~5、促销更长、中点·、长码≥3) RE_VALID_SLASH = re.compile( r"^[A-Za-z0-9]{1,5}/" r"(?:" r"[A-Za-z0-9]{1,5}" # 普通两侧 1~5 r"|[A-Za-z0-9]{1,6}-[A-Za-z0-9]{1,3}" # 连字符促销 如 SM-P / 30th-P / S-P r"|[A-Za-z0-9]{1,6}·[A-Za-z0-9]{1,3}" # 中点促销 如 SM·P r")$" ) RE_VALID_NO = re.compile(r"^No\.\d{1,4}$") RE_VALID_LONG = re.compile(r"^[A-Z][A-Z0-9]{2,}$") # 总长≥3,字母开头,含数字在 is_valid 再查 RE_VALID_SHORT_NUM = re.compile(r"^\d{1,3}$") RE_VALID_HASH = re.compile(r"^[A-Za-z]{3,4}#\d+$") CARD_NO_BLACKLIST = {"999", "000"} # 空结果原因(与“无检测框”区分) EMPTY_NO_DETECTION = "no_detection" # yolo26s conf<0.3,卡面本身无编号 EMPTY_BLUR_UNREADABLE = "blur_unreadable" # 有检测框,但 rec 低分且不合规(图模糊) EMPTY_REC_EMPTY = "rec_empty" # 有检测框,rec 直接空文本 def apply_leniency(text: str) -> str: """对 rec 识别文本应用宽容政策,返回最终 card_no。仅执行一次,不递归。""" if text is None: return "" text = str(text).strip() if not text: return "" # 1. 末尾稀有度后缀 / 单符号剥离(允许前导空格) for suffix in RARITY_SUFFIXES: if text.endswith(" " + suffix): return text[: -(len(suffix) + 1)].rstrip() if text.endswith(suffix): return text[: -len(suffix)].rstrip() for ch in TAIL_CHARS: if text.endswith(" " + ch): return text[:-2].rstrip() if text.endswith(ch): return text[:-1].rstrip() # 2. 末尾 '-' 补 'P'(如 001/SM- → 001/SM-P) if text.endswith("-"): return text + "P" # 3. 纯数字固定位置 1/7 → / if text.isdigit() and len(text) in DIGIT_SLASH_INDEX: idx = DIGIT_SLASH_INDEX[len(text)] if text[idx] in "17": return text[:idx] + "/" + text[idx + 1 :] # 4. 纯数字或数字+/ 中间空格去除 if RE_DIGIT_SPACE_SLASH.fullmatch(text) and " " in text: return text.replace(" ", "") # 5. 纯数字中间点号 → / if RE_DIGIT_DOT.fullmatch(text): return text.replace(".", "/") # 6. No./mo./Na./ma. 等 → No.数字 m = RE_NO_MO.fullmatch(text) if m: return f"No.{m.group(1)}" return text def is_valid_card_no_format(text: str) -> bool: """是否命中合法 card_no 格式(指南老格式 + 四种补全形态)。""" if text is None: return False t = str(text).strip() if not t: return False if t in CARD_NO_BLACKLIST: return False if RE_VALID_SLASH.fullmatch(t): return True if RE_VALID_NO.fullmatch(t): return True if RE_VALID_LONG.fullmatch(t) and re.search(r"\d", t) and re.search(r"[A-Z]", t): return True if RE_VALID_SHORT_NUM.fullmatch(t): return True if RE_VALID_HASH.fullmatch(t): return True return False def finalize_card_no(rec_text: str, rec_score: float, score_threshold: float = 0.9) -> dict: """宽容 + 低分不合规置空。 返回: card_no, leniency_applied, empty_reason empty_reason: None — 有有效编号 rec_empty — rec 原文为空 blur_unreadable — rec_score≤阈值 且宽容后仍不合规(图模糊) 注意:无检测框的 no_detection 由上层在缺检测记录时写入,不在此函数判定。 """ rec_text = (rec_text or "").strip() if not rec_text: return { "card_no": "", "leniency_applied": False, "empty_reason": EMPTY_REC_EMPTY, } final = apply_leniency(rec_text) applied = final != rec_text score = float(rec_score or 0.0) # rec_score > 0.9:人眼已确认基本正确,保留(即使偶发形态偏怪) if score > score_threshold: return { "card_no": final, "leniency_applied": applied, "empty_reason": None, } # rec_score ≤ 0.9:必须命中合法格式,否则视为模糊不可读 → 置空 if is_valid_card_no_format(final): return { "card_no": final, "leniency_applied": applied, "empty_reason": None, } return { "card_no": "", "leniency_applied": applied, "empty_reason": EMPTY_BLUR_UNREADABLE, }