# eBay 图片推理入库全流程文档 > 用 `ebay_record_2026h1` 的图片做模型推理,读出真实 `card_id` / 评级公司 / 评级分数,回填进 ClickHouse `card_transactions_unified_sync`。 > 本文档覆盖:取数 → 缓存 → 推理 → 语种判定 → OCR → 重检索 → 入库 → 人工核验导出,以及全程踩过的坑与解法。 --- ## 一、背景与目标 原来的 `import_card_transactions_unified.py` 走**纯文本**路线:从 `title` 用正则提 `card_no` → 查 PG `cards_master` 映射 `card_id`(命中率极低,大量 UNKNOWN),eBay 的 `grade_company` 直接写死 `RAW`、`grade_score` 写死 `无`,**完全没用图片**。 本流程改用**图片模型推理**: - **DINOv2** 算出真实 `card_id`(图像相似度 Top1) - **best.pt**(YOLO 评级卡检测)读出真实 `grade_company`(PSA/BGS/CGC/SGC) - **PaddleOCR** 读出评级标签上的 `grade_score` + 判定语种 ### 最终成果(2026-07-09 收工) | 指标 | 数值 | |---|---| | 推理总量 | **184881** 张(category_id=16 Pokemon 且 img_stat=40 且有图)| | ClickHouse 整表 | **158304 行**(本次新增 61947)| | 评级卡 | 38768 | | 有评级分数 | 32652 | | 人工核验案例(sim<0.60)| **26577** → 导出待人工核对 | --- ## 二、整体架构(两机协作 + 三库) ``` ┌─────────────────────────────────────────────────────────────┐ │ 本地 Windows (D:\顾工交接\wzj\ebay_sync\) │ │ ─ Anaconda Python,连三库 │ │ ─ 01 导出目标 / 04 入库 / 05 导出人工核验 / finalize_watch │ │ ─ paramiko SSH 编排服务器 │ └───────────────┬─────────────────────────────────────────────┘ │ SSH (martin@192.168.77.249) + scp ▼ ┌─────────────────────────────────────────────────────────────┐ │ 服务器 192.168.77.249 (~/wzj/wzj/ebay_sync/) │ │ ─ GPU: V100×2 (GPU1/GPU2) + GTX1060 (GPU0,不用) │ │ ─ conda env: pytorch (推理) / paddleocr (OCR+语种) │ │ ─ ebay_infer_stream / ebay_lang_judge / ebay_ocr_step / │ │ ebay_rematch / run_infer_dual.sh │ └───────────────┬─────────────────────────────────────────────┘ │ 内网拉图 ▼ 内网 MinIO 192.168.77.80:9000 (~0.1s/张) 三个数据库: MySQL 100.64.0.21:3306 crawler/readonly/Pass2024 ← 取原始记录 + 图片路径 PG 100.64.0.10:25432 hs_sync_data/readonlyuser/Pass2026 ← cards_master 中文名/图(人工核验用) ClickHouse 192.168.31.233:8123 card_transactions_ro/... ← 最终入库(可写) ``` **关键隔离点**:本地能连三库,服务器能拉图 + 跑 GPU,两者网络隔离,靠 SSH/scp 传 CSV。 --- ## 三、端到端数据流 ``` [本地 01] MySQL 拉 source_id+全字段 └─ ebay_targets.csv (184881行, 含 img_url=MinIO内网地址) │ scp 上传服务器 ▼ [服务器 infer] 双卡并行 (V100×2, max-items 分片重启防OOM) 下载图 → best.pt 评级壳 → YOLO 卡面分割 → 透视矫正 → DINOv2 特征 → 全库向量检索 Top1 └─ ebay_infer_result.csv (card_id/sim/grade_company/...) └─ ebay_feats.npz (DINOv2 特征缓存, 供 rematch 复用) └─ data/ebay_cardcrops/ (卡面图, 供 lang 判语种) └─ data/ebay_crops/ (评级标签条, 供 ocr 读分数) ▼ [服务器 lang] PaddleOCR 判语种 (kana/CJK/latin + hanzidentifier) └─ ebay_lang.csv (tx_id, language) ▼ [服务器 ocr] PaddleOCR 读评级标签分数 └─ ebay_ocr_result.csv (tx_id, grade_score) ▼ [服务器 rematch] 语种分层重检索 (复用缓存特征, 不重跑模型) 英文卡只在 tcg us 库找, 避免错配日/中文卡 └─ ebay_infer_result_lang.csv (覆盖 card_id/sim/top5, 增列 language) │ scp 取回本地 ▼ [本地 04] 构建 20 列 CH 行, 增量入库 sim≥0.50 → 入库 ; sim<0.50/失败 → "人工核验" 跳过 └─ card_transactions_unified_sync (+61947 行) ▼ [本地 05] 导出人工核验案例 (info-only) └─ D:\顾工交接\wzj\人工核验\ebay\ (manual_top5_detail.csv 等) ``` --- ## 四、各阶段详解 ### 阶段 0 | 01_export_targets.py(本地,连 MySQL) **作用**:从 MySQL `crawler.ebay_record_2026h1` 拉满足条件的记录,产出推理+入库用的 CSV。 - **过滤基线**:`category_id=16` (Pokemon) `AND img_stat=40` (图下载成功) `AND image_uri IS NOT NULL AND image_uri<>''` - **图片源**:内网 MinIO 网关 `http://192.168.77.80:9000/` + `image_uri`。**不用** `img` 列的公网 ebay 图(公网慢 ~10×、且部分失效)。 - **排序**:有 `--from/--to` 时按 `last_sold` 序(业务取法),否则按 `id ASC`(冷启动测试)。 - **输出** `ebay_targets.csv` 字段(9列): `tx_id, source_id, item_id, title, avg_sold_price, asp_type, total_sold, last_sold, img_url` - `tx_id = ebay_record_2026h1_`(全流程主键锚点) - `img_url = 网关 + image_uri`(内网图) ```bash python 01_export_targets.py # 默认拉全部满足条件 python 01_export_targets.py --from 2026-06-22 --to 2026-06-28 # 按日期段 ``` ### 阶段 1 | ebay_infer_stream.py(服务器,pytorch env,GPU)★ 核心 **作用**:逐行下载图 → 评级壳检测 → 卡面分割矫正 → DINOv2 特征 → 全库 Top1 检索,一次跑完 card_id + 评级。 **单条处理流程** (`process_one`): 1. **下载** img_url 到临时目录(8 线程并发,块 256) 2. **评级壳检测** `GradingDetector(best.pt)` 在原图上 → `(company, subtype, conf, box)`;检出则裁标签条存 `ebay_crops/` 3. **卡面分割** `CardDetector.detect_box_mask` → `(box, mask, orig)` 4. **透视矫正** `CardRectifier.rectify(mask, box, angle=0)` → 正立卡面;存 `ebay_cardcrops/` 5. **特征提取** `CardFeatureExtractor.extract` (DINOv2, 768d) → 存 `ebay_feats.npz`(**供 rematch 复用,不重跑模型**) 6. **向量检索** `VectorIndex.search(feat, top_k=5)` → Top1 card_id + sim + top5 7. **删临时图**(磁盘峰值仅一块 ~128MB) **模型一次性加载**:★卡牌区域检测 (`card_seg_pn_v2/weights/best.pt`,TRT 可选 `best.engine`;旧 `yolov11n_card_seg01.onnx` 仅回退) + best.pt + DINOv2 (`PokemonCN04`) + 图库(78272 卡 ×768d)。 **产出**: - `ebay_infer_result.csv` — tx_id/source_id/card_id/similarity/grade_company/grade_subtype/grade_conf/is_graded/crop_file/top5/status - `ebay_feats.npz` — tx_ids + feats(N,768) - `data/ebay_cardcrops/*.jpg`、`data/ebay_crops/*.jpg` **断点续跑**:启动时读 `ebay_infer_result.csv` 已 done 的 tx_id,自动跳过。 ### 阶段 1.5 | 双卡并行编排 run_infer_dual.sh ★ 防OOM核心 见 **第五章**。把剩余 tx_id 分成 shard0/shard1,两块 V100 并行,每跑 1500 条重启进程释放泄漏内存。 ### 阶段 2 | ebay_lang_judge.py(服务器,paddleocr env,双卡) **作用**:读卡面图 `ebay_cardcrops/`,用 PaddleOCR (PP-OCRv5) + `hanzidentifier` 判定语种(kana=日 / CJK=中 / latin=英)。 - 双卡分片:`SHARD=0/2` 和 `SHARD=1/2` 各处理一半 - 输出 `ebay_lang_0.csv` / `_1.csv` → 合并成 `ebay_lang.csv` (tx_id, language) ### 阶段 3 | ebay_ocr_step.py(服务器,paddleocr env,双卡) **作用**:读评级标签条 `ebay_crops/`,PaddleOCR 识别评级分数(如 `GEM MT 10`、`9.5`)。 - 双卡分片同上 - 输出 `ebay_ocr_result.csv` (tx_id, grade_score) ### 阶段 4 | ebay_rematch.py(服务器,pytorch env,单卡秒级) **作用**:**语种分层重检索**——复用阶段1缓存的 DINOv2 特征 + 阶段2判定的语种,在对应语种子库内重新检索,覆盖 card_id/similarity/top5。 **为什么需要**:阶段1全库检索时,英文卡可能错配到日/中文卡(DINOv2 特征相近)。按语种分层后,英文卡只在 tcg us 库找,准确率提升。 **性能优化**:预建"每种语种 → 子索引"(只建 4 次),替代每次检索重建 mask。检索变成纯 numpy matmul,41876 条秒级完成。 **输入**:`ebay_feats.npz` + `ebay_lang.csv` + `ebay_infer_result.csv` **输出**:`ebay_infer_result_lang.csv`(覆盖 card_id/sim/top5,增列 language) ### 阶段 5 | 04_load_clickhouse.py(本地,写 ClickHouse)★ 入库 **作用**:合并 `ebay_targets.csv`(全字段) + 推理结果 + OCR,构建 20 列 CH 行入库。 **字段映射策略**: | CH 字段 | 来源 | |---|---| | tx_id/source_id/title/item_id/auction_type/total_sold/price_original/sold_date/image_uri | ebay_targets.csv | | **card_id** | status==ok 且 **sim≥0.50** → 图像 Top1;否则 `"人工核验"`(不入库,留给 05)| | **grade_company** | 检出评级壳且 ∈{PSA,BGS,CGC,SGC} → 该公司;否则 `"非评级卡"` | | **grade_score** | 评级卡 → OCR 分数;否则 `"无"` | | source_table/platform/currency | 固定 `ebay_record_2026h1`/`eBay`/`USD` | **入库模式**(关键): - **默认增量追加**:查 DB 已有同源 tx_id,只插新的,**保留已入库数据**(本次:DB已有96357 → 新增61947) - `--replace`:全量替换(先 `ALTER DELETE WHERE 1=1` 清表再灌) - `--dry-run`:只打印样例不动库 - `--target-valid N`:入库有效行封顶(0=全部高置信入库) ```bash python 04_load_clickhouse.py --dry-run # 先校验 python 04_load_clickhouse.py # 增量入库(默认) ``` > **注意**:sim<0.50 的"人工核验"行**不入库**(`if cid == MANUAL: continue`),留给 05 导出。所以入库数 < 推理数是正常的。 ### 阶段 6 | 05_export_manual.py(本地) **作用**:把 sim<0.50 / 匹配不稳的案例导出到本地,供人工核对。 - 默认 `--info-only`(只导信息,不下图) - 查 PG `cards_master` 补候选 card_id 的中文名/img_url - 输出 `D:\顾工交接\wzj\人工核验\ebay\`: - `manual_top5_detail.csv` — 每个案例的 Top5 候选明细 - `manual_index.csv` — 汇总索引 --- ## 五、双卡并行与防 OOM 设计(核心技术决策) ### 5.1 为什么要双卡 infer 是 **GPU 密集**型(下载只占 ~8% 时间),单卡时 GPU 空闲多。两块 V100 并行 → infer 时间约减半。提 workers(8→32) 只加速下载的 ~8%,收益小;双卡收益大。 ### 5.2 分片机制 `prepare_shards.py`:读 `ebay_infer_result.csv` 已 done 的 tx_id,从 `ebay_targets.csv` 减去,剩余按偶/奇索引分成 `shard0`/`shard1`(各 ~18060 条)。 ### 5.3 max-items 分片重启(防 OOM 灵魂) **问题**:ultralytics `model.predict()` 会缓存 Results 对象,每图 ~6MB 泄漏,两个 detector(yolo+grading) 双倍。跑到 5120 条时 RSS 飙到 30GB → OOM Killed。 **解法**:`--max-items 1500` 让进程处理 1500 条后**正常退出**(写 checkpoint),外层 `run_shard()` 循环重启新进程(自动跳过已 done)。 - **只有进程退出才能彻底释放 ultralytics 缓存**,这是唯一可靠办法 - 每批实际跑 1536 条(1500 按 chunk 256 取整 = 6块),重启加载图库 ~9s,开销可忽略 - RSS 从原来 5120 条 30GB → 现在 2.4GB 稳定 ### 5.4 run_shard() 失败检测(防死循环) ```bash run_shard() { while true; do DONE=$(行数) [ $DONE -ge $TOTAL ] && break python ebay_infer_stream.py --max-items 1500 ... NEWDONE=$(新行数) if [ $NEWDONE -le $DONE ]; then FAIL=$((FAIL+1)) [ $FAIL -ge 4 ] && break # 连续4次无进展放弃, 防死循环 else FAIL=0; fi done } ``` ### 5.5 checkpoint 内存优化 原实现 `np.stack([100k arrays])` 在首次 checkpoint 触发峰值 OOM。改为: - 结果 CSV:`prev_results + results` 全量重写 - 特征 npz:用 `np.concatenate([old_keep, new_feats])` 替代 list+stack,并 `del` 中间量 ### 5.6 合并 ebay_merge_results.py 双卡产出 `ebay_infer_result_0.csv`/`_1.csv` + `ebay_feats_0.npz`/`_1.npz`,合并到主文件(后者覆盖去重),特征用预分配 `np.empty((n,768))` 逐条填避免峰值,然后清理分片文件。 --- ## 六、踩过的坑与解决方案 ⚠️(重点) ### 坑1:5120 条必 OOM - **现象**:infer 每次卡在 5120 条,`Killed process ... anon-rss:30291292kB` - **根因**:ultralytics `predict()` 缓存 Results 对象,6MB/图 × 5120 = 30GB;yolo_detector + grading_detector 两个都 predict,双倍泄漏 - **解法**:`--max-items 1500` 分片重启(进程退出才彻底释放)+ `del downloaded` + 每512条 `gc.collect()` + checkpoint 改 concatenate - 详见第五章 5.3-5.5 ### 坑2:milvus 抢内存(且 pkill 无效) - **现象**:服务器可用内存被 milvus 吃到只剩很少,infer 一跑就 thrash - **误区**:`sudo pkill milvus` 杀掉后又被自动拉起,内存又涨回 7.5GB - **根因**:milvus 是 **Docker 容器**(milvus-standalone + milvus-minio + milvus-etcd),restart policy=always。pkill 进程后 docker daemon 立即重启它 - **解法**:`docker stop milvus-standalone milvus-minio milvus-etcd` + `docker update --restart=no`(永久停直到手动恢复)。推理完再 `finalize_watch` 的 [5/5] 恢复 - **注意**:249 的 milvus-minio 也用 port 9000,但和拉图的 192.168.77.80 是**不同 minio**,停 milvus 不影响拉图 ### 坑3:run_single_finish.sh 无 set -e → 假 PIPELINE_DONE - **现象**:infer 崩溃后脚本继续跑 lang/ocr/rematch,最后照样 touch PIPELINE_DONE,finalize 误以为完成去入库(结果不全) - **根因**:旧编排脚本没有失败检测 - **解法**:新 `run_infer_dual.sh` 的 `run_shard()` 加 4 次无进展放弃机制(5.4) ### 坑4:checkpoint npz 不落盘 / 峰值 OOM - **现象**:第一次触发 checkpoint 时又 OOM - **根因**:`list(10万 arrays) + np.stack` 在 stack 那一刻内存峰值爆炸 - **解法**:改 `np.concatenate` + `del` 中间量;合并阶段用预分配(5.5) ### 坑5:ulimit -v 14000000 致 DINOv2 加载失败 - **现象**:为限内存加 `ulimit -v 14000000`,结果 DINOv2 模型 mmap 报 `Cannot allocate memory (12)` - **根因**:虚拟地址空间限制卡住了 mmap - **解法**:删除 ulimit,靠 max-items 重启控内存(治本) ### 坑6:paramiko 后台启动进程时 o.read() 挂起 - **现象**:用 `setsid nohup ... &` 后台启动 run_infer_dual.sh,paramiko `o.read()` 一直挂住不返回 - **根因**:读 backgrounded 进程的 stdout,channel 不会 EOF - **解法**:用 `transport.open_session()` + `chan.exec_command(...)` + `chan.close()`,**不读 stdout** ### 坑7:dl_fail 间歇升高(~9-15%) - **现象**:infer 时 `dl_fail` 从 270 升到 601(~15%),后来稳定 ~9% - **根因**:内网 MinIO 间歇性失败(网络抖动/限速) - **处理**:**非致命**——失败行 status=`download_fail`,04 入库时归为"人工核验"跳过,不阻断整体 ### 坑8:MySQL 大 IN-list 超时 - **现象**:用 `WHERE id IN (...)` 一次查几万 id 容易超时 - **解法**:分块查(每块 200),或直接 `WHERE 过滤条件 ORDER BY` 全量拉(01 采用后者) ### 坑9:查进度.py 读错日志 → 误判卡死 - **现象**:旧版查进度读单卡时代的 `infer_single.log`,双卡改用 `infer0/1.log` 后显示全是"7分前"和空,误以为卡死;读旧残留 `ebay_ocr_result.csv`(2141行) 误导 - **解法**:重写成**链式结构**——一条 SSH 命令 + `<<>>` 分段一次拿回全部(进程/进度/RSS/GPU/内存/milvus),单次往返;阶段判断**优先用进程名**(has_lang 等),log-age 兜底 ### 坑10:05 导出 PermissionError - **现象**:`05_export_manual.py` 写 `manual_top5_detail.csv` 报 `Permission denied` - **根因**:该 CSV 被Excel/WPS 打开占用(之前导出的旧文件) - **解法**:关闭占用程序后重跑 `python 05_export_manual.py`(非致命,入库已完成) --- ## 七、如何运行 ### 一键全自动(推荐) ```bash # 本地后台启动监控(会自动: 轮询PIPELINE_DONE → 下载结果 → 04入库 → 05导出 → 恢复milvus) python finalize_watch.py ``` ### 手动分步 ```bash # 1) 本地导出目标 python 01_export_targets.py # scp 上传 ebay_targets.csv 到服务器 ~/wzj/wzj/ebay_sync/ # 2) 服务器双卡推理(SSH 上去) cd ~/wzj/wzj setsid nohup bash ebay_sync/run_infer_dual.sh /dev/null 2>&1 & # 产物: ebay_infer_result.csv + ebay_feats.npz + ebay_lang.csv + ebay_ocr_result.csv + ebay_infer_result_lang.csv # 生成 PIPELINE_DONE 标记 # 3) 本地入库(先 dry-run) python 04_load_clickhouse.py --dry-run python 04_load_clickhouse.py # 增量入库 # 4) 本地导出人工核验 python 05_export_manual.py ``` ### 查进度 ```bash python 查进度.py # 链式版, 单次SSH往返, 显示阶段/进度/RSS/GPU/内存/milvus/进程 ``` ### 恢复 milvus(推理完成后) ```bash # 服务器 sudo docker update --restart=always milvus-etcd milvus-minio milvus-standalone sudo docker start milvus-etcd milvus-minio # 先依赖 sleep 6 sudo docker start milvus-standalone ``` (finalize_watch 的 [5/5] 会自动做这步) --- ## 八、关键配置与依赖 ### GPU 映射(服务器) | 物理位 | UUID 前缀 | 型号 | 用途 | |---|---|---|---| | GPU0 | GPU-bb6d8194 | GTX1060 3GB | 不用 | | GPU1 | GPU-9d6999fb | V100 16GB | shard0 (V1) | | GPU2 | GPU-db470791 | V100 16GB | shard1 (V2) | ### 环境变量(GPU 隔离) ```bash CUDA_VISIBLE_DEVICES=GPU-9d6999fb-... # 指定单卡 export PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:64,expandable_segments:True # 减少显存碎片 ``` ### 关键阈值 - `SIM_THRESHOLD = 0.50`(card_id 入库门槛,<0.50 归人工核验)—— `config.py` + `04` - `--max-items 1500`(防 OOM 分片大小) - `--workers 8 --chunk 256`(并发下载/分块) ### ClickHouse 表 - `card_transactions.card_transactions_unified_sync` - 引擎 ReplacingMergeTree,`ORDER BY (card_id, sold_date, platform, tx_id)` - 20 列(见 04 的 CH_COLUMNS) - 默认 APPEND,按 tx_id 去重 ### 文件镜像 本地 `D:\顾工交接\wzj\ebay_sync\` ↔ 服务器 `~/wzj/wzj/ebay_sync/`,同名同结构。 --- ## 九、文件清单 | 文件 | 位置 | 作用 | |---|---|---| | `01_export_targets.py` | 本地 | MySQL 拉目标 → ebay_targets.csv | | `ebay_infer_stream.py` | 服务器 | ★ 核心:下载+评级+分割+特征+检索 | | `prepare_shards.py` | 服务器 | 剩余 tx_id 分 shard0/shard1 | | `run_infer_dual.sh` | 服务器 | ★ 双卡编排 + max-items 重启 + 失败检测 | | `ebay_merge_results.py` | 服务器 | 合并双卡 result + feats,清理分片 | | `ebay_lang_judge.py` | 服务器 | PaddleOCR 判语种(双卡)| | `ebay_ocr_step.py` | 服务器 | PaddleOCR 读评级分数(双卡)| | `ebay_rematch.py` | 服务器 | 语种分层重检索(复用特征)| | `04_load_clickhouse.py` | 本地 | ★ 构建20列行,增量入库 | | `05_export_manual.py` | 本地 | 导出人工核验案例 | | `finalize_watch.py` | 本地 | 轮询 DONE 自动收尾(+恢复milvus)| | `查进度.py` | 本地 | 链式查进度(单次SSH往返)| --- ## 十、复盘要点(给下一次) 1. **内存泄漏类问题**:ultralytics/某些库的缓存只能靠进程退出释放 → `--max-items` 分片重启是最稳的通用解法 2. **milvus/常驻服务抢资源**:先查清楚是 systemd 还是 docker,docker 要 `update --restart=no` 才能真停 3. **编排脚本必须有失败检测**:无进展计数器防死循环,`set -e` 或显式 rc 检查防假完成 4. **大数组合并避免 list+stack**:用 concatenate 或预分配 5. **进度查询要读对文件**:架构变了日志名就变,链式结构 + 进程名判断最稳 6. **入库与导出分离**:sim<0.50 不入库而是导出人工核验,保证入库数据全是高置信的