# 卡牌区域检测 + 透视矫正 + 旋转正立 流程详解 > ⚠️ **范围声明**:本文档只讲**卡牌本体区域**(整张宝可梦/球星卡卡面)的检测裁切与正立矫正, > 对应 `modules/yolo_detector.py`(`CardDetector`)。 > **不是**评级封装壳上的评级公司标签条检测(那部分见 `modules/grading_detector.py` + `best.pt`(YOLO26), > 是在原图上单独跑的另一个模型,用于识别 PSA/BGS/CGC/SGC,二者互不影响,详见文末「区别」一节)。 > > **2026-08 更新**:查询侧(待匹配拍摄图)的检测/裁切模型由 `yolov11n_card_seg01.onnx`(YOLOv11 分割,ONNX/CPU) > 升级为 **`card_seg_pn_v2`(yolo26s-seg,2073 张宝可梦+球星卡混合数据全新训练,73 服务器已导出 TensorRT FP16 engine)**, > 裁切策略由"护栏式 warp(`has_perspective` 阈值判断是否值得矫正)"改为**始终 warp(`valid_quad` 通过即摆正)**。 > **图库/向量库特征不受影响**——图库本来就是正立清晰图,本次只优化"待匹配拍摄图"的裁切几何,不改建库预处理、不重建 78272 张图库。 > 旧模型/旧护栏逻辑仍保留作 `CARD_CROP_MODE=bbox_legacy` 回退路径,见第十节。 --- ## 一、总体链条 卡牌区域的"裁切→正立"不是一个模型/一步完成的,而是**两个独立环境接力**: ``` 原图 │ │ ①【pytorch 环境】yolo26s-seg 分割检测卡牌区域(TRT可选) │ modules/yolo_detector.py (CardDetector) │ 模型: card_seg_v2/best.pt(或同目录 best.engine) task=segment ▼ box(x1,y1,x2,y2) + mask(二值图,与原图同尺寸) │ │ ②【pytorch 环境】mask → 4角点 → 透视矫正(warpPerspective),valid_quad通过即摆正 │ modules/rectifier.py: mask_to_corners / order_corners / warp_perspective ▼ 拉正后的卡牌矩形图(几何已摆正;细小/大角度朝向仍可能横躺/倒置) │ │ ③【paddleocr 环境,跨进程/跨环境,独立于①②】PaddleOCR 识别文字方向(可选,未接入查询主链路) │ ocr_judge.py / ebay_sync/ebay_lang_judge.py │ doc_preprocessor_res.angle ∈ {0, 90, 180, 270} ▼ angle │ │ ④【pytorch 环境】用 angle 把卡牌转到竖向正立(可选);查询侧默认只用几何 ensure_portrait │ modules/rectifier.py: rotate_to_portrait(img, angle) / ensure_portrait(img) ▼ 正立竖向卡牌图 → 供 DINOv2 提特征 / 向量检索 ``` **为什么裁切和判方向要拆两个环境**:①②在 `pytorch` 环境(依赖 `ultralytics`),③在 `paddleocr` 环境 (依赖 `paddleocr==3.2.0` + `paddlepaddle==3.2.0`),两套包不能装在同一 conda 环境,只能拆成独立步骤、 分别产出中间文件(裁切图 / JSON),再在④里合流。 > **查询侧(`CardDetector.detect_and_crop`,`CARD_CROP_MODE=seg_warp` 默认路径)目前不接入③④的 OCR angle**—— > 大角度朝向错误(90°/180°/270°级别)本轮明确判断为"可人工修正"、性价比不高,不做自动纠正; > 只做①②的几何检测+透视摆正+`ensure_portrait`(纯几何 w>h 判断转90°),细节见第五节 5.4。 > ③④这条 OCR 链路仍保留给 cat16 三属性识别等**其它子系统**使用(见第九节表格)。 --- ## 二、①卡牌区域检测裁切(`modules/yolo_detector.py` · `CardDetector`) - **模型**:`card_seg_pn_v2`(`ultralytics/runs/segment/card_seg_pn_v2/weights/best.pt`),`task="segment"`(yolo26s **分割**模型,不是纯 detect) - TensorRT:`CARD_SEG_USE_TRT=1` 时优先加载同目录同名 `best.engine`(73 服务器已导出 FP16,检测延迟 728ms→49ms,约 15×),加载失败自动回退 `.pt` - 训练数据:176 张旧标注 + 1897 张新标注(宝可梦为主,标签点数不强制 4 点,按原始描边多边形训练) - **关键参数**(`config.py`):`conf=0.25`,`imgsz=640` - 输入:整张原图(交易图/eBay图/测试图),可能带背景、桌面、手、评级壳边框等干扰 - 取框逻辑:一张图可能检出多个候选框,**只取面积最大的一个**(`np.argmax(areas)`),不严格按类别过滤 两种取值方式,服务于不同下游: | 方法 | 返回 | 用途 | |------|------|------| | `detect_and_crop(img)` | 按 `crop_mode` 处理后的 RGB 图(详见第五节) | 查询侧主入口——`crop_mode=seg_warp`(默认)走 mask→4点→warp;`bbox_legacy` 只矩形裁切 | | `detect_box_mask(img)` | `(box, mask, orig_rgb)`,**不裁切** | 底层方法,`detect_and_crop(seg_warp)` 内部调用;也可供其它脚本自行组合 rectifier | `detect_box_mask` 里 mask 的取法:`res.masks.data`(分割置信度>0.5 二值化),并 resize 回原图尺寸,与 box 对应同一个最大面积实例。 > **图库/建库侧不受影响**:本次升级只针对"待匹配拍摄图"(查询侧)的裁切;78272 张图库的历史特征沿用已有向量,不需要、也没有重新用新模型跑一遍建库。 --- ## 三、②透视矫正(`modules/rectifier.py`)——只用 mask,与 OCR 无关 卡牌拍摄时常有透视形变(斜着拍、镜头畸变),直接按 bbox 矩形裁会带一圈背景或裁歪。矫正流程: 1. **`mask_to_corners(mask)`**:二值 mask → `cv2.findContours` 取最大轮廓 → `convexHull` 凸包 → 多次放宽 `epsilon` 跑 `approxPolyDP` 直到拟合出 4 个角点;拟合不出兜底用 `minAreaRect` 的 4 角点。 2. **`order_corners(pts)`**:把 4 点按图像坐标排序为 `TL, TR, BR, BL`(此时只是几何排序,不代表卡牌"上下")。 3. **护栏(查询侧 vs 旧版差异)**: - `valid_quad(ordered)`:4 个内角是否接近 90°(容差 ±40°)、边长比是否不过分离奇(<3倍)——挡住 mask 抖动/角点崩坏的情况,**这道护栏仍保留**; - ~~`has_perspective(ordered)`~~:旧版第二道护栏("梯形度/最大偏角超阈值才算值得矫正,否则跳过 warp") **查询侧默认路径(`seg_warp`)已不再使用**——新数据集+新模型实测"始终 warp"裁切效果更好(三方对比人工评估结论, seg mask→4点→warp 优于 OBB 与 legacy bbox),`has_perspective` 函数仍在 `rectifier.py` 保留,仅 `CardRectifier.rectify()` (给 OCR-angle 链路/cat16 等其它子系统用)继续沿用两道护栏,查询侧 `CardDetector._detect_and_crop_seg_warp` 只判 `valid_quad`。 4. `valid_quad` 通过就 **`warp_perspective(img, corners)`**:`cv2.getPerspectiveTransform` + `warpPerspective` 把梯形拉正为矩形;输出尺寸 `_dst_size()` **忠实保留角点实测的宽高**(不强制卡牌标准比例 0.714)。 5. 任一步失败(mask 为空 / 角点拟合失败 / `valid_quad` 不通过)→ 回退到 **bbox 矩形裁切**(`crop_bbox`); bbox 也没有 → 直接用原图。 > 这一步产出"梯形拉正后的矩形图",方向可能仍是横躺的(是否倒置 180° 查询侧不处理,见第五节)。 --- ## 四、③方向角来源(PaddleOCR,另一环境,独立脚本)——查询侧默认不接入 透视矫正不解决"卡牌到底哪边朝上",这个信息来自 OCR 的文档方向分类,与卡牌几何完全独立: - 脚本:`ocr_judge.py`(本地/单机版)、`ebay_sync/ebay_lang_judge.py`(ebay 批量版),均跑在 `paddleocr` 环境 - 关键开关:`PaddleOCR(use_doc_orientation_classify=True, ...)` —— **必须开**,实测关闭时旋转卡的 OCR 会直接乱码;开启后不仅方向判断准,识别文本也顺带修正了。 - 取值:`result.doc_preprocessor_res.angle`,取值 `0/90/180/270`,**语义是"输入图相对正立顺时针转了多少度"** - 这一步的主目的其实是判语种(假名/汉字/拉丁字母计数,见 `语种判断逻辑说明.md`),`angle` 是**顺带产出**, 单独存进 `language_labels_320.json` / `ebay_lang.csv` 里的 `angle` 字段,供下游正立步骤读取。 - 输入图:**必须是第①②步裁出来的卡面图**,不能用原图直接 OCR——原图上的 eBay 页面边框、评级壳文字、 卖家水印都会干扰文字方向判断和语种统计。 > **当前定位**:这条 OCR angle 链路服务于 cat16 三属性识别、语种判断等**独立子系统**(见第九节表格), > **查询侧主检索链路(`CardDetector.detect_and_crop`)不调用它**——大角度朝向错误接受人工修正, > 不为此额外付出跨环境 OCR 调用的延迟/复杂度。 --- ## 五、④正立处理——查询侧 vs 旧版 OCR-angle 版本 ### 5.1 `rotate_upright(img, angle)` —— 直接按 angle 转(简单版,不判几何,供 OCR-angle 链路用) ```python _ANGLE_TO_CV = { 0: None, 90: cv2.ROTATE_90_COUNTERCLOCKWISE, # 输入顺时针倒90° → 逆时针转回来 180: cv2.ROTATE_180, 270: cv2.ROTATE_90_CLOCKWISE, # 输入逆时针倒90° → 顺时针转回来 } ``` angle 是几度就按对应方向转,没有其它判断。 ### 5.2 `rotate_to_portrait(img, angle=0)` —— OCR-angle 链路用的保守版(几何优先,供 cat16/CardRectifier 用) ```python h, w = img.shape[:2] if w <= h: # 已经是竖向 → 不转 return img a = int(angle or 0) % 360 if a == 90: return cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE) if a == 270: return cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE) return img # angle=0/180 时,横向图也不转——方向不明时不赌 ``` 设计原因(代码注释里有实测数据支撑): - **已经竖向的图完全不碰**:覆盖了"angle 判成 180 但其实卡是端正的""angle 判成 90 但实际已经竖向"这类 OCR 误判场景(实测 case:tx38/tx65)。 - **横向图只信 angle∈{90,270}**:因为只有 90/270 明确指出了"往哪边转";angle=0 或 180 时横向图**保持原样 不转**——实测 case tx86(angle=0 但图是横向)如果盲目转会转错(相似度从 0.86 掉到 0.45), 不转反而是对的。也就是"方向不确定时不赌,把决定权交给下游 DINOv2"。 ### 5.3 `angle_geometry_disagree(mask, angle)` —— 一致性校验(用于人工复核分支,OCR-angle 链路专用) 比较"mask 几何朝向"(W>H 视为横放)与"OCR angle 朝向"(angle∈{90,270} 视为横放)是否矛盾: - 一致 → 可以放心自动转正(如 tx2); - 不一致 → 说明模型对"卡牌到底正不正"没把握,应该走人工判断分支,而不是硬转(如 tx86、tx38/65)。 ### 5.4 `ensure_portrait(img)` —— ★查询侧默认路径实际使用的版本 ```python def ensure_portrait(img): """保证竖向:宽>高则逆时针转90°(仅当无 angle 信息或兜底时生效)。""" h, w = img.shape[:2] if w > h: return cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE) return img ``` **纯几何判断,不依赖 OCR,不跨环境**:宽>高就转 90° 到竖向,不判断是否倒置 180°。 `CardDetector._detect_and_crop_seg_warp()`(查询侧主路径)在 warp/bbox 裁切后统一调这个函数, 链路里**没有** ③OCR-angle 步骤,延迟只在 `pytorch` 单环境内,不跨进程调用 PaddleOCR。 --- ## 六、统一入口对照 ### 6.1 查询侧主路径 `CardDetector.detect_and_crop()`(`crop_mode=seg_warp`,默认) ```python box, mask, orig_rgb = self.detect_box_mask(img) # ① yolo26s-seg out = None if mask is not None: corners = mask_to_corners(mask) if corners is not None: ordered = order_corners(corners) if valid_quad(ordered): # ② 只判 valid_quad,始终 warp out = warp_perspective(orig_rgb, corners) if out is None and box is not None: out = crop_bbox(orig_rgb, box) # 退回 bbox if out is None: out = orig_rgb out = ensure_portrait(out) # ④' 纯几何竖放,无 OCR angle ``` ### 6.2 `CardRectifier.rectify()`(OCR-angle 链路,供 cat16 等其它子系统用,未改动) ```python class CardRectifier: def rectify(self, img, mask=None, box=None, angle=0): out = None if self.use_perspective: corners = mask_to_corners(mask) if mask is not None else None if corners is not None: ordered = order_corners(corners) if valid_quad(ordered) and has_perspective(ordered): # 两道护栏都要过 out = warp_perspective(img, corners) if out is None and box is not None: out = crop_bbox(img, box) if out is None: out = img out = rotate_to_portrait(out, angle) # 用 OCR angle 正立 return out ``` 调用方准备好 `img`(原图) + `mask`/`box`(来自 `CardDetector.detect_box_mask`) + `angle`(来自 PaddleOCR), 一次调用拿到"正立竖向的卡牌图"。**这条路径的模型底座已同步升级为 `card_seg_pn_v2`**(因为 `detect_box_mask` 和 `CardDetector` 共用一套 `model_path`/TRT 开关),只是 rectify 的护栏逻辑保持不变,未采用"始终 warp"。 --- ## 七、生产链路里的落地方式 ### 7.1 语种判断(`yolo_crop.py` + `ocr_judge.py`) 只做**矩形裁切**(`boxes.xyxy` 直接切图),**不做透视矫正**,因为这一路的目的只是让 OCR 输入更干净 (去掉背景/评级壳文字),不追求几何精度;`angle` 在这一路只是随手记录,不会拿回来对裁切图做旋转。 底座模型同样已随 `config.YOLO_MODEL_PATH` 切到 `card_seg_pn_v2`。 ### 7.2 卡牌检索匹配查询侧(`scripts/cascade_match.py` / `serve_card_match.py` / `serve_recognition_api.py`) ```python detector = CardDetector(config.YOLO_MODEL_PATH) # 默认 seg_warp + card_seg_pn_v2(TRT 可选) crop = detector.detect_and_crop(img) # 见 6.1,内部已含 warp + ensure_portrait ``` 无需手动传 `angle`;查询侧不做 OCR 转正,大角度朝向错误接受人工/后续复核修正。 ### 7.3 cat16 三属性识别 / 评级卡等仍需 OCR-angle 的子系统 沿用 `CardRectifier.rectify(img, mask, box, angle)`(6.2),逻辑未变,只是底层 `CardDetector` 的检测模型 已统一升级(因为共用 `config.CARD_SEG_MODEL_PATH`),检测/mask 质量随之提升,但护栏与转正策略不变。 --- ## 八、关键参数速查 | 参数 | 值 | 位置 | |------|-----|------| | 卡牌区域检测模型(现役) | `card_seg_pn_v2`(yolo26s-seg,`ultralytics/runs/segment/card_seg_pn_v2/weights/best.pt`) | `config.CARD_SEG_MODEL_PATH` | | TensorRT engine | 同目录 `best.engine`(73 已导出 FP16) | 环境变量 `CARD_SEG_USE_TRT=1` | | 卡牌区域检测模型(旧,仅回退) | `yolov11n_card_seg01.onnx`(YOLOv11 分割) | `config.YOLO_MODEL_PATH_LEGACY`,`CARD_CROP_MODE=bbox_legacy` 时生效 | | 裁切策略开关 | `seg_warp`(默认,mask→4点→始终warp→ensure_portrait) / `bbox_legacy`(仅矩形裁切) | `config.CARD_CROP_MODE` / 环境变量 `CARD_CROP_MODE` | | 置信度阈值 | `0.25` | `config.YOLO_CONF_THRESHOLD` | | 输入尺寸 | `640` | `config.YOLO_IMG_SIZE` | | 取框规则 | 面积最大的一个检出实例 | `yolo_detector.py: np.argmax(areas)` | | 角点合理性容差(查询侧唯一护栏) | 内角偏差 `<40°`、边长比 `<3` | `rectifier.py: valid_quad` | | ~~透视矫正触发阈值~~(仅 `CardRectifier.rectify` OCR-angle 链路仍用,查询侧已不用) | 梯形度 `trap>0.10` 或 最大偏角 `maxdev>3.5°` | `rectifier.py: has_perspective` | | 方向角来源(OCR-angle 链路专用,查询侧不接入) | PaddleOCR `doc_preprocessor_res.angle`,`0/90/180/270` | `ocr_judge.py` / `ebay_lang_judge.py` | | 查询侧正立策略 | 纯几何 `ensure_portrait`:w>h 则转90°,不判180°倒置 | `rectifier.py: ensure_portrait` | | OCR-angle 链路正立策略(`CardRectifier` 用) | 仅横向图 + angle∈{90,270} 才转;angle=0/180 或已竖向不转 | `rectifier.py: rotate_to_portrait` | --- ## 九、与「评级卡区域」检测的区别(勿混淆) | 维度 | 卡牌区域(本文档) | 评级卡区域(评级公司标签条) | |------|------|------| | 模块 | `modules/yolo_detector.py` (`CardDetector`) | `modules/grading_detector.py` (`GradingDetector`) | | 模型 | `card_seg_v2/best.pt`(TRT engine 可选),**yolo26s 分割** | `card_v1/best.pt`,**YOLO26 检测**,11 类 | | 检测对象 | 整张卡面(宝可梦卡/球星卡本体) | 评级封装壳上印的公司标签条(PSA/BGS/CGC/SGC 及子类) | | 必须在什么图上检测 | 原图(检出后即裁掉背景) | **必须在原图上检测**——卡牌检测裁完会把评级壳裁掉,标签条随之丢失,裁切图上几乎检不到 | | 后续处理 | mask→透视矫正(始终warp)→几何正立(ensure_portrait) → 送 DINOv2 检索;或 mask→护栏式warp→(OCR定角度)→旋转正立 → 送 cat16 等 | 裁出标签条→送 PaddleOCR 识别评级公司/分数/serial_no,**不参与 DINOv2 特征提取** | | 对应子系统 | 子系统 A(卡牌检索匹配)+ C(语种/方向前置,仅部分子系统使用) | 子系统 B(评级卡识别) | 两者是**并行、互不依赖**的两次独立 YOLO 推理,分别服务两条产出线(card_id 匹配 vs 评级信息识别), 本文档只覆盖前者。 --- ## 十、回退旧方案 若需回退到升级前的行为(旧 `yolov11n_card_seg01.onnx` + 仅矩形 bbox 裁切): ```bash export CARD_CROP_MODE=bbox_legacy ``` 或在调用处显式传参: ```python CardDetector(config.YOLO_MODEL_PATH_LEGACY, crop_mode="bbox_legacy") ``` 此时行为等价于旧版第二节 `detect_and_crop` 的矩形裁切逻辑,不使用 mask/warp/ensure_portrait。 --- ## 十一、效果对比(A/B 实测,2026-08) 在 618 张真实生产查询图上,对比升级前后: | 指标 | 旧(yolov11n bbox,CPU ONNX) | 新(card_seg_v2 TRT + seg_warp) | |------|------|------| | 检测耗时 | 728.8 ms/张 | **49.3 ms/张**(约 15×) | | fusion 均值 | 0.4316 | **0.4524**(+4.8%) | | fusion 中位 | 0.4374 | **0.4461**(+2.0%) | | 高置信 ≥0.6 张数 | 0/618 | **25/618** | | Top-1 card_id 更高 fusion | 253 张 | **364 张** | 结论:新方案在**速度**和**检索质量**上均全面优于旧方案,已作为查询侧默认路径上线(8000/8010 服务)。