From a64690477a181b9ccd4089dfd88d4ee401786f63 Mon Sep 17 00:00:00 2001 From: kirl <18827922006@163.com> Date: Tue, 23 Jun 2026 14:41:39 +0800 Subject: [PATCH] Dataset: add READMEs, organize scripts, update .gitignore - Add README.md to all 16 dataset directories with script references - Centralize generation scripts under scripts/ with full README - Update .gitignore to cover all data directories, keep only metadata - Remove redundant CCPD/.gitignore (parent covers same rules) - Remove stale scripts/test/ (254MB pipeline residue) - Fix label parser reference: scripts are authoritative source of truth --- .gitignore | 90 +++- CBLPRD_TRANS/README.md | 11 + CCPD/.gitignore | 7 - CCPD/README.md | 28 +- CCPD_19K/.gitignore | 2 + CCPD_19K/README.md | 75 ++++ CCPD_19K/build_ccpd_19k.py | 195 ++++++++ CCPD_19K_EXP_CUT/.gitignore | 2 + CCPD_19K_EXP_CUT/README.md | 69 +++ CCPD_19K_EXP_CUT/build_ccpd_19k_exp_cut.py | 422 ++++++++++++++++++ CCPD_19K_EXP_CUT_TRANS/.gitignore | 2 + CCPD_19K_EXP_CUT_TRANS/README.md | 61 +++ .../build_ccpd_19k_exp_cut_trans.py | 344 ++++++++++++++ CCPD_322K/README.md | 10 + CCPD_322K_EXP_CUT/README.md | 10 + CCPD_322K_EXP_CUT_TRANS/README.md | 11 + CCPD_REAL_TRANS/README.md | 11 + CCPD_Random_Blue150K_Green100K/README.md | 9 + CCPD_Random_Blue5K_Green5K/README.md | 10 + CCPD_Random_Blue5K_Green5K/build_exp_cut.py | 188 ++++++++ .../build_exp_cut_trans.py | 135 ++++++ CCPD_SPE/README.md | 97 +--- CCPD_SPE_GEN/cpp_pipeline/build.ps1 | 2 +- CCPD_SPE_GEN/cpp_pipeline/gen_plate_pool.py | 2 + CCPD_SPE_GEN/cpp_pipeline/main.cpp | 5 - CCPD_SPE_GEN/cpp_pipeline/plate_swap.cpp | 37 +- CCPD_SPE_GEN/cpp_pipeline/plate_swap.hpp | 1 - CCPD_SPE_GEN/cv_pipeline/color_tuner.py | 41 +- .../cv_pipeline/plate_color_profile.py | 6 +- CCPD_SPE_GEN/cv_pipeline/plate_swap.py | 61 ++- ccpd_combined_5k5k_spe/README.md | 10 + ccpd_combined_5k5k_spe/dataset.yaml | 10 + scripts/README.md | 26 ++ scripts/build_cblprd_trans.py | 138 ++++++ scripts/gen_random_bg.py | 76 ++++ scripts/orchestrator_322k.ps1 | 38 ++ scripts/pipeline_322k.ps1 | 85 ++++ scripts/pipeline_322k.py | 64 +++ scripts/rebuild_spe.ps1 | 54 +++ scripts/rebuild_spe.py | 70 +++ scripts/rebuild_spe_cpp.py | 99 ++++ scripts/sample_ccpd_full.py | 126 ++++++ scripts/sample_ccpd_real.py | 156 +++++++ 43 files changed, 2691 insertions(+), 205 deletions(-) create mode 100644 CBLPRD_TRANS/README.md delete mode 100644 CCPD/.gitignore create mode 100644 CCPD_19K/.gitignore create mode 100644 CCPD_19K/README.md create mode 100644 CCPD_19K/build_ccpd_19k.py create mode 100644 CCPD_19K_EXP_CUT/.gitignore create mode 100644 CCPD_19K_EXP_CUT/README.md create mode 100644 CCPD_19K_EXP_CUT/build_ccpd_19k_exp_cut.py create mode 100644 CCPD_19K_EXP_CUT_TRANS/.gitignore create mode 100644 CCPD_19K_EXP_CUT_TRANS/README.md create mode 100644 CCPD_19K_EXP_CUT_TRANS/build_ccpd_19k_exp_cut_trans.py create mode 100644 CCPD_322K/README.md create mode 100644 CCPD_322K_EXP_CUT/README.md create mode 100644 CCPD_322K_EXP_CUT_TRANS/README.md create mode 100644 CCPD_REAL_TRANS/README.md create mode 100644 CCPD_Random_Blue150K_Green100K/README.md create mode 100644 CCPD_Random_Blue5K_Green5K/README.md create mode 100644 CCPD_Random_Blue5K_Green5K/build_exp_cut.py create mode 100644 CCPD_Random_Blue5K_Green5K/build_exp_cut_trans.py create mode 100644 ccpd_combined_5k5k_spe/README.md create mode 100644 ccpd_combined_5k5k_spe/dataset.yaml create mode 100644 scripts/README.md create mode 100644 scripts/build_cblprd_trans.py create mode 100644 scripts/gen_random_bg.py create mode 100644 scripts/orchestrator_322k.ps1 create mode 100644 scripts/pipeline_322k.ps1 create mode 100644 scripts/pipeline_322k.py create mode 100644 scripts/rebuild_spe.ps1 create mode 100644 scripts/rebuild_spe.py create mode 100644 scripts/rebuild_spe_cpp.py create mode 100644 scripts/sample_ccpd_full.py create mode 100644 scripts/sample_ccpd_real.py diff --git a/.gitignore b/.gitignore index 0507dcb..2e55693 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,100 @@ +# ============================================================ # Python +# ============================================================ __pycache__/ *.py[cod] +# ============================================================ # IDE / Editor +# ============================================================ .vscode/ .idea/ *.swp *.swo *~ +# ============================================================ # OS +# ============================================================ .DS_Store Thumbs.db Desktop.ini -# Logs +# ============================================================ +# Logs & Cache +# ============================================================ *.log - -# Jupyter .ipynb_checkpoints/ -ccpd_combined_5k5k_spe/ -output/ -CCPD_SPE/*/ - -build/ - -# CCPD raw data (keep on disk, never commit) +# ============================================================ +# CCPD 原始真图 (~353K, ~23GB) +# ============================================================ CCPD/CCPD2019/ CCPD/CCPD2020/ -CCPD/*.7z \ No newline at end of file +CCPD/*.7z +CCPD/*.zip + +# ============================================================ +# CCPD 真图 TRANS (~297K, ~2GB) +# ============================================================ +CCPD_REAL_TRANS/train/*.jpg + +# ============================================================ +# CCPD 322K 合成管线 +# ============================================================ +CCPD_322K/train/ +CCPD_322K/test/ +CCPD_322K_EXP_CUT/train/ +CCPD_322K_EXP_CUT/test/ +CCPD_322K_EXP_CUT_TRANS/train/ +CCPD_322K_EXP_CUT_TRANS/test/ + +# ============================================================ +# CCPD 19K 合成管线 (YOLO / CAT / COR) +# ============================================================ +CCPD_19K/train/ +CCPD_19K/test/ +CCPD_19K_EXP_CUT/train/ +CCPD_19K_EXP_CUT/test/ +CCPD_19K_EXP_CUT_TRANS/train/ +CCPD_19K_EXP_CUT_TRANS/test/ + +# ============================================================ +# CCPD Random 蓝绿 +# ============================================================ +CCPD_Random_Blue5K_Green5K/blue/ +CCPD_Random_Blue5K_Green5K/green/ +CCPD_Random_Blue5K_Green5K_EXP_CUT/blue/ +CCPD_Random_Blue5K_Green5K_EXP_CUT/green/ +CCPD_Random_Blue5K_Green5K_EXP_CUT_TRANS/blue/ +CCPD_Random_Blue5K_Green5K_EXP_CUT_TRANS/green/ +CCPD_Random_Blue150K_Green100K/blue/ +CCPD_Random_Blue150K_Green100K/green/ + +# ============================================================ +# CCPD SPE 特殊车牌 +# ============================================================ +CCPD_SPE/army/ +CCPD_SPE/coach/ +CCPD_SPE/gangao/ +CCPD_SPE/lingguan_new/ +CCPD_SPE/lingguan_old/ +CCPD_SPE/new_energy/ +CCPD_SPE/police/ +CCPD_SPE/shiguan_new/ +CCPD_SPE/shiguan_old/ +CCPD_SPE/ccpd_base/ + +# ============================================================ +# CBLPRD +# ============================================================ +CBLPRD_TRANS/train/*.jpg +CBLPRD_eval/ + +# ============================================================ +# 其他 +# ============================================================ +ccpd_combined_5k5k_spe/images/ +ccpd_combined_5k5k_spe/labels/ +output/ +build/ \ No newline at end of file diff --git a/CBLPRD_TRANS/README.md b/CBLPRD_TRANS/README.md new file mode 100644 index 0000000..22211f3 --- /dev/null +++ b/CBLPRD_TRANS/README.md @@ -0,0 +1,11 @@ +# CBLPRD TRANS(ICE-COR 矫正) + +| 项目 | 详情 | +|---|---| +| 来源 | CBLPRD-330K 经过 ICE-COR 角点矫正 + 透视变换 | +| 规模 | 289,480 张 | +| 生成脚本 | `scripts/build_cblprd_trans.py` | +| 标签格式 | 文件名后缀(`parse_plate_suffix` 解析) | +| 训练用途 | OCR 三源混合训练数据源之一(真实域锚点) | + +> **以脚本为准**:生成脚本负责调用 ICE-COR 模型对每张图做角点检测并 warp,输出到 `train/` 子目录。 diff --git a/CCPD/.gitignore b/CCPD/.gitignore deleted file mode 100644 index df7caae..0000000 --- a/CCPD/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# CCPD raw data (download from official source) -CCPD2019/ -CCPD2020/ - -# 7z archives (optional, via git LFS) -*.7z - diff --git a/CCPD/README.md b/CCPD/README.md index c1b349c..55ffcd0 100644 --- a/CCPD/README.md +++ b/CCPD/README.md @@ -1,23 +1,21 @@ -# CCPD Dataset +# CCPD 原始真图 -CCPD (Chinese City Parking Dataset) 公开车牌识别数据集。 +> **以脚本为准**:`scripts/sample_ccpd_full.py` 决定如何读取和转换此目录。 -## 获取数据 - -从 CCPD 官方下载,解压到本目录。 - -## 目录结构 +| 项目 | 详情 | +|---|---| +| 来源 | CCPD2019 + CCPD2020 公开数据集 | +| 规模 | 353,158 张 | +| 标签格式 | CCPD 文件名编码(`parse_ccpd_filename` 解析) | +| 下游脚本 | `scripts/sample_ccpd_full.py` → `CCPD_REAL_TRANS/` | +| 训练用途 | 不直接用于训练,仅作为生成脚本的输入源 | ``` CCPD/ -├── CCPD2019/ # 原始 CCPD2019(本地,不入库) -│ ├── ccpd_base/ # 199,996 张 -│ ├── ccpd_blur/ # 20,611 张 -│ ├── ccpd_challenge/ # 50,003 张 -│ ├── ccpd_db/ # 10,132 张 -│ ├── ccpd_fn/ # 20,967 张 -│ ├── ccpd_np/ # 3,036 张 -│ ├── ccpd_rotate/ # 10,053 张 +├── CCPD2019/ # ccpd_base / blur / challenge / fn / rotate / tilt / weather / np +└── CCPD2020/ # 同上结构 +``` + │ ├── ccpd_tilt/ # 30,216 张 │ ├── ccpd_weather/ # 9,999 张 │ └── splits/ # 标注文件(本地,不入库) diff --git a/CCPD_19K/.gitignore b/CCPD_19K/.gitignore new file mode 100644 index 0000000..5420c69 --- /dev/null +++ b/CCPD_19K/.gitignore @@ -0,0 +1,2 @@ +test/ +train/ \ No newline at end of file diff --git a/CCPD_19K/README.md b/CCPD_19K/README.md new file mode 100644 index 0000000..8c235ce --- /dev/null +++ b/CCPD_19K/README.md @@ -0,0 +1,75 @@ +# CCPD_19K — 11类中国车牌数据集 + +> **以脚本为准**:生成逻辑以 `ICE_Dataset/scripts/build_ccpd_19k.py` 为准。 + +基于 CCPD 构建的 11 分类车牌数据集,涵盖蓝牌、绿牌及 9 种特殊车牌。 + +## 数据来源 + +| 来源 | 类型 | 采样数 | 说明 | +|------|------|--------|------| +| CCPD2019 | 蓝牌 (blue) | 5,000(随机) | 全蓝牌,从 341,369 张中随机抽取 | +| CCPD2020 | 绿牌 (green) | 5,000(随机) | 全绿牌,从 11,776 张中随机抽取 | +| CCPD_SPE | 特殊车牌 (9类) | 9,000(全部) | 9 类特殊车牌,每类 1,000 张 | + +## 11 类车牌 + +| 序号 | 类别 | 目录名 | 说明 | 底板色 | +|------|------|--------|------|--------| +| 1 | 蓝牌 | `blue` | 普通小型汽车 | 蓝底白字 | +| 2 | 绿牌 | `green` | 新能源小型汽车 | 绿底黑字 | +| 3 | 警车 | `police` | 警用车牌 | 白底,末位"警"红字 | +| 4 | 军车 | `army` | 军车/武警 | 白底 | +| 5 | 港澳 | `gangao` | 港澳入出境 | 黑底,粤Z·XXXX港/澳 | +| 6 | 使馆旧版 | `shiguan_old` | 使馆车牌(旧版) | 黑底,"使"红字 | +| 7 | 使馆新版 | `shiguan_new` | 使馆车牌(新版) | 黑底 | +| 8 | 领馆旧版 | `lingguan_old` | 领馆车牌(旧版) | 黑底,"领"红字 | +| 9 | 领馆新版 | `lingguan_new` | 领馆车牌(新版) | 黑底 | +| 10 | 教练车 | `coach` | 教练车牌 | 黄底,末位"学" | +| 11 | 新能源大型 | `new_energy` | 大型新能源 | 黄绿双拼,8位 | + +## 目录结构 + +``` +CCPD_19K/ +├── train/ # 训练集 (90%, 17,100 张) +│ ├── blue/ # 4,500 张 +│ ├── green/ # 4,500 张 +│ ├── police/ # 900 张 +│ ├── army/ # 900 张 +│ ├── gangao/ # 900 张 +│ ├── shiguan_old/ # 900 张 +│ ├── shiguan_new/ # 900 张 +│ ├── lingguan_old/ # 900 张 +│ ├── lingguan_new/ # 900 张 +│ ├── coach/ # 900 张 +│ └── new_energy/ # 900 张 +├── test/ # 测试集 (10%, 1,900 张) +│ ├── blue/ # 500 张 +│ ├── green/ # 500 张 +│ └── ... (同上 11 类) +└── README.md +``` + +## 划分方式 + +- 训练集:测试集 = 9:1 +- 分层抽样(stratified):每类内部独立按 9:1 划分 +- 随机种子:42(可复现) + +## 总计 + +- 训练集:17,100 张 +- 测试集:1,900 张 +- **总计:19,000 张** + +## 生成脚本 + +```bash +# 构建 CCPD_19K (从源数据采样) +python build_ccpd_19k.py + +# 推理 → bbox拓展裁剪 → 角点重映射 → CCPD_19K_EXP_CUT +# 脚本位于 CCPD_19K_EXP_CUT/build_ccpd_19k_exp_cut.py +python ../CCPD_19K_EXP_CUT/build_ccpd_19k_exp_cut.py --full +``` diff --git a/CCPD_19K/build_ccpd_19k.py b/CCPD_19K/build_ccpd_19k.py new file mode 100644 index 0000000..4eefa75 --- /dev/null +++ b/CCPD_19K/build_ccpd_19k.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Build CCPD_19K dataset from: + - CCPD2019 (all blue plates): random 5,000 images -> class "blue" + - CCPD2020 (all green plates): random 5,000 images -> class "green" + - CCPD_SPE (9 special plate types): all 9,000 images -> 9 classes + +Output structure (under ICE_Dataset/CCPD_19K/): + train/ + blue/ ~4500 + green/ ~4500 + police/ ~900 + army/ ~900 + gangao/ ~900 + shiguan_old/ ~900 + shiguan_new/ ~900 + lingguan_old/ ~900 + lingguan_new/ ~900 + coach/ ~900 + new_energy/ ~900 + test/ + (same 11 classes, ~10% each) + +Split ratio: 9:1 (train : test), stratified by class. +""" + +import os +import sys +import random +import shutil +from pathlib import Path +from collections import defaultdict + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +BASE_DIR = Path(r"C:\Users\Kirl\Desktop\ICEv2\ICE_Dataset") +CCPD2019_DIR = BASE_DIR / "CCPD" / "CCPD2019" +CCPD2020_DIR = BASE_DIR / "CCPD" / "CCPD2020" +CCPD_SPE_DIR = BASE_DIR / "CCPD_SPE" +OUTPUT_DIR = BASE_DIR / "CCPD_19K" + +SEED = 42 +TRAIN_RATIO = 0.9 # 9:1 split + +# Number of images to sample from CCPD2019 / CCPD2020 +N_CCPD2019 = 5000 +N_CCPD2020 = 5000 + +# --------------------------------------------------------------------------- +# Step 1: Collect all images from CCPD2019 (blue plates) +# --------------------------------------------------------------------------- +print("=" * 60) +print("Step 1: Collecting CCPD2019 (blue) images ...") + +ccpd2019_images = [] +for jpg in CCPD2019_DIR.rglob("*.jpg"): + ccpd2019_images.append(jpg) + +print(f" Found {len(ccpd2019_images)} total images") +random.seed(SEED) +sampled_2019 = random.sample(ccpd2019_images, min(N_CCPD2019, len(ccpd2019_images))) +print(f" Sampled {len(sampled_2019)} images") + +# --------------------------------------------------------------------------- +# Step 2: Collect all images from CCPD2020 (green plates) +# --------------------------------------------------------------------------- +print("\nStep 2: Collecting CCPD2020 (green) images ...") + +ccpd2020_images = [] +for jpg in CCPD2020_DIR.rglob("*.jpg"): + ccpd2020_images.append(jpg) + +print(f" Found {len(ccpd2020_images)} total images") +random.seed(SEED) +sampled_2020 = random.sample(ccpd2020_images, min(N_CCPD2020, len(ccpd2020_images))) +print(f" Sampled {len(sampled_2020)} images") + +# --------------------------------------------------------------------------- +# Step 3: Collect all images from CCPD_SPE (9 special classes) +# --------------------------------------------------------------------------- +print("\nStep 3: Collecting CCPD_SPE (9 special plate classes) ...") + +# The 9 CCPD_SPE class names (subdirectory names) +SPE_CLASSES = [ + "police", + "army", + "gangao", + "shiguan_old", + "shiguan_new", + "lingguan_old", + "lingguan_new", + "coach", + "new_energy", +] + +spe_images = defaultdict(list) +for jpg in CCPD_SPE_DIR.rglob("*.jpg"): + # Path like: .../CCPD_SPE/ccpd_base/police/xxx.jpg + # The class is the immediate parent directory name + parent_dir = jpg.parent.name + if parent_dir in SPE_CLASSES: + spe_images[parent_dir].append(jpg) + +for cls in SPE_CLASSES: + print(f" {cls}: {len(spe_images[cls])} images") + +total_spe = sum(len(v) for v in spe_images.values()) +print(f" Total CCPD_SPE: {total_spe} images") + +# --------------------------------------------------------------------------- +# Step 4: Build class -> file list mapping +# --------------------------------------------------------------------------- +print("\nStep 4: Building class mappings ...") + +class_files = {} + +# Blue plates from CCPD2019 +class_files["blue"] = sampled_2019 + +# Green plates from CCPD2020 +class_files["green"] = sampled_2020 + +# 9 special plate classes +for cls in SPE_CLASSES: + class_files[cls] = spe_images[cls] + +# Validate +all_11_classes = ["blue", "green"] + SPE_CLASSES +print(f" 11 classes: {all_11_classes}") +for cls in all_11_classes: + print(f" {cls}: {len(class_files[cls])} images") + +# --------------------------------------------------------------------------- +# Step 5: Stratified 9:1 train/test split and copy +# --------------------------------------------------------------------------- +print("\nStep 5: Splitting and copying files ...") + +random.seed(SEED) + +for cls in all_11_classes: + files = class_files[cls] + random.shuffle(files) + + n_train = max(1, int(len(files) * TRAIN_RATIO)) + train_files = files[:n_train] + test_files = files[n_train:] + + # Create output directories + train_dir = OUTPUT_DIR / "train" / cls + test_dir = OUTPUT_DIR / "test" / cls + train_dir.mkdir(parents=True, exist_ok=True) + test_dir.mkdir(parents=True, exist_ok=True) + + # Copy train files (handle duplicate filenames by adding suffix) + for src in train_files: + dst = train_dir / src.name + counter = 1 + stem, suffix = dst.stem, dst.suffix + while dst.exists(): + dst = train_dir / f"{stem}_{counter}{suffix}" + counter += 1 + shutil.copy2(src, dst) + + # Copy test files (handle duplicate filenames by adding suffix) + for src in test_files: + dst = test_dir / src.name + counter = 1 + stem, suffix = dst.stem, dst.suffix + while dst.exists(): + dst = test_dir / f"{stem}_{counter}{suffix}" + counter += 1 + shutil.copy2(src, dst) + + print(f" {cls}: train={len(train_files)}, test={len(test_files)}") + +# --------------------------------------------------------------------------- +# Step 6: Summary +# --------------------------------------------------------------------------- +print("\n" + "=" * 60) +print("CCPD_19K dataset built successfully!") +print(f" Output: {OUTPUT_DIR}") + +total_train = 0 +total_test = 0 +for cls in all_11_classes: + train_cnt = len(list((OUTPUT_DIR / "train" / cls).glob("*.jpg"))) + test_cnt = len(list((OUTPUT_DIR / "test" / cls).glob("*.jpg"))) + total_train += train_cnt + total_test += test_cnt + print(f" {cls:15s} train: {train_cnt:5d} test: {test_cnt:5d}") + +print(f" {'TOTAL':15s} train: {total_train:5d} test: {total_test:5d}") +print(f" Grand total: {total_train + total_test}") +print("=" * 60) diff --git a/CCPD_19K_EXP_CUT/.gitignore b/CCPD_19K_EXP_CUT/.gitignore new file mode 100644 index 0000000..5420c69 --- /dev/null +++ b/CCPD_19K_EXP_CUT/.gitignore @@ -0,0 +1,2 @@ +test/ +train/ \ No newline at end of file diff --git a/CCPD_19K_EXP_CUT/README.md b/CCPD_19K_EXP_CUT/README.md new file mode 100644 index 0000000..5bad17a --- /dev/null +++ b/CCPD_19K_EXP_CUT/README.md @@ -0,0 +1,69 @@ +# CCPD_19K_EXP_CUT — 拓展裁剪 + 角点重映射 + +> **以脚本为准**:生成逻辑以 `ICE_Dataset/scripts/build_ccpd_19k_exp_cut.py` 为准。 + +由 CCPD_19K 经 YOLOv5t 推理 → bbox 拓展 → 裁剪 + 角点重映射生成。 + +## 处理流程 + +``` +CCPD_19K (19,000 张) + → YOLOv5t best.pt 推理 (imgsz=352, conf=0.25) + → bbox 拓展: + 常规: 12% (w ≥ 67 且 h ≥ 51) + 小车牌: 18% (w < 67 或 h < 51) + → 裁剪 + → 角点重映射写入文件名 + → CCPD_19K_EXP_CUT +``` + +## 目录结构 + +``` +CCPD_19K_EXP_CUT/ +├── train/ # 16,999 张 +│ ├── blue/ # 4,435 张 +│ ├── green/ # 4,486 张 +│ ├── police/ # 897 张 +│ ├── army/ # 898 张 +│ ├── gangao/ # 899 张 +│ ├── shiguan_old/ # 898 张 +│ ├── shiguan_new/ # 897 张 +│ ├── lingguan_old/ # 897 张 +│ ├── lingguan_new/ # 897 张 +│ ├── coach/ # 897 张 +│ └── new_energy/ # 898 张 +├── test/ # 1,888 张 +│ └── ... (同上 11 类) +└── README.md +``` + +## 统计 + +| 指标 | 数值 | +|------|------| +| 源图 | 19,000 | +| 裁剪成功 | 18,887 (99.4%) | +| 角点重映射 | 18,870 (99.3%) | +| 跳过 | 113 (0.6%) | +| - 漏检 | 71 | +| - 多框 | 42 | + +## 文件名角点格式 + +裁剪后 CCPD 文件名中的角点段已更新为裁剪坐标系像素坐标: + +``` +原: ...-324&452_241&449_242&420_325&423-... +新: ...-99&38_16&35_17&6_100&9-... +``` + +格式: `-C1X&C1Y_C2X&C2Y_C3X&C3Y_C4X&C4Y-` (像素坐标, 整数) + +## 生成脚本 + +```bash +cd ICE_Dataset/CCPD_19K_EXP_CUT +python build_ccpd_19k_exp_cut.py --dry-run # 预览 +python build_ccpd_19k_exp_cut.py --full # 全量 +``` diff --git a/CCPD_19K_EXP_CUT/build_ccpd_19k_exp_cut.py b/CCPD_19K_EXP_CUT/build_ccpd_19k_exp_cut.py new file mode 100644 index 0000000..f37d143 --- /dev/null +++ b/CCPD_19K_EXP_CUT/build_ccpd_19k_exp_cut.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +""" +CCPD_19K 模型推理 → bbox 拓展 → 裁剪 → 角点重映射写入文件名 → CCPD_19K_EXP_CUT + +使用 YOLOv5t best.pt 对 CCPD_19K 全部图片进行推理, +按 12%/18% 策略拓展 bbox 后裁剪,并将原 CCPD 角点重映射到裁剪坐标系后更新文件名。 +保持原文件夹结构,无额外标签文件。 + +用法: + python build_ccpd_19k_exp_cut.py --dry-run # 预览 (每类2张, 图上绘制角点) + python build_ccpd_19k_exp_cut.py --n 100 # 预览 N 张 + python build_ccpd_19k_exp_cut.py --full # 全量处理 (~19K, 无绘制) +""" + +import argparse +import os +import re +import shutil +import sys +import time +from collections import defaultdict +from pathlib import Path + +import cv2 +import numpy as np + +# ---- Unicode-safe I/O (must be BEFORE ultralytics import!) ---- +_original_imread = cv2.imread + +def _imread_unicode(filepath, flags=cv2.IMREAD_COLOR): + try: + data = np.fromfile(filepath, dtype=np.uint8) + if len(data) == 0: + return None + img = cv2.imdecode(data, flags) + return img + except Exception: + return _original_imread(filepath, flags) + +def _imwrite_unicode(filepath, img, params=None): + try: + ext = os.path.splitext(filepath)[1] + ok, buf = cv2.imencode(ext, img, params or []) + if ok: + with open(filepath, 'wb') as f: + f.write(buf.tobytes()) + return True + return False + except Exception: + return cv2.imwrite(filepath, img, params) + +cv2.imread = _imread_unicode # patch before ultralytics hooks it +# ----------------------------------------------------------------- + +import torch +import yaml +from ultralytics import YOLO + +# --------------------------------------------------------------------------- +# 路径 +# --------------------------------------------------------------------------- +SCRIPT_DIR = Path(__file__).resolve().parent +PROJ_ROOT = SCRIPT_DIR.parents[1] # ICEv2/ +MODEL_PATH = PROJ_ROOT / "ICE_NNv2" / "yolov5t" / "output" / "1_CCPD_19K" / "weights" / "best.pt" +SRC_DIR = PROJ_ROOT / "ICE_Dataset" / "CCPD_19K" # 源数据集 +DST_DIR = SCRIPT_DIR # 本目录 (CCPD_19K_EXP_CUT) + +# --------------------------------------------------------------------------- +# 推理 & 拓展参数 (与 ICE 主项目 / infer_expand.py 一致) +# --------------------------------------------------------------------------- +IMGSZ = 352 +CONF = 0.25 + +# CCPD 图像原始分辨率 +CCPD_W = 720 +CCPD_H = 1160 + +# ICE 主项目 bbox 拓展系数: 12% 常规, 18% 小车牌 +EXPAND_NORMAL = 1.12 +EXPAND_SMALL = 1.18 + +# ICE: bw < 180 || bh < 48 @1080p → 缩放到 CCPD 分辨率 +SMALL_BOX_W = int(180 * CCPD_W / 1920) # ≈ 68 +SMALL_BOX_H = int(48 * CCPD_H / 1080) # ≈ 52 + +MIN_PAD = 2 + +# CCPD_19K 的 split 名称 +SPLITS = ["train", "test"] + +# --------------------------------------------------------------------------- +# CCPD 角点解析 & 文件名重映射 +# --------------------------------------------------------------------------- + +# 匹配 CCPD 文件名中角点段: -C1X&C1Y_C2X&C2Y_C3X&C3Y_C4X&C4Y- +CORNER_RE = re.compile(r'-(\d+)&(\d+)_(\d+)&(\d+)_(\d+)&(\d+)_(\d+)&(\d+)-') + + +def parse_ccpd_corners(filename: str): + """ + 从 CCPD 文件名解析 4 个角点 (四边形), 返回像素坐标. + 格式: ...-C1X&C1Y_C2X&C2Y_C3X&C3Y_C4X&C4Y-... + 返回: [(x1,y1), (x2,y2), (x3,y3), (x4,y4)] 或 None + """ + m = CORNER_RE.search(filename) + if not m: + return None + nums = [int(m.group(i)) for i in range(1, 9)] + return [(nums[0], nums[1]), (nums[2], nums[3]), (nums[4], nums[5]), (nums[6], nums[7])] + + +def remap_corners_to_crop(corners, x1, y1, x2, y2): + """ + 将角点从原图坐标系重映射到裁剪区域像素坐标 (整数). + 仅当四边形与裁剪区重叠 > 25% 时才重映射. + + 返回: [(px,py), ...] 裁剪区像素坐标 或 None + """ + crop_w = x2 - x1 + crop_h = y2 - y1 + if crop_w <= 0 or crop_h <= 0: + return None + + # 四边形外包矩形与裁剪区重叠率 + xs = [c[0] for c in corners] + ys = [c[1] for c in corners] + poly_x1, poly_x2 = min(xs), max(xs) + poly_y1, poly_y2 = min(ys), max(ys) + + inter_w = max(0, min(poly_x2, x2) - max(poly_x1, x1)) + inter_h = max(0, min(poly_y2, y2) - max(poly_y1, y1)) + poly_area = (poly_x2 - poly_x1) * (poly_y2 - poly_y1) + + if poly_area <= 0 or (inter_w * inter_h) / poly_area <= 0.25: + return None + + # 逐点平移 + clip + new_corners = [] + for (px, py) in corners: + nx = max(0, min(crop_w - 1, int(round(px - x1)))) + ny = max(0, min(crop_h - 1, int(round(py - y1)))) + new_corners.append((nx, ny)) + return new_corners + + +def replace_corners_in_filename(filename: str, new_corners): + """ + 替换文件名中的角点段为新的像素坐标. + new_corners: [(px1,py1), (px2,py2), (px3,py3), (px4,py4)] + """ + new_seg = "-{0}&{1}_{2}&{3}_{4}&{5}_{6}&{7}-".format( + new_corners[0][0], new_corners[0][1], + new_corners[1][0], new_corners[1][1], + new_corners[2][0], new_corners[2][1], + new_corners[3][0], new_corners[3][1], + ) + return CORNER_RE.sub(new_seg, filename, count=1) + + +# --------------------------------------------------------------------------- +# 单张处理 +# --------------------------------------------------------------------------- + +def infer_and_crop(model, img_path: Path, out_img_path: Path, draw_corners: bool = False): + """ + 对单张图片: + 1. YOLO 推理 → 检出框 + 2. 按大小选择 12%/18% 拓展 + 3. 裁剪, 角点重映射 → 更新文件名 + 4. (可选) 在裁剪图上绘制四边形角点供审核 + 返回: + "cropped" — 成功 (角点已重映射写入文件名) + "cropped_nocorners" — 成功 (源文件名无角点段, 原名保存) + "cropped_low_overlap" — 成功 (角点重叠不足, 原名保存) + "miss" — 漏检 + "multi" — 多框跳过 + """ + try: + with open(str(img_path), 'rb') as f: + data = f.read() + img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR) + except Exception: + img = None + if img is None: + return "miss" + + orig_h, orig_w = img.shape[:2] + + results = model.predict( + str(img_path), imgsz=IMGSZ, verbose=False, + conf=CONF, max_det=5 + ) + boxes = results[0].boxes + + if boxes is None or len(boxes) == 0: + return "miss" + + xywh = boxes.xywh.cpu().numpy() + + if len(xywh) > 1: + return "multi" + + cx, cy, bw, bh = xywh[0] + + # ---- 拓展系数 ---- + expand_factor = EXPAND_SMALL if (bw < SMALL_BOX_W or bh < SMALL_BOX_H) else EXPAND_NORMAL + + # ---- 裁剪区域 ---- + pad_x = max(MIN_PAD, int(round(bw * (expand_factor - 1.0) / 2.0))) + pad_y = max(MIN_PAD, int(round(bh * (expand_factor - 1.0) / 2.0))) + + x1_raw = int(cx - bw / 2) + y1_raw = int(cy - bh / 2) + x2_raw = int(cx + bw / 2) + y2_raw = int(cy + bh / 2) + + x1 = max(x1_raw - pad_x, 0) + y1 = max(y1_raw - pad_y, 0) + x2 = min(x2_raw + pad_x + 1, orig_w) + y2 = min(y2_raw + pad_y + 1, orig_h) + + if x2 - x1 <= 0 or y2 - y1 <= 0: + return "miss" + + crop = img[y1:y2, x1:x2] + + # ---- 角点重映射 → 更新文件名 ---- + stem = img_path.stem + corners = parse_ccpd_corners(stem) + remapped = None + + if corners is not None: + remapped = remap_corners_to_crop(corners, x1, y1, x2, y2) + if remapped is not None: + new_stem = replace_corners_in_filename(stem, remapped) + else: + new_stem = stem # 重叠不足, 保留原名 + else: + new_stem = stem # 无角点段 + + # ---- 绘制角点 (预览模式) ---- + if draw_corners and remapped is not None: + crop_h_draw, crop_w_draw = crop.shape[:2] + pts = [(px, py) for (px, py) in remapped] + pts_arr = np.array(pts, dtype=np.int32) + cv2.polylines(crop, [pts_arr], isClosed=True, color=(0, 255, 0), thickness=2) + for k, (px, py) in enumerate(pts): + cv2.circle(crop, (px, py), 3, (0, 0, 255), -1) + cv2.putText(crop, str(k), (px + 4, py - 4), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 0), 1) + label = f"expand={expand_factor:.0%}" + cv2.putText(crop, label, (4, 14), + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 255), 1) + + # ---- 保存 ---- + out_img_path.parent.mkdir(parents=True, exist_ok=True) + final_out = out_img_path.parent / (new_stem + out_img_path.suffix) + _imwrite_unicode(str(final_out), crop) + + if corners is None: + return "cropped_nocorners" + elif remapped is None: + return "cropped_low_overlap" + else: + return "cropped" + + +# --------------------------------------------------------------------------- +# 主流程 +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description="CCPD_19K → 推理拓展裁剪+角点重映射 → CCPD_19K_EXP_CUT") + parser.add_argument("--dry-run", action="store_true", help="预览 (每类2张, 图上绘制角点)") + parser.add_argument("--n", type=int, default=None, help="预览 N 张") + parser.add_argument("--full", action="store_true", help="全量处理 (~19K)") + args = parser.parse_args() + + if not (args.dry_run or args.n or args.full): + print("请指定 --dry-run, --n N, 或 --full") + print(f" python {Path(__file__).name} --dry-run") + print(f" python {Path(__file__).name} --full") + return + + if args.dry_run: + per_class_limit = 2 + label = "DRY-RUN (每类2张, 绘制角点)" + elif args.n: + per_class_limit = None + label = f"预览 ({args.n} 张)" + else: + per_class_limit = None + label = "全量处理" + + draw = not args.full # 非全量模式绘制角点 + + print("=" * 70) + print(f"CCPD_19K → bbox拓展 {EXPAND_NORMAL:.0%}/{EXPAND_SMALL:.0%} → 裁剪 → 角点重映射写入文件名") + print(f"模式: {label}") + print("=" * 70) + + # ---- 加载模型 ---- + print(f"\n加载: {MODEL_PATH}") + device = 'cuda:0' if torch.cuda.is_available() else 'cpu' + model = YOLO(str(MODEL_PATH)) + model.to(device) + print(f"设备: {device} | imgsz={IMGSZ} | conf={CONF}") + print(f"拓展: 常规 {EXPAND_NORMAL:.0%} (w>={SMALL_BOX_W},h>={SMALL_BOX_H}) / 小车牌 {EXPAND_SMALL:.0%} (w<{SMALL_BOX_W}或h<{SMALL_BOX_H})") + if draw: + print(f"预览: 裁剪图上绘制四边形角点 (绿框+红顶点+序号)") + + # ---- 收集图片 ---- + print("\n收集图片...") + all_tasks = [] # [(src_path, out_path)] + + for split in SPLITS: + split_dir = SRC_DIR / split + if not split_dir.exists(): + continue + for cls_dir in sorted(split_dir.iterdir()): + if not cls_dir.is_dir(): + continue + for jpg in sorted(cls_dir.glob("*.jpg")): + rel = jpg.relative_to(SRC_DIR) + out = DST_DIR / rel + all_tasks.append((jpg, out)) + + print(f"共 {len(all_tasks)} 张") + + # ---- 限制数量 ---- + if args.dry_run: + by_class = defaultdict(list) + for src, dst in all_tasks: + by_class[src.parent.name].append((src, dst)) + limited = [] + for cls in sorted(by_class): + limited.extend(by_class[cls][:per_class_limit]) + all_tasks = limited + print(f"预览: 限制 {len(all_tasks)} 张 (每类 {per_class_limit} 张)") + elif args.n: + all_tasks = all_tasks[:args.n] + print(f"预览: 限制 {len(all_tasks)} 张") + + # ---- 清理旧输出 (非 full) ---- + if not args.full and DST_DIR.exists(): + shutil.rmtree(DST_DIR) + + # ---- 处理 ---- + print(f"\n开始处理...") + t0 = time.perf_counter() + + stats = {"total": 0, "cropped": 0, "nocorners": 0, "low_overlap": 0, "miss": 0, "multi": 0} + sc = defaultdict(lambda: {"ok": 0, "skip": 0}) + + for idx, (src_path, out_path) in enumerate(all_tasks): + rel = src_path.relative_to(SRC_DIR) + parts = rel.parts # (split, class, filename) + split_name, cls_name = parts[0], parts[1] + + result = infer_and_crop(model, src_path, out_path, draw_corners=draw) + + stats["total"] += 1 + key = f"{split_name}/{cls_name}" + + if result == "cropped": + stats["cropped"] += 1 + sc[key]["ok"] += 1 + elif result == "cropped_nocorners": + stats["nocorners"] += 1 + sc[key]["ok"] += 1 + elif result == "cropped_low_overlap": + stats["low_overlap"] += 1 + sc[key]["ok"] += 1 + elif result in ("miss", "multi"): + stats[result] += 1 + sc[key]["skip"] += 1 + + if (idx + 1) % 500 == 0 or (idx + 1) == len(all_tasks): + elapsed = time.perf_counter() - t0 + rate = (idx + 1) / elapsed if elapsed > 0 else 0 + eta = (len(all_tasks) - (idx + 1)) / rate if rate > 0 else 0 + ok = stats["cropped"] + stats["nocorners"] + stats["low_overlap"] + skip = stats["miss"] + stats["multi"] + print(f" [{idx+1}/{len(all_tasks)}] " + f"裁剪={ok} (重映射={stats['cropped']}) " + f"跳过={skip} " + f"速率={rate:.1f}张/s ETA={eta:.0f}s") + + t1 = time.perf_counter() + + # ---- 统计 ---- + total_ok = stats["cropped"] + stats["nocorners"] + stats["low_overlap"] + total_skip = stats["miss"] + stats["multi"] + print(f"\n{'='*70}") + print(f"完成! {t1-t0:.1f}s") + print(f" 源图: {stats['total']}") + print(f" 裁剪成功: {total_ok} ({total_ok/max(stats['total'],1)*100:.1f}%)") + print(f" - 角点已重映射写入文件名: {stats['cropped']}") + print(f" - 无角点段 (保留原名): {stats['nocorners']}") + print(f" - 重叠不足 (保留原名): {stats['low_overlap']}") + print(f" 跳过: {total_skip}") + print(f" - 漏检: {stats['miss']}") + print(f" - 多框: {stats['multi']}") + + print(f"\n 按类别:") + for key in sorted(sc): + s = sc[key] + print(f" {key:30s} ok={s['ok']:5d} skip={s['skip']:5d}") + + print(f"\n 输出: {DST_DIR}") + for split in SPLITS: + sp = DST_DIR / split + if sp.exists(): + jpg_n = sum(1 for _ in sp.rglob("*.jpg")) + cls_n = len([d for d in sp.iterdir() if d.is_dir()]) + print(f" {split}/ : {jpg_n} 图, {cls_n} 类") + + print(f"\n{'='*70}") + + +if __name__ == "__main__": + main() diff --git a/CCPD_19K_EXP_CUT_TRANS/.gitignore b/CCPD_19K_EXP_CUT_TRANS/.gitignore new file mode 100644 index 0000000..5420c69 --- /dev/null +++ b/CCPD_19K_EXP_CUT_TRANS/.gitignore @@ -0,0 +1,2 @@ +test/ +train/ \ No newline at end of file diff --git a/CCPD_19K_EXP_CUT_TRANS/README.md b/CCPD_19K_EXP_CUT_TRANS/README.md new file mode 100644 index 0000000..b7c1d32 --- /dev/null +++ b/CCPD_19K_EXP_CUT_TRANS/README.md @@ -0,0 +1,61 @@ +# CCPD_19K_EXP_CUT_TRANS — ICE-COR 角点矫正 + 透视变换 + +> **以脚本为准**:生成逻辑以 `ICE_Dataset/scripts/build_ccpd_19k_exp_cut_trans.py` 为准。 + +由 CCPD_19K_EXP_CUT 经 ICE-COR 角点推理 → 中心外扩 8%(x)/5%(y) → 透视变换生成。 + +## 处理流程 + +``` +CCPD_19K_EXP_CUT (~18,887 张) + → ICE-COR best.pth 推理 (192×64) + → 角点排序 (TL→TR→BR→BL) + → 中心外扩: 8% (x) / 5% (y) + → 透视变换 → 192×64 + → 文件名追加 _trans + → CCPD_19K_EXP_CUT_TRANS +``` + +## 目录结构 + +``` +CCPD_19K_EXP_CUT_TRANS/ +├── train/ # 与源数据集对应 +│ ├── blue/ +│ ├── green/ +│ ├── police/ +│ ├── army/ +│ ├── gangao/ +│ ├── shiguan_old/ +│ ├── shiguan_new/ +│ ├── lingguan_old/ +│ ├── lingguan_new/ +│ ├── coach/ +│ └── new_energy/ +├── test/ +│ └── ... (同上 11 类) +└── README.md +``` + +## 生成脚本 + +```bash +cd ICE_Dataset/CCPD_19K_EXP_CUT_TRANS +python build_ccpd_19k_exp_cut_trans.py --dry-run # 预览 (每类2张) +python build_ccpd_19k_exp_cut_trans.py --n 100 # 预览 100 张 +python build_ccpd_19k_exp_cut_trans.py --full # 全量处理 +``` + +## 参数说明 + +| 参数 | 说明 | +|------|------| +| 外扩比例 | 8% (x) / 5% (y),从四角点中心向外径向扩展 | +| 输出尺寸 | 192×64 (对齐车牌生成器 440×140 底图比例) | +| 文件名 | 原文件名追加 `_trans`,不重映射角点 | +| 跳过策略 | ICE-COR 推理失败 / 角点越界 → 直接跳过 | + +## 模型 + +- ICE-COR: `ICE_NNv2/ICE-COR/output/1_CCPD_19K_EXP_CUT/best.pth` +- 专为 CCPD_19K_EXP_CUT 裁剪车牌 ROI 训练 diff --git a/CCPD_19K_EXP_CUT_TRANS/build_ccpd_19k_exp_cut_trans.py b/CCPD_19K_EXP_CUT_TRANS/build_ccpd_19k_exp_cut_trans.py new file mode 100644 index 0000000..1aed5ba --- /dev/null +++ b/CCPD_19K_EXP_CUT_TRANS/build_ccpd_19k_exp_cut_trans.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +""" +CCPD_19K_EXP_CUT → ICE-COR 角点推理 → 中心外扩 → 透视变换 → CCPD_19K_EXP_CUT_TRANS + +基于 CCPD_19K_EXP_CUT 裁剪车牌图片,使用 ICE-COR 检测四角点, +按 8%(x)/5%(y) 中心外扩后进行透视变换矫正至 192×64, +文件名追加 _trans 后缀,保持原文件夹结构。 + +外扩算法参考: Linux/App/main.cc rectify_plate_pfld() + +用法: + python build_ccpd_19k_exp_cut_trans.py --dry-run # 预览 (每类2张) + python build_ccpd_19k_exp_cut_trans.py --n 100 # 预览 100 张 + python build_ccpd_19k_exp_cut_trans.py --full # 全量处理 (~19K) +""" + +import argparse +import os +import sys +import time +from collections import defaultdict +from pathlib import Path + +import cv2 +import numpy as np + +# ---- Unicode-safe I/O ---- +def _imread_unicode(filepath, flags=cv2.IMREAD_COLOR): + try: + data = np.fromfile(filepath, dtype=np.uint8) + if len(data) == 0: + return None + return cv2.imdecode(data, flags) + except Exception: + return cv2.imread(filepath, flags) # fallback + +def _imwrite_unicode(filepath, img, params=None): + """Unicode-safe imwrite — NEVER falls back to cv2.imwrite.""" + try: + ext = os.path.splitext(filepath)[1] + ok, buf = cv2.imencode(ext, img, params or []) + if ok: + os.makedirs(os.path.dirname(filepath), exist_ok=True) + with open(filepath, 'wb') as f: + f.write(buf.tobytes()) + return True + # imencode failed — try writing via temp file + import tempfile, shutil + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp: + tmp.write(cv2.imencode(ext, img, params or [])[1].tobytes()) + shutil.move(tmp.name, filepath) + return True + except Exception: + # Last resort: imencode to bytes, write with Python I/O + try: + ext = os.path.splitext(filepath)[1] + ok, buf = cv2.imencode(ext, img, params or []) + if ok: + os.makedirs(os.path.dirname(filepath), exist_ok=True) + with open(filepath, 'wb') as f: + f.write(buf.tobytes()) + return True + except Exception: + pass + return False + +import torch + +# --------------------------------------------------------------------------- +# 路径 +# --------------------------------------------------------------------------- +SCRIPT_DIR = Path(__file__).resolve().parent # CCPD_19K_EXP_CUT_TRANS/ +PROJ_ROOT = SCRIPT_DIR.parents[1] # ICEv2/ +ICE_COR_DIR = PROJ_ROOT / "ICE_NNv2" / "ICE-COR" +MODEL_PATH = ICE_COR_DIR / "output" / "1_CCPD_19K_EXP_CUT" / "best.pth" +SRC_DIR = PROJ_ROOT / "ICE_Dataset" / "CCPD_19K_EXP_CUT" # 源数据集 +DST_DIR = SCRIPT_DIR # 本目录 + +if str(ICE_COR_DIR) not in sys.path: + sys.path.insert(0, str(ICE_COR_DIR)) + +from corner_rectify import load_corner_model, predict_plate_corners, order_corners + +# --------------------------------------------------------------------------- +# 参数 +# --------------------------------------------------------------------------- +OUT_W = 192 +OUT_H = 64 +CORNER_PAD_X = 0.08 # 从中心向外扩展 8% (x) +CORNER_PAD_Y = 0.05 # 从中心向外扩展 5% (y) + +SPLITS = ["train", "test"] + + +# =================================================================== +# 核心处理: 单张图片 +# =================================================================== + +def process_single(model, device, img_path: Path, out_img_path: Path, + draw_corners: bool = False): + """ + 对单张裁剪车牌图片: + 1. ICE-COR 推理 → 4 角点像素坐标 + 2. order_corners → TL, TR, BR, BL + 3. 中心外扩 8%(x)/5%(y), clip 到图像边界 + 4. 透视变换 → 192×64 + 5. (可选) 在输出图上绘制角点 + 6. 保存, 文件名追加 _trans + + 返回: + "ok" — 成功 + "skip" — 跳过 (推理失败/角点异常) + """ + img = _imread_unicode(str(img_path)) + if img is None: + return "skip" + + H, W = img.shape[:2] + + # ---- 1. ICE-COR 角点推理 ---- + try: + corners_raw = predict_plate_corners(img, model, device) # (4,2) 像素坐标 + except Exception: + return "skip" + + # ---- 2. 排序 (TL, TR, BR, BL) ---- + ordered = order_corners(corners_raw) # (4,2) float32 + + # 校验: 角点是否有效 (非退化四边形) + if not _valid_quad(ordered, W, H): + return "skip" + + # ---- 3. 中心外扩 ---- + src_pts = _expand_corners(ordered, W, H, CORNER_PAD_X, CORNER_PAD_Y) + + # ---- 4. 透视变换 ---- + dst_pts = np.float32([[0, 0], [OUT_W - 1, 0], [OUT_W - 1, OUT_H - 1], [0, OUT_H - 1]]) + M = cv2.getPerspectiveTransform(src_pts, dst_pts) + warped = cv2.warpPerspective(img, M, (OUT_W, OUT_H)) + + # ---- 5. 绘制角点 (预览模式) ---- + if draw_corners: + # 将原始角点 (ordered) 和外扩后角点 (src_pts) 映射到 warped 上绘制 + _draw_debug(warped, ordered, src_pts, M) + + # ---- 6. 保存 ---- + stem = img_path.stem + new_name = stem + "_trans" + img_path.suffix + out_img_path.parent.mkdir(parents=True, exist_ok=True) + final_out = out_img_path.parent / new_name + _imwrite_unicode(str(final_out), warped) + + return "ok" + + +def _valid_quad(pts, W, H): + """检查四边形是否有效 (非退化, 不越界太离谱)""" + pts = np.asarray(pts, dtype=np.float32) + # 所有点必须在图像范围内 (允许少许越界) + if (pts[:, 0] < -0.5 * W).any() or (pts[:, 0] > 1.5 * W).any(): + return False + if (pts[:, 1] < -0.5 * H).any() or (pts[:, 1] > 1.5 * H).any(): + return False + # 面积不能太小 (退化) + area = cv2.contourArea(pts.reshape(-1, 1, 2).astype(np.float32)) + if area < 0.01 * W * H: + return False + return True + + +def _expand_corners(pts, W, H, pad_x, pad_y): + """ + 从四角点中心出发, 各点沿径向向外扩展 pad_x/pad_y 比例. + 参考: Linux/App/main.cc rectify_plate_pfld() + """ + pts = np.asarray(pts, dtype=np.float32).copy() + center = pts.mean(axis=0) + + for i in range(4): + pts[i, 0] = center[0] + (pts[i, 0] - center[0]) * (1.0 + pad_x) + pts[i, 1] = center[1] + (pts[i, 1] - center[1]) * (1.0 + pad_y) + + # clip 到图像边界 + pts[:, 0] = np.clip(pts[:, 0], 0.0, W - 1.0) + pts[:, 1] = np.clip(pts[:, 1], 0.0, H - 1.0) + return pts + + +def _draw_debug(warped, ordered, src_pts, M): + """在 warped 图上绘制 ICE-COR 原始角点和外扩后角点""" + # 原始角点 (ordered) → warped 坐标系 + ordered_h = cv2.perspectiveTransform( + ordered.reshape(1, -1, 2).astype(np.float32), M + ).reshape(-1, 2).astype(np.int32) + + # 外扩角点 (src_pts) → warped 坐标系 (应该映射到四角) + src_h = cv2.perspectiveTransform( + src_pts.reshape(1, -1, 2).astype(np.float32), M + ).reshape(-1, 2).astype(np.int32) + + # 绿色: 外扩后的四边形 + cv2.polylines(warped, [src_h], isClosed=True, color=(0, 255, 0), thickness=1) + # 蓝色: 原始 ICE-COR 角点 + for pt in ordered_h: + cv2.circle(warped, tuple(pt), 2, (255, 0, 0), -1) + + # 标签 + cv2.putText(warped, "blue=ICE-COR green=expanded", + (3, 10), cv2.FONT_HERSHEY_SIMPLEX, 0.3, (255, 255, 255), 1) + + +# =================================================================== +# 主流程 +# =================================================================== + +def main(): + parser = argparse.ArgumentParser( + description="CCPD_19K_EXP_CUT → ICE-COR角点 + 外扩 + 透视变换 → CCPD_19K_EXP_CUT_TRANS") + parser.add_argument("--dry-run", action="store_true", + help="预览 (每类2张, 图上绘制角点)") + parser.add_argument("--n", type=int, default=None, + help="预览 N 张") + parser.add_argument("--full", action="store_true", + help="全量处理 (~19K)") + args = parser.parse_args() + + if not (args.dry_run or args.n or args.full): + print("请指定 --dry-run, --n N, 或 --full") + print(f" python {Path(__file__).name} --dry-run") + print(f" python {Path(__file__).name} --full") + return + + draw = not args.full + label = ("DRY-RUN (每类2张)" if args.dry_run else + f"预览 ({args.n} 张)" if args.n else "全量处理") + + print("=" * 70) + print(f"CCPD_19K_EXP_CUT → ICE-COR 角点 + 外扩 {CORNER_PAD_X:.0%}(x)/{CORNER_PAD_Y:.0%}(y) → 透视变换 {OUT_W}×{OUT_H}") + print(f"模式: {label}") + print("=" * 70) + + # ---- 加载 ICE-COR 模型 ---- + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + print(f"\n加载 ICE-COR: {MODEL_PATH}") + model, _ = load_corner_model(str(MODEL_PATH), device) + print(f"设备: {device} | 输出: {OUT_W}×{OUT_H}") + + # ---- 收集图片 ---- + print("\n收集图片...") + all_tasks = [] + for split in SPLITS: + split_dir = SRC_DIR / split + if not split_dir.exists(): + continue + for cls_dir in sorted(split_dir.iterdir()): + if not cls_dir.is_dir(): + continue + for jpg in sorted(cls_dir.glob("*.jpg")): + rel = jpg.relative_to(SRC_DIR) + out = DST_DIR / rel + all_tasks.append((jpg, out)) + + print(f"共 {len(all_tasks)} 张") + + # ---- 限制数量 ---- + if args.dry_run: + by_class = defaultdict(list) + for src, dst in all_tasks: + by_class[src.parent.name].append((src, dst)) + limited = [] + for cls in sorted(by_class): + limited.extend(by_class[cls][:2]) + all_tasks = limited + print(f"预览: 限制 {len(all_tasks)} 张 (每类 2 张)") + elif args.n: + all_tasks = all_tasks[:args.n] + print(f"预览: 限制 {len(all_tasks)} 张") + + # ---- 清理旧输出 (非 full 模式) ---- + if not args.full: + import shutil + for split in SPLITS: + sp = DST_DIR / split + if sp.exists(): + shutil.rmtree(sp) + + # ---- 处理 ---- + print(f"\n开始处理...") + t0 = time.perf_counter() + stats = {"total": 0, "ok": 0, "skip": 0} + sc = defaultdict(lambda: {"ok": 0, "skip": 0}) + + for idx, (src_path, out_path) in enumerate(all_tasks): + rel = src_path.relative_to(SRC_DIR) + parts = rel.parts + split_name, cls_name = parts[0], parts[1] + + result = process_single(model, device, src_path, out_path, + draw_corners=draw) + + stats["total"] += 1 + key = f"{split_name}/{cls_name}" + if result == "ok": + stats["ok"] += 1 + sc[key]["ok"] += 1 + else: + stats["skip"] += 1 + sc[key]["skip"] += 1 + + if (idx + 1) % 500 == 0 or (idx + 1) == len(all_tasks): + elapsed = time.perf_counter() - t0 + rate = (idx + 1) / elapsed if elapsed > 0 else 0 + eta = (len(all_tasks) - (idx + 1)) / rate if rate > 0 else 0 + print(f" [{idx+1}/{len(all_tasks)}] " + f"成功={stats['ok']} 跳过={stats['skip']} " + f"速率={rate:.1f}张/s ETA={eta:.0f}s") + + t1 = time.perf_counter() + + # ---- 统计 ---- + print(f"\n{'='*70}") + print(f"完成! {t1 - t0:.1f}s") + print(f" 总图: {stats['total']}") + print(f" 成功: {stats['ok']} ({stats['ok'] / max(stats['total'], 1) * 100:.1f}%)") + print(f" 跳过: {stats['skip']}") + + print(f"\n 按类别:") + for key in sorted(sc): + s = sc[key] + print(f" {key:30s} ok={s['ok']:5d} skip={s['skip']:5d}") + + print(f"\n 输出: {DST_DIR}") + for split in SPLITS: + sp = DST_DIR / split + if sp.exists(): + jpg_n = sum(1 for _ in sp.rglob("*.jpg")) + cls_n = len([d for d in sp.iterdir() if d.is_dir()]) + print(f" {split}/ : {jpg_n} 图, {cls_n} 类") + + print(f"\n{'='*70}") + + +if __name__ == "__main__": + main() diff --git a/CCPD_322K/README.md b/CCPD_322K/README.md new file mode 100644 index 0000000..dfcaae1 --- /dev/null +++ b/CCPD_322K/README.md @@ -0,0 +1,10 @@ +# CCPD 322K 合成原图 + +| 项目 | 详情 | +|---|---| +| 来源 | `chinese_license_plate_generator` + 随机背景合成 | +| 规模 | 322,134 张 | +| 生成脚本 | `scripts/pipeline_322k.py` 第一步 | +| 下游脚本 | 同脚本第二步 → `CCPD_322K_EXP_CUT/` | + +> **以脚本为准**:此目录为合成管线中间产物,不直接用于训练。最终训练使用 `CCPD_322K_EXP_CUT_TRANS/`。 diff --git a/CCPD_322K_EXP_CUT/README.md b/CCPD_322K_EXP_CUT/README.md new file mode 100644 index 0000000..c77de83 --- /dev/null +++ b/CCPD_322K_EXP_CUT/README.md @@ -0,0 +1,10 @@ +# CCPD 322K YOLO 裁剪 + +| 项目 | 详情 | +|---|---| +| 来源 | `CCPD_322K/` 经过 YOLO 车牌检测裁剪(12%/18% 外扩) | +| 规模 | 321,122 张 | +| 生成脚本 | `scripts/pipeline_322k.py` 第二步 | +| 下游脚本 | 同脚本第三步 → `CCPD_322K_EXP_CUT_TRANS/` | + +> **以脚本为准**:此目录为管线中间产物,不直接用于训练。 diff --git a/CCPD_322K_EXP_CUT_TRANS/README.md b/CCPD_322K_EXP_CUT_TRANS/README.md new file mode 100644 index 0000000..32085cb --- /dev/null +++ b/CCPD_322K_EXP_CUT_TRANS/README.md @@ -0,0 +1,11 @@ +# CCPD 322K 合成 TRANS + +| 项目 | 详情 | +|---|---| +| 来源 | `CCPD_322K_EXP_CUT/` 经过透视变换(仿射 warp 到 192×64) | +| 规模 | 305,970 张(11 类分子目录) | +| 生成脚本 | `scripts/pipeline_322k.py` 第三步 | +| 标签格式 | 文件名后缀(`parse_plate_suffix` 解析) | +| 训练用途 | OCR 三源混合训练数据源之一(合成域) | + +> **以脚本为准**:标签从文件名后缀解析,生成脚本中类别标记格式为 `_{color}_{label}_trans.jpg` 或 `__{type}__{label}_trans.jpg`。 diff --git a/CCPD_REAL_TRANS/README.md b/CCPD_REAL_TRANS/README.md new file mode 100644 index 0000000..44a91ba --- /dev/null +++ b/CCPD_REAL_TRANS/README.md @@ -0,0 +1,11 @@ +# CCPD 真图 TRANS + +| 项目 | 详情 | +|---|---| +| 来源 | 由 `CCPD/` 通过 GT 顶点透视变换生成 | +| 规模 | 296,838 张 | +| 生成脚本 | `scripts/sample_ccpd_full.py` | +| 标签格式 | 从原始 CCPD stem 编码解析(`parse_ccpd_filename`),**不依赖文件名后缀** | +| 训练用途 | OCR 三源混合训练数据源之一 | + +> **以脚本为准**:文件名后缀中的标签可能不准确,实际标签由 `dataset.py` 中的 `parse_plate_suffix` 从原始 CCPD stem 解码。2026-06-23 已修复 AO→澳 误替换 bug。 diff --git a/CCPD_Random_Blue150K_Green100K/README.md b/CCPD_Random_Blue150K_Green100K/README.md new file mode 100644 index 0000000..52c1370 --- /dev/null +++ b/CCPD_Random_Blue150K_Green100K/README.md @@ -0,0 +1,9 @@ +# CCPD Random 蓝 150K + 绿 100K + +| 项目 | 详情 | +|---|---| +| 来源 | `gen_random_bg.py` 大规模随机背景合成 | +| 规模 | 250,002 张 | +| 训练用途 | 未在 OCR 训练中使用 | + +> **以脚本为准**:此数据集为生成脚本的大规模产物,当前模型训练未引用。 diff --git a/CCPD_Random_Blue5K_Green5K/README.md b/CCPD_Random_Blue5K_Green5K/README.md new file mode 100644 index 0000000..9af4f27 --- /dev/null +++ b/CCPD_Random_Blue5K_Green5K/README.md @@ -0,0 +1,10 @@ +# CCPD Random 蓝 5K + 绿 5K + +| 项目 | 详情 | +|---|---| +| 来源 | `gen_random_bg.py` 随机背景合成 | +| 规模 | 10,006 张(蓝 5K + 绿 5K) | +| 下游 | `CCPD_Random_Blue5K_Green5K_EXP_CUT/` → `..._TRANS/` | +| 训练用途 | 早期 OCR 蓝绿新能源实验,已被 322K 管线替代 | + +> **以脚本为准**:`gen_random_bg.py` 生成原图,后续 EXP_CUT 和 TRANS 步骤同上。 diff --git a/CCPD_Random_Blue5K_Green5K/build_exp_cut.py b/CCPD_Random_Blue5K_Green5K/build_exp_cut.py new file mode 100644 index 0000000..fa8cc03 --- /dev/null +++ b/CCPD_Random_Blue5K_Green5K/build_exp_cut.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +CCPD_Random_Blue5K_Green5K → YOLOv5t 推理 → bbox 拓展 → 裁剪 → 角点重映射 +========================================================================== +输出: CCPD_Random_Blue5K_Green5K_EXP_CUT/ + +用法: + python build_exp_cut.py --full # 全量处理 + python build_exp_cut.py --n 100 # 预览 N 张 + python build_exp_cut.py --dry-run # 预览 (每类2张, 图上绘制角点) +""" + +import argparse +import os +import re +import sys +import time +from pathlib import Path + +import cv2 +import numpy as np +import torch +# Save original imdecode before ultralytics patches it +_orig_imdecode = cv2.imdecode +from ultralytics import YOLO + +SCRIPT_DIR = Path(__file__).resolve().parent +PROJ_ROOT = SCRIPT_DIR.parents[1] +MODEL_PATH = PROJ_ROOT / "ICE_NNv2" / "yolov5t" / "output" / "1_CCPD_19K" / "weights" / "best.pt" +SRC_DIR = SCRIPT_DIR +DST_DIR = PROJ_ROOT / "ICE_Dataset" / "CCPD_Random_Blue5K_Green5K_EXP_CUT" + +IMGSZ = 352 +CONF = 0.25 +CCPD_W, CCPD_H = 720, 1160 +EXPAND_NORMAL = 1.12 +EXPAND_SMALL = 1.18 +# bw < SMALL_BOX_W or bh < SMALL_BOX_H → small plate +SMALL_BOX_W = int(180 * CCPD_W / 1920) # ≈ 68 +SMALL_BOX_H = int(48 * CCPD_H / 1080) # ≈ 52 +MIN_PAD = 2 + +CORNER_RE = re.compile(r'-(\d+)&(\d+)_(\d+)&(\d+)_(\d+)&(\d+)_(\d+)&(\d+)-') + +# ---- Corner remap ---- +def parse_ccpd_corners(filename: str): + m = CORNER_RE.search(filename) + if not m: + return None + nums = [int(m.group(i)) for i in range(1, 9)] + return [(nums[0], nums[1]), (nums[2], nums[3]), (nums[4], nums[5]), (nums[6], nums[7])] + +def remap_corners_to_crop(corners, x1, y1, x2, y2): + crop_w, crop_h = x2 - x1, y2 - y1 + if crop_w <= 0 or crop_h <= 0: + return None + xs, ys = [c[0] for c in corners], [c[1] for c in corners] + poly_x1, poly_x2 = min(xs), max(xs) + poly_y1, poly_y2 = min(ys), max(ys) + inter_w = max(0, min(poly_x2, x2) - max(poly_x1, x1)) + inter_h = max(0, min(poly_y2, y2) - max(poly_y1, y1)) + poly_area = (poly_x2 - poly_x1) * (poly_y2 - poly_y1) + if poly_area <= 0 or (inter_w * inter_h) / poly_area <= 0.25: + return None + new = [] + for px, py in corners: + nx = max(0, min(crop_w - 1, int(round(px - x1)))) + ny = max(0, min(crop_h - 1, int(round(py - y1)))) + new.append((nx, ny)) + return new + +def replace_corners_in_filename(filename: str, new_corners): + seg = "-{0}&{1}_{2}&{3}_{4}&{5}_{6}&{7}-".format( + new_corners[0][0], new_corners[0][1], + new_corners[1][0], new_corners[1][1], + new_corners[2][0], new_corners[2][1], + new_corners[3][0], new_corners[3][1], + ) + return CORNER_RE.sub(seg, filename, count=1) + +# ---- Unicode-safe I/O ---- +def _imread(path: str): + with open(path, 'rb') as f: + data = f.read() + if not data: + raise ValueError(f"Empty file: {path}") + return _orig_imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR) + +def _imwrite(path: str, img: np.ndarray, params=None): + ext = os.path.splitext(path)[1] or '.jpg' + ok, buf = cv2.imencode(ext, img, params or []) + if ok: + with open(path, 'wb') as f: + f.write(buf.tobytes()) + return ok + +# ---- Single image ---- +def process_one(model, img_path: Path, out_img_path: Path, draw: bool = False): + img = _imread(str(img_path)) + if img is None: + return "miss" + orig_h, orig_w = img.shape[:2] + results = model.predict(str(img_path), imgsz=IMGSZ, verbose=False, conf=CONF, max_det=5) + boxes = results[0].boxes + if boxes is None or len(boxes) == 0: + return "miss" + xywh = boxes.xywh.cpu().numpy() + if len(xywh) > 1: + return "multi" + cx, cy, bw, bh = xywh[0] + expand = EXPAND_SMALL if (bw < SMALL_BOX_W or bh < SMALL_BOX_H) else EXPAND_NORMAL + pad_x = max(MIN_PAD, int(round(bw * (expand - 1.0) / 2.0))) + pad_y = max(MIN_PAD, int(round(bh * (expand - 1.0) / 2.0))) + x1 = max(int(cx - bw/2) - pad_x, 0) + y1 = max(int(cy - bh/2) - pad_y, 0) + x2 = min(int(cx + bw/2) + pad_x + 1, orig_w) + y2 = min(int(cy + bh/2) + pad_y + 1, orig_h) + if x2 - x1 <= 0 or y2 - y1 <= 0: + return "miss" + crop = img[y1:y2, x1:x2] + stem = img_path.stem + corners = parse_ccpd_corners(stem) + remapped = None + if corners is not None: + remapped = remap_corners_to_crop(corners, x1, y1, x2, y2) + new_stem = replace_corners_in_filename(stem, remapped) if remapped else stem + else: + new_stem = stem + if draw and remapped: + pts_arr = np.array(remapped, dtype=np.int32) + cv2.polylines(crop, [pts_arr], isClosed=True, color=(0,255,0), thickness=2) + for k, (px, py) in enumerate(remapped): + cv2.circle(crop, (px, py), 3, (0,0,255), -1) + out_img_path.parent.mkdir(parents=True, exist_ok=True) + _imwrite(str(out_img_path.with_stem(new_stem)), crop, [cv2.IMWRITE_JPEG_QUALITY, 92]) + return "cropped" if remapped else "cropped_nocorners" + +# ---- Main ---- +def main(): + parser = argparse.ArgumentParser() + g = parser.add_mutually_exclusive_group(required=True) + g.add_argument('--full', action='store_true') + g.add_argument('--n', type=int) + g.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + print(f"Model: {MODEL_PATH}") + device = 'cuda' if torch.cuda.is_available() else 'cpu' + model = YOLO(str(MODEL_PATH)) + model.to(device) + print(f"Device: {device}\n") + + classes = ['blue', 'green'] + draw = bool(args.dry_run) + + for cls in classes: + src_cls = SRC_DIR / cls + dst_cls = DST_DIR / cls + dst_cls.mkdir(parents=True, exist_ok=True) + images = sorted(src_cls.glob('*.jpg')) + if not images: + print(f"[WARN] {cls}: no images") + continue + + n = 2 if args.dry_run else (args.n or len(images)) + images = images[:n] + print(f"[{cls}] {len(images)} images...", flush=True) + + t0 = time.perf_counter() + stats = {'cropped': 0, 'cropped_nocorners': 0, 'miss': 0, 'multi': 0} + for img_path in images: + out = dst_cls / img_path.name + try: + r = process_one(model, img_path, out, draw=draw) + stats[r] = stats.get(r, 0) + 1 + except Exception as e: + print(f"\n [ERROR] {img_path.name}: {e}", flush=True) + stats['miss'] = stats.get('miss', 0) + 1 + elapsed = time.perf_counter() - t0 + + total = sum(stats.values()) + ok = stats['cropped'] + stats['cropped_nocorners'] + print(f" → ok:{ok}/{total} miss:{stats['miss']} multi:{stats['multi']} " + f"({elapsed:.1f}s, {total/elapsed:.1f} img/s)") + print(f"\nDone. Output: {DST_DIR}") + +if __name__ == '__main__': + main() diff --git a/CCPD_Random_Blue5K_Green5K/build_exp_cut_trans.py b/CCPD_Random_Blue5K_Green5K/build_exp_cut_trans.py new file mode 100644 index 0000000..fc18d88 --- /dev/null +++ b/CCPD_Random_Blue5K_Green5K/build_exp_cut_trans.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +""" +CCPD_Random_Blue5K_Green5K_EXP_CUT → ICE-COR 角点推理 → 中心外扩 → 透视变换 +================================================================================== +输出: CCPD_Random_Blue5K_Green5K_EXP_CUT_TRANS/ (192×64, 文件名追加 _trans) + +用法: + python build_exp_cut_trans.py --full + python build_exp_cut_trans.py --n 100 + python build_exp_cut_trans.py --dry-run +""" + +import argparse +import os +import sys +import time +from pathlib import Path + +import cv2 +import numpy as np +import torch + +SCRIPT_DIR = Path(__file__).resolve().parent +PROJ_ROOT = SCRIPT_DIR.parents[1] +ICE_COR_DIR = PROJ_ROOT / "ICE_NNv2" / "ICE-COR" +MODEL_PATH = ICE_COR_DIR / "output" / "1_CCPD_19K_EXP_CUT" / "best.pth" +SRC_DIR = PROJ_ROOT / "ICE_Dataset" / "CCPD_Random_Blue5K_Green5K_EXP_CUT" +DST_DIR = PROJ_ROOT / "ICE_Dataset" / "CCPD_Random_Blue5K_Green5K_EXP_CUT_TRANS" + +OUT_W, OUT_H = 192, 64 +PAD_X = 0.08 +PAD_Y = 0.05 + +if str(ICE_COR_DIR) not in sys.path: + sys.path.insert(0, str(ICE_COR_DIR)) +from corner_rectify import load_corner_model, predict_plate_corners, order_corners + +# ---- Unicode-safe I/O ---- +def _imread(path: str): + with open(path, 'rb') as f: + data = f.read() + return cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR) + +def _imwrite(path: str, img: np.ndarray, params=None): + ext = os.path.splitext(path)[1] or '.jpg' + ok, buf = cv2.imencode(ext, img, params or []) + if ok: + with open(path, 'wb') as f: + f.write(buf.tobytes()) + return ok + +# ---- Single image ---- +def process_one(model, device, img_path: Path, out_img_path: Path, draw: bool = False): + img = _imread(str(img_path)) + if img is None: + return "skip" + H, W = img.shape[:2] + try: + corners = predict_plate_corners(img, model, device) + if corners is None: + return "skip" + except Exception: + return "skip" + try: + ordered = order_corners(corners) + except Exception: + return "skip" + + # center expand + cx = ordered.mean(axis=0) + expanded = ordered + (ordered - cx) * np.array([PAD_X, PAD_Y]) + expanded[:, 0] = np.clip(expanded[:, 0], 0, W - 1) + expanded[:, 1] = np.clip(expanded[:, 1], 0, H - 1) + + src = expanded.astype(np.float32) + dst = np.float32([[0, 0], [OUT_W-1, 0], [OUT_W-1, OUT_H-1], [0, OUT_H-1]]) + M = cv2.getPerspectiveTransform(src, dst) + warped = cv2.warpPerspective(img, M, (OUT_W, OUT_H), + flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) + + if draw: + for k, (px, py) in enumerate(expanded): + cv2.circle(warped, tuple(dst[k].astype(int)), 3, (0, 255, 0), -1) + cv2.polylines(warped, [dst.astype(np.int32)], isClosed=True, color=(0, 255, 0), thickness=1) + + out_stem = img_path.stem + "_trans" + out_img_path.with_stem(out_stem).parent.mkdir(parents=True, exist_ok=True) + _imwrite(str(out_img_path.with_stem(out_stem)), warped, [cv2.IMWRITE_JPEG_QUALITY, 92]) + return "ok" + +# ---- Main ---- +def main(): + parser = argparse.ArgumentParser() + g = parser.add_mutually_exclusive_group(required=True) + g.add_argument('--full', action='store_true') + g.add_argument('--n', type=int) + g.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + + print(f"Model: {MODEL_PATH}") + device = 'cuda' if torch.cuda.is_available() else 'cpu' + model, device = load_corner_model(str(MODEL_PATH), device) + model.eval() + print(f"Device: {device}\n") + + classes = ['blue', 'green'] + draw = bool(args.dry_run) + + for cls in classes: + src_cls = SRC_DIR / cls + dst_cls = DST_DIR / cls + dst_cls.mkdir(parents=True, exist_ok=True) + images = sorted(src_cls.glob('*.jpg')) + if not images: + print(f"[WARN] {cls}: no images") + continue + + n = 2 if args.dry_run else (args.n or len(images)) + images = images[:n] + print(f"[{cls}] {len(images)} images...", flush=True) + + t0 = time.perf_counter() + ok = skip = 0 + for img_path in images: + out = dst_cls / img_path.name + r = process_one(model, device, img_path, out, draw=draw) + if r == "ok": ok += 1 + else: skip += 1 + elapsed = time.perf_counter() - t0 + total = ok + skip + print(f" → ok:{ok}/{total} skip:{skip} ({elapsed:.1f}s, {total/elapsed:.1f} img/s)") + print(f"\nDone. Output: {DST_DIR}") + +if __name__ == '__main__': + main() diff --git a/CCPD_SPE/README.md b/CCPD_SPE/README.md index e93d05a..b22f004 100644 --- a/CCPD_SPE/README.md +++ b/CCPD_SPE/README.md @@ -1,91 +1,10 @@ -# CCPD_SPE — 特殊车牌数据集 +# CCPD SPE 特殊车牌 -基于 CCPD 真实街景照片,将普通蓝牌替换为 9 种特殊车牌。 +| 项目 | 详情 | +|---|---| +| 来源 | `chinese_license_plate_generator` 合成特殊车牌(警/学/港澳/使/领/挂 等) | +| 规模 | 72,132 张 | +| 生成脚本 | `scripts/rebuild_spe.py`(及 `rebuild_spe.ps1`) | +| 训练用途 | 早期 OCR 实验,322K 管线已包含部分 SPE 类别 | -## 目录结构 - -``` -CCPD_SPE/ -├── ccpd_base/ -│ ├── police/ # 警车 (白底, 末位JING) -│ ├── army/ # 军车 (白底) -│ ├── gangao/ # 港澳入出境 (黑底, GANG=港 / AO=澳) -│ ├── shiguan_old/ # 使馆旧版 (黑底, SHI红字) -│ ├── shiguan_new/ # 使馆新版 (黑底) -│ ├── lingguan_old/ # 领馆旧版 (黑底, LING红字) -│ ├── lingguan_new/ # 领馆新版 (黑底) -│ ├── coach/ # 教练车 (黄底, 末位XUE) -│ └── new_energy/ # 新能源 (黄绿双拼, 8位) -├── ccpd_blur/ -├── ccpd_challenge/ -├── ccpd_fn/ -├── ccpd_rotate/ -├── ccpd_tilt/ -└── ccpd_weather/ -``` - -## 文件命名规则 - -``` -{CCPD原始文件名}__{特殊车牌类型}__{车牌号拼音}.jpg -``` - -### 字段说明 - -| 段 | 示例 | 说明 | -|----|------|------| -| CCPD原始文件名 | `003402777-94_83-366&419_...-123-6` | 保留 CCPD 全部几何标注(角点/bbox/亮度/模糊度) | -| 特殊车牌类型 | `gangao` | 9 种类型之一 | -| 车牌号拼音 | `B690TGANG` | 原车牌号,中文转拼音标注 | - -### 拼音标注表 - -| 中文 | 拼音 | 含义 | -|------|------|------| -| 港 | GANG | 港澳入出境—香港 | -| 澳 | AO | 港澳入出境—澳门 | -| 警 | JING | 警车 | -| 使 | SHI | 使馆 | -| 领 | LING | 领馆 | -| 学 | XUE | 教练车 | - -### 类型对照 - -| 目录名 | 底板色 | 说明 | -|--------|--------|------| -| `police` | 白底 | 警用车牌,末位"警"红色 | -| `army` | 白底 | 军车/武警 | -| `gangao` | 黑底 | 港澳入出境,粤Z·XXXX港/澳 | -| `shiguan_old` | 黑底 | 使馆旧版,"使"在首位红色 | -| `shiguan_new` | 黑底 | 使馆新版,XXX·XXX使 | -| `lingguan_old` | 黑底 | 领馆旧版,省A·XXXX领 | -| `lingguan_new` | 黑底 | 领馆新版,省XXX·XX领 | -| `coach` | 黄底 | 教练车,末位"学" | -| `new_energy` | 黄绿双拼 | 大型新能源,8位 | - -### 使用示例 - -```python -# 按类型筛选 -gangao_files = [f for f in glob('CCPD_SPE/*/gangao/*.jpg')] - -# 按港/澳筛选 -hk_files = [f for f in gangao_files if 'GANG' in f] -mo_files = [f for f in gangao_files if 'AO' in f and 'GANG' not in f] - -# 解析CCPD角点 -# CCPD文件名格式: area_tilt_bbox_corners_chars_brightness_blurriness -# corners段: TL_x&TL_y_TR_x&TR_y_BR_x&BR_y_BL_x&BL_y -``` - -## 生成参数 - -| 参数 | 值 | -|------|-----| -| 引擎 | C++ / OpenCV 4.10 | -| 线程 | 24 | -| 速度 | ~53 张/秒 | -| 含退化模拟 | JPEG+降采样+模糊 | -| 羽化 | 内侧 erode+GaussianBlur | -| 色温匹配 | 参考原车牌白字色温 | -| 噪声匹配 | 原车牌区域噪声σ估计 | +> **以脚本为准**:文件名编码包含 `__{type}__` 双下划线标记,类型在 `dataset.py` 的 `known_markers` 中定义。 diff --git a/CCPD_SPE_GEN/cpp_pipeline/build.ps1 b/CCPD_SPE_GEN/cpp_pipeline/build.ps1 index 2990dac..dfb0a5f 100644 --- a/CCPD_SPE_GEN/cpp_pipeline/build.ps1 +++ b/CCPD_SPE_GEN/cpp_pipeline/build.ps1 @@ -39,4 +39,4 @@ Write-Host "可执行文件: $buildDir\Release\plate_swap.exe" # 使用示例 Write-Host "" Write-Host "用法示例:" -Write-Host " .\build\Release\plate_swap.exe --ccpd-dir ./CCPD/CCPD2019/ccpd_base --plate-type police --plate-pool ./output/plate_pool --num 500 --workers 32 --fast" +Write-Host " .\build\Release\plate_swap.exe --ccpd-dir ./CCPD/CCPD2019/ccpd_base --plate-type police --plate-pool ./output/plate_pool --num 500 --workers 32" diff --git a/CCPD_SPE_GEN/cpp_pipeline/gen_plate_pool.py b/CCPD_SPE_GEN/cpp_pipeline/gen_plate_pool.py index ec5702e..af928a8 100644 --- a/CCPD_SPE_GEN/cpp_pipeline/gen_plate_pool.py +++ b/CCPD_SPE_GEN/cpp_pipeline/gen_plate_pool.py @@ -49,6 +49,8 @@ from plate_number import ( # 类型配置 PLATE_TYPES = [ + ('blue', 'blue', generate_plate_number_blue), + ('green', 'green_car', lambda: generate_plate_number_blue(length=8)), ('police', 'white', generate_plate_number_white), ('army', 'white_army', generate_plate_number_army), ('gangao', 'black', generate_plate_number_black_gangao), diff --git a/CCPD_SPE_GEN/cpp_pipeline/main.cpp b/CCPD_SPE_GEN/cpp_pipeline/main.cpp index b73e3ae..672ac82 100644 --- a/CCPD_SPE_GEN/cpp_pipeline/main.cpp +++ b/CCPD_SPE_GEN/cpp_pipeline/main.cpp @@ -18,7 +18,6 @@ struct Config { string plate_pool_dir; int num_images = 500; unsigned workers = thread::hardware_concurrency(); - bool fast = false; int seed = 42; int h_shift = 0, s_scale = 100, v_scale = 100; }; @@ -50,7 +49,6 @@ Config parse_args(int argc, char* argv[]) { else if (arg == "--num" && i+1 < argc) cfg.num_images = stoi(argv[++i]); else if (arg == "--workers" && i+1 < argc) cfg.workers = stoi(argv[++i]); else if (arg == "--seed" && i+1 < argc) cfg.seed = stoi(argv[++i]); - else if (arg == "--fast") cfg.fast = true; else if (arg == "--h-shift" && i+1 < argc) cfg.h_shift = stoi(argv[++i]); else if (arg == "--s-scale" && i+1 < argc) cfg.s_scale = stoi(argv[++i]); else if (arg == "--v-scale" && i+1 < argc) cfg.v_scale = stoi(argv[++i]); @@ -103,7 +101,6 @@ int main(int argc, char* argv[]) { cerr << " --plate-pool Plate pool directory (required)" << endl; cerr << " --num Number of images (default 500)" << endl; cerr << " --workers Thread count (default all cores)" << endl; - cerr << " --fast Fast mode (skip degradation)" << endl; return 1; } @@ -111,7 +108,6 @@ int main(int argc, char* argv[]) { cout << "Type: " << cfg.plate_type << endl; cout << "Threads: " << cfg.workers << endl; cout << "Target: " << cfg.num_images << " images" << endl; - cout << "Fast: " << (cfg.fast ? "yes" : "no") << endl; if (cfg.plate_pool_dir.empty()) { cerr << "[ERROR] --plate-pool is required" << endl; @@ -153,7 +149,6 @@ int main(int argc, char* argv[]) { SwapConfig swap_cfg; swap_cfg.plate_type = cfg.plate_type; swap_cfg.output_dir = cfg.output_dir; - swap_cfg.fast_mode = cfg.fast; swap_cfg.h_shift = cfg.h_shift; swap_cfg.s_scale = cfg.s_scale; swap_cfg.v_scale = cfg.v_scale; diff --git a/CCPD_SPE_GEN/cpp_pipeline/plate_swap.cpp b/CCPD_SPE_GEN/cpp_pipeline/plate_swap.cpp index 18676d7..05d9137 100644 --- a/CCPD_SPE_GEN/cpp_pipeline/plate_swap.cpp +++ b/CCPD_SPE_GEN/cpp_pipeline/plate_swap.cpp @@ -184,6 +184,8 @@ static string bg_color_of(const string& plate_type) { if (plate_type == "lingguan_old" || plate_type == "lingguan_new") return "black"; if (plate_type == "coach") return "yellow"; if (plate_type == "new_energy") return "green_truck"; + if (plate_type == "blue") return "blue"; + if (plate_type == "green") return "green_car"; return "white"; } @@ -322,7 +324,7 @@ Mat match_plate_color(const Mat& synthetic, const Mat& orig_roi, // 白/绿牌 Scalar color_cast(white_ref[0]/ref_mean, white_ref[1]/ref_mean, white_ref[2]/ref_mean); - float strength = (bg_color == "green_truck") ? 1.5f : 1.0f; + float strength = (bg_color == "green_truck" || bg_color == "green_car") ? 1.5f : 1.0f; Scalar amplified(1.0f + (color_cast[0]-1.0f)*strength, 1.0f+(color_cast[1]-1.0f)*strength, 1.0f+(color_cast[2]-1.0f)*strength); amplified[0] = std::clamp((float)amplified[0], 0.85f, 1.15f); amplified[1] = std::clamp((float)amplified[1], 0.85f, 1.15f); @@ -359,7 +361,8 @@ Mat fix_bg_color(const Mat& plate, const string& bg_color, int h_lo = 0, h_hi = 180; if (bg_color == "yellow") { h_lo = 15; h_hi = 50; } - else if (bg_color == "green_truck") { h_lo = 40; h_hi = 85; } + else if (bg_color == "green_truck" || bg_color == "green_car") { h_lo = 40; h_hi = 85; } + else if (bg_color == "blue") { h_lo = 90; h_hi = 130; } Mat hsv; cvtColor(plate, hsv, COLOR_BGR2HSV); @@ -629,7 +632,7 @@ bool process_single(const string& img_path, const SwapConfig& cfg, if (current_w > 10 && abs(target_w - current_w) > 10) { float l_ratio = target_w / current_w; float min_c = 0.5f, max_c = 1.1f; - if (bg_color == "green_truck" || bg_color == "yellow") { min_c = 0.4f; max_c = 1.0f; } + if (bg_color == "green_truck" || bg_color == "green_car" || bg_color == "yellow") { min_c = 0.4f; max_c = 1.0f; } else if (bg_color == "black" || bg_color == "black_shi") { min_c = 0.3f; max_c = 1.2f; } l_ratio = std::clamp(l_ratio, min_c, max_c); @@ -650,38 +653,16 @@ bool process_single(const string& img_path, const SwapConfig& cfg, } // 8. Degradation - if (!cfg.fast_mode) - matched = degrade_plate(matched); + matched = degrade_plate(matched); // 9-11. Warp + blend + noise auto [warped, mask] = warp_plate_back(matched, meta.corners, Size(w_img, h_img)); Mat blended = feather_blend(img_orig, warped, mask); Mat final = match_noise(blended, img_orig, mask); - // 12. Save with pinyin tags + // 12. Save with original Chinese plate number (imwrite_unicode supports UTF-8) string stem = fp.stem().string(); - // Convert Chinese plate markers to ASCII tags - string tag = plate_number; - // Manual pinyin mapping for common plate characters (UTF-8 byte sequences) - // 港=E6B8AF 澳=E6BEB3 警=E8ADA6 使=E4BDBF 领=E9A286 学=E5ADA6 - struct { const char* utf8; const char* py; } map[] = { - {"\xE6\xB8\xAF", "GANG"}, {"\xE6\xBE\xB3", "AO"}, - {"\xE8\xAD\xA6", "JING"}, {"\xE4\xBD\xBF", "SHI"}, - {"\xE9\xA2\x86", "LING"}, {"\xE5\xAD\xA6", "XUE"}, - }; - for (auto& m : map) { - size_t pos = 0; - while ((pos = tag.find(m.utf8, pos)) != string::npos) { - tag.replace(pos, 3, m.py); - pos += strlen(m.py); - } - } - // Remove any remaining non-ASCII - string clean; - for (char c : tag) { - if ((unsigned char)c < 128) clean += c; - } - string out_name = stem + "__" + cfg.plate_type + "__" + clean + ".jpg"; + string out_name = stem + "__" + cfg.plate_type + "__" + plate_number + ".jpg"; string ccpd_subdir = fp.parent_path().filename().string(); fs::path out_dir = fs::path(cfg.output_dir) / ccpd_subdir / cfg.plate_type; fs::create_directories(out_dir); diff --git a/CCPD_SPE_GEN/cpp_pipeline/plate_swap.hpp b/CCPD_SPE_GEN/cpp_pipeline/plate_swap.hpp index b5d9f64..f3178cc 100644 --- a/CCPD_SPE_GEN/cpp_pipeline/plate_swap.hpp +++ b/CCPD_SPE_GEN/cpp_pipeline/plate_swap.hpp @@ -34,7 +34,6 @@ struct SwapConfig { std::string plate_type; std::string output_dir; int h_shift = 0, s_scale = 100, v_scale = 100; - bool fast_mode = false; }; // 单张处理 (pre-parsed meta 避免重复解析) diff --git a/CCPD_SPE_GEN/cv_pipeline/color_tuner.py b/CCPD_SPE_GEN/cv_pipeline/color_tuner.py index a781601..12af7f6 100644 --- a/CCPD_SPE_GEN/cv_pipeline/color_tuner.py +++ b/CCPD_SPE_GEN/cv_pipeline/color_tuner.py @@ -45,8 +45,10 @@ from plate_number import ( # ── 车牌类型 ── PLATE_TYPES = [ + ('blue', '蓝牌', generate_plate_number_blue), + ('green_car', '小新能源', lambda: generate_plate_number_blue(length=8)), ('yellow', '教练车', generate_plate_number_yellow_xue), - ('green_truck','新能源', lambda: generate_plate_number_blue(length=8)), + ('green_truck','大新能源', lambda: generate_plate_number_blue(length=8)), ('white', '警车', generate_plate_number_white), ('white_army', '军车', generate_plate_number_army), ('black', '港澳', generate_plate_number_black_gangao), @@ -113,7 +115,8 @@ def adjust_plate(plate_bgr, bg_color, h_shift, s_scale, v_scale): return plate_bgr.copy() hue_ranges = { - 'yellow': (15, 50), 'green_truck': (40, 85), + 'yellow': (15, 50), 'green_truck': (40, 85), 'green_car': (40, 85), + 'blue': (90, 130), 'white': (0, 180), 'white_army': (0, 180), 'black': (0, 180), 'black_shi': (0, 180), } @@ -182,7 +185,7 @@ def update_display(*_args): (w + 10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 200), 1) # 类型标签 - cv2.putText(display, f"[{current_type_idx+1}/9] {label}", + cv2.putText(display, f"[{current_type_idx+1}/{len(PLATE_TYPES)}] {label}", (10, display.shape[0] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (180, 180, 180), 1) @@ -206,7 +209,8 @@ def save_params(): '# Format: (h_shift, s_scale_percent, v_scale_percent)', '', 'PLATE_COLOR_PROFILE = {'] - for bg_color in ['yellow', 'green_truck', 'white', 'white_army', 'black', 'black_shi']: + for bg_color in ['yellow', 'green_truck', 'green_car', 'blue', + 'white', 'white_army', 'black', 'black_shi']: p = params.get(bg_color, {'h_shift': 0, 's_scale': 100, 'v_scale': 100}) lines.append(f" '{bg_color}': ({p['h_shift']}, {p['s_scale']}, {p['v_scale']}),") lines.append('}') @@ -219,19 +223,26 @@ def save_params(): def on_key(key): global current_type_idx, synthetic_plate + # keys: 1-9, 0(=10), -(=11) — 切换车牌类型 + idx = -1 if ord('1') <= key <= ord('9'): idx = key - ord('1') - if idx < len(PLATE_TYPES): - current_type_idx = idx - # 切换类型时复位 trackbar 到该类型已保存的参数 - bg_color = PLATE_TYPES[current_type_idx][0] - p = load_params(bg_color) - cv2.setTrackbarPos('H offset', 'Color Tuner', p['h_shift'] + 30) - cv2.setTrackbarPos('S scale', 'Color Tuner', p['s_scale']) - cv2.setTrackbarPos('V scale', 'Color Tuner', p['v_scale']) - # 生成新车牌 - synthetic_plate, _, _ = generate_plate(current_type_idx) - update_display() + elif key == ord('0'): + idx = 9 + elif key == ord('-'): + idx = 10 + + if 0 <= idx < len(PLATE_TYPES): + current_type_idx = idx + # 切换类型时复位 trackbar 到该类型已保存的参数 + bg_color = PLATE_TYPES[current_type_idx][0] + p = load_params(bg_color) + cv2.setTrackbarPos('H offset', 'Color Tuner', p['h_shift'] + 30) + cv2.setTrackbarPos('S scale', 'Color Tuner', p['s_scale']) + cv2.setTrackbarPos('V scale', 'Color Tuner', p['v_scale']) + # 生成新车牌 + synthetic_plate, _, _ = generate_plate(current_type_idx) + update_display() elif key == ord('r') or key == ord('R'): bg_color = PLATE_TYPES[current_type_idx][0] params[bg_color] = {'h_shift': 0, 's_scale': 100, 'v_scale': 100} diff --git a/CCPD_SPE_GEN/cv_pipeline/plate_color_profile.py b/CCPD_SPE_GEN/cv_pipeline/plate_color_profile.py index 17fe241..1bba3f0 100644 --- a/CCPD_SPE_GEN/cv_pipeline/plate_color_profile.py +++ b/CCPD_SPE_GEN/cv_pipeline/plate_color_profile.py @@ -3,8 +3,10 @@ # Format: (h_shift, s_scale_percent, v_scale_percent) PLATE_COLOR_PROFILE = { - 'yellow': (-2, 150, 100), - 'green_truck': (0, 100, 100), + 'yellow': (0, 150, 150), + 'green_truck': (0, 100, 150), + 'green_car': (-4, 69, 92), + 'blue': (-7, 150, 150), 'white': (0, 100, 100), 'white_army': (0, 100, 100), 'black': (0, 100, 100), diff --git a/CCPD_SPE_GEN/cv_pipeline/plate_swap.py b/CCPD_SPE_GEN/cv_pipeline/plate_swap.py index 40f024b..df75d9f 100644 --- a/CCPD_SPE_GEN/cv_pipeline/plate_swap.py +++ b/CCPD_SPE_GEN/cv_pipeline/plate_swap.py @@ -156,6 +156,16 @@ PLATE_TYPE_CONFIG = { 'generator': lambda: generate_plate_number_blue(length=8), 'description': '大型新能源' }, + 'blue': { + 'bg_color': 'blue', + 'generator': generate_plate_number_blue, + 'description': '蓝牌小型汽车' + }, + 'green': { + 'bg_color': 'green_car', + 'generator': lambda: generate_plate_number_blue(length=8), + 'description': '小型新能源' + }, } @@ -407,7 +417,7 @@ def match_plate_color(synthetic_plate: np.ndarray, color_cast = white_ref / ref_mean strength_map = { 'white': 1.0, 'white_army': 1.0, - 'green_truck': 1.5, + 'blue': 1.0, 'green_car': 1.5, 'green_truck': 1.5, } strength = strength_map.get(bg_color, 2.0) @@ -492,7 +502,8 @@ def fix_plate_bg_color(plate_bgr: np.ndarray, bg_color: str) -> np.ndarray: # 色相范围 hue_ranges = { - 'yellow': (15, 50), 'green_truck': (40, 85), + 'yellow': (15, 50), 'green_truck': (40, 85), 'green_car': (40, 85), + 'blue': (90, 130), 'white': (0, 180), 'white_army': (0, 180), 'black': (0, 180), 'black_shi': (0, 180), } @@ -741,11 +752,10 @@ def match_image_quality(result: np.ndarray, _worker_pipeline = None -def _worker_init(plate_type, fast): +def _worker_init(plate_type): """进程初始化:每个 worker 启动时调用一次(加载字体、底板缓存)""" global _worker_pipeline _worker_pipeline = PlateSwapPipeline(plate_type=plate_type) - _worker_pipeline._fast_mode = fast def _worker_process_single(args): @@ -791,9 +801,6 @@ class PlateSwapPipeline: font_dir = str(_GENERATOR_DIR / 'font_model') self.generator = MultiPlateGenerator(plate_model_dir, font_dir) - # 快速模式标志(多进程时由 _worker_process_single 设置) - self._fast_mode = False - def generate_plate_number(self) -> str: """生成随机特殊车牌号码""" return self.number_generator() @@ -864,7 +871,8 @@ class PlateSwapPipeline: # 不同底板色用不同下限,防止白底变灰、绿牌变亮 clip_cfg = { 'white': (0.5, 1.1), 'white_army': (0.5, 1.1), - 'green_truck': (0.4, 1.0), + 'blue': (0.5, 1.1), + 'green_truck': (0.4, 1.0), 'green_car': (0.4, 1.0), 'yellow': (0.4, 1.0), 'black': (0.3, 1.2), 'black_shi': (0.3, 1.2), } @@ -879,16 +887,15 @@ class PlateSwapPipeline: matched_plate = (matched_plate.astype(np.float32) * effective_ratio) matched_plate = np.clip(matched_plate, 0, 255).astype(np.uint8) - # ---- 8. 退化模拟(快速模式下跳过) ---- - if not self._fast_mode: - # JPEG 极限压缩 - matched_plate = jpeg_compress(matched_plate, quality=2) - # 降采样→升采样 模拟马赛克/像素化 - h_p, w_p = matched_plate.shape[:2] - small = cv2.resize(matched_plate, (w_p // 4, h_p // 4), interpolation=cv2.INTER_LINEAR) - matched_plate = cv2.resize(small, (w_p, h_p), interpolation=cv2.INTER_NEAREST) - # 较强模糊 - matched_plate = cv2.GaussianBlur(matched_plate, (5, 5), 1.5) + # ---- 8. 退化模拟 ---- + # JPEG 极限压缩 + matched_plate = jpeg_compress(matched_plate, quality=2) + # 降采样→升采样 模拟马赛克/像素化 + h_p, w_p = matched_plate.shape[:2] + small = cv2.resize(matched_plate, (w_p // 4, h_p // 4), interpolation=cv2.INTER_LINEAR) + matched_plate = cv2.resize(small, (w_p, h_p), interpolation=cv2.INTER_NEAREST) + # 较强模糊 + matched_plate = cv2.GaussianBlur(matched_plate, (5, 5), 1.5) # ---- 9. 透视变换回贴 ---- warped, plate_mask = warp_plate_back( @@ -903,7 +910,7 @@ class PlateSwapPipeline: # ---- 12. 保存 ---- basename = Path(filename).stem - out_name = f"{basename}_{self.plate_type}_{new_plate_number}.jpg" + out_name = f"{basename}__{self.plate_type}__{new_plate_number}.jpg" out_path = os.path.join(output_dir, out_name) _imwrite_unicode(out_path, final, [cv2.IMWRITE_JPEG_QUALITY, 95]) @@ -927,8 +934,7 @@ class PlateSwapPipeline: num_images: int = 500, preview: bool = False, seed: int = 42, - workers: int = 1, - fast: bool = False): + workers: int = 1): """ 批量处理 CCPD 图片目录。 @@ -939,7 +945,6 @@ class PlateSwapPipeline: preview: 是否保存调试中间结果 seed: 随机种子 workers: 并行进程数 (1=单进程, >1=多进程) - fast: 快速模式 (跳过退化模拟步骤) """ random.seed(seed) np.random.seed(seed) @@ -969,8 +974,6 @@ class PlateSwapPipeline: print(f"目标生成数: {num_images}") print(f"输出目录: {output_dir}") print(f"并行进程: {workers}" + (" (多核加速)" if workers > 1 else "")) - if fast: - print(f"快速模式: 跳过退化模拟") print("-" * 60) import time @@ -984,7 +987,7 @@ class PlateSwapPipeline: else: # 多进程模式 success, failed = self._process_parallel( - image_paths, output_dir, num_images, preview, workers, fast + image_paths, output_dir, num_images, preview, workers ) elapsed = time.perf_counter() - t0 @@ -1013,7 +1016,7 @@ class PlateSwapPipeline: return success, failed def _process_parallel(self, image_paths, output_dir, num_images, - preview, workers, fast): + preview, workers): """多进程并行处理(worker 复用 Pipeline,仅初始化一次)""" from multiprocessing import Pool, cpu_count n_procs = min(workers, cpu_count()) @@ -1022,7 +1025,7 @@ class PlateSwapPipeline: # 每个 worker 启动时加载一次 Pipeline(含字体、底板缓存) with Pool(n_procs, initializer=_worker_init, - initargs=(self.plate_type, fast)) as pool: + initargs=(self.plate_type,)) as pool: # 参数精简: (img_path, output_dir, preview_bool) args = [ (p, output_dir, (preview and i < 10)) @@ -1074,9 +1077,6 @@ def main(): help='随机种子 (默认: 42)') parser.add_argument('--workers', type=int, default=1, help='并行进程数 (默认: 1, 建议设为CPU核心数)') - parser.add_argument('--fast', action='store_true', - help='快速模式:跳过退化模拟,速度提升约40%%') - args = parser.parse_args() ccpd_dirs = [d.strip() for d in args.ccpd_dir.split(',') if d.strip()] @@ -1088,7 +1088,6 @@ def main(): preview=args.preview, seed=args.seed, workers=args.workers, - fast=args.fast, ) diff --git a/ccpd_combined_5k5k_spe/README.md b/ccpd_combined_5k5k_spe/README.md new file mode 100644 index 0000000..26cbd90 --- /dev/null +++ b/ccpd_combined_5k5k_spe/README.md @@ -0,0 +1,10 @@ +# CCPD Combined 5K+5K+SPE + +| 项目 | 详情 | +|---|---| +| 来源 | 混合 Blue 5K + Green 5K + SPE 特殊车牌 | +| 规模 | 37,907 张 | +| 生成脚本 | `scripts/build_ccpd_combined.py` | +| 训练用途 | YOLO 检测模型训练 | + +> **以脚本为准**:`build_ccpd_combined.py` 负责从多个源目录合并并转换标注格式。 diff --git a/ccpd_combined_5k5k_spe/dataset.yaml b/ccpd_combined_5k5k_spe/dataset.yaml new file mode 100644 index 0000000..89e38ca --- /dev/null +++ b/ccpd_combined_5k5k_spe/dataset.yaml @@ -0,0 +1,10 @@ +# CCPD Combined Dataset (single class: license_plate) +# CCPD2019 5k + CCPD2020 5k + CCPD_SPE all = 19000 images + +path: C:/Users/Kirl/Desktop/ICEv2/ICE_Dataset/ccpd_combined_5k5k_spe +train: images/train +val: images/val + +nc: 1 +names: + 0: license_plate diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..a0597c4 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,26 @@ +# 数据生成脚本 + +此目录包含所有数据集生成脚本和少量测试样本。 + +## 脚本清单 + +| 脚本 | 输入 | 输出 | 用途 | +|------|------|------|------| +| `pipeline_322k.py` | `CCPD_322K/` | `CCPD_322K_EXP_CUT_TRANS/` | 322K 合成全管线(3 步) | +| `sample_ccpd_full.py` | `CCPD/` | `CCPD_REAL_TRANS/` | CCPD 真图全量 GT-warp | +| `sample_ccpd_real.py` | `CCPD/` | `CCPD_REAL_TRANS/` | CCPD 真图采样(实验用) | +| `build_cblprd_trans.py` | CBLPRD zip | `CBLPRD_TRANS/` | CBLPRD COR 矫正 TRANS | +| `build_ccpd_combined.py` | 多源目录 | `ccpd_combined_5k5k_spe/` | 合并数据集 | +| `rebuild_spe.py` | — | `CCPD_SPE/` | SPE 特殊车牌生成 | +| `gen_random_bg.py` | — | `CCPD_Random_*` | 随机背景蓝绿合成 | + +## 目录结构 + +``` +scripts/ +├── *.py, *.ps1 ← 生成脚本(权威来源) +├── train/ ← 空(数据已移至 CCPD_322K_EXP_CUT_TRANS/) +└── test/ ← 34004 张测试样本 +``` + +> **以脚本为准**:所有数据集的生成逻辑、标签格式、预处理参数均以本目录中的脚本为唯一权威来源。文件名后缀标签的解析规则见 `ICE_NNv2/ICE-OCR/script/tools/dataset.py`。 diff --git a/scripts/build_cblprd_trans.py b/scripts/build_cblprd_trans.py new file mode 100644 index 0000000..15e8f07 --- /dev/null +++ b/scripts/build_cblprd_trans.py @@ -0,0 +1,138 @@ +""" +CBLPRD → CCPD_TRANS 数据集构建 (ICE-COR 矫正) +输出: ICE_Dataset/CBLPRD_TRANS/train/ +""" +import sys, os, cv2, numpy as np, torch +from pathlib import Path +import zipfile +from concurrent.futures import ThreadPoolExecutor, as_completed +from threading import Lock + +ICEv2 = Path(r"C:\Users\Kirl\Desktop\ICEv2") +COR_DIR = ICEv2 / "ICE_NNv2/ICE-COR" +MODEL_COR = COR_DIR / "output/1_CCPD_19K_EXP_CUT/best.pth" +ZIP_PATH = r"C:\Users\Kirl\Downloads\CBLPRD-330k_v1.zip" +OUT_DIR = ICEv2 / "ICE_Dataset/CBLPRD_TRANS/train" + +sys.path.insert(0, str(COR_DIR)) +from corner_rectify import load_corner_model, predict_plate_corners, order_corners + +# 类型→文件名标记 +TYPE_MARKER = { + '普通蓝牌': 'blue', + '新能源小型车': 'green', + '新能源大型车': 'new_energy', + '单层黄牌': 'yellow', + '黑色车牌': 'black', +} +SUPPORTED = set(TYPE_MARKER.keys()) + +print(f"COR: {MODEL_COR}") +dev = torch.device("cuda" if torch.cuda.is_available() else "cpu") +cor_model, _ = load_corner_model(str(MODEL_COR), dev) + +print(f"Output: {OUT_DIR}") +OUT_DIR.mkdir(parents=True, exist_ok=True) + +# 读取 CBLPRD train.txt +zf = zipfile.ZipFile(ZIP_PATH, 'r') +labels = [] +for line in zf.read('train.txt').decode('utf-8').strip().split('\n'): + parts = line.strip().split(' ') + if len(parts) < 3 or parts[2] not in SUPPORTED: + continue + fname = parts[0].split('/')[-1] + labels.append((fname, parts[1], parts[2])) + +# 同时加入 val.txt (合并训练) +for line in zf.read('val.txt').decode('utf-8').strip().split('\n'): + parts = line.strip().split(' ') + if len(parts) < 3 or parts[2] not in SUPPORTED: + continue + fname = parts[0].split('/')[-1] + labels.append((fname, parts[1], parts[2])) + +print(f"Total labels: {len(labels)}") +from collections import Counter +for t, c in Counter(l[2] for l in labels).most_common(): + print(f" {t}: {c}") + +# Build name→zip_path index +name_idx = {} +for n in zf.namelist(): + if n.endswith('.jpg'): + name_idx[n.split('/')[-1]] = n +print(f"Zip index: {len(name_idx)} images") + +# 统计已完成的 +existing = set(f.stem.split('_trans')[0] if '_trans' in f.stem else '' for f in OUT_DIR.glob('*.jpg')) +print(f"Already done: {len(existing)}") + +# 过滤已完成 (按原始文件名前缀) +pending = [] +for fname, plate, ptype in labels: + if fname in name_idx and fname.rsplit('.',1)[0] not in existing: + pending.append((fname, plate, ptype, name_idx[fname])) + +print(f"Pending: {len(pending)}") + +lock = Lock() +count = [0] +total = len(pending) + +def process_one(item): + fname, plate, ptype, zip_path = item + try: + data = zf.read(zip_path) + img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR) + if img is None: + return f"READ_ERR:{fname}" + + # ICE-COR + corners = predict_plate_corners(img, cor_model, dev) + ordered = order_corners(corners) + OW, OH = 192, 64 + cx, cy = ordered.mean(axis=0) + exp = ordered + (ordered - np.array([[cx, cy]])) * np.array([0.08, 0.05]) + exp[:, 0] = np.clip(exp[:, 0], 0, img.shape[1] - 1) + exp[:, 1] = np.clip(exp[:, 1], 0, img.shape[0] - 1) + dst = np.float32([[0, 0], [OW - 1, 0], [OW - 1, OH - 1], [0, OH - 1]]) + M = cv2.getPerspectiveTransform(exp.astype(np.float32), dst) + warped = cv2.warpPerspective(img, M, (OW, OH)) + except Exception as e: + return f"COR_ERR:{fname}:{e}" + + # 文件名: {原始名}_{marker}_{plate}_trans.jpg + marker = TYPE_MARKER[ptype] + stem = fname.rsplit('.', 1)[0] + out_name = f"{stem}_{marker}_{plate}_trans.jpg" + out_path = OUT_DIR / out_name + + # Unicode safe write + ret, enc = cv2.imencode('.jpg', warped, [cv2.IMWRITE_JPEG_QUALITY, 95]) + if ret: + with open(str(out_path), 'wb') as f: + f.write(enc.tobytes()) + else: + return f"WRITE_ERR:{fname}" + + with lock: + count[0] += 1 + if count[0] % 500 == 0: + print(f" [{count[0]}/{total}] {fname} -> {marker}:{plate}") + return f"OK:{fname}" + +# 多线程处理 +WORKERS = 8 +print(f"\nProcessing {total} images with {WORKERS} workers...") +with ThreadPoolExecutor(max_workers=WORKERS) as executor: + futures = {executor.submit(process_one, item): item for item in pending} + for future in as_completed(futures): + result = future.result() + if result.startswith("READ_ERR") or result.startswith("WRITE_ERR"): + print(f" [!] {result}") + +zf.close() +print(f"\nDone! Total saved: {count[0]}/{total}") +print(f"Output: {OUT_DIR}") +print(f"Files: {len(list(OUT_DIR.glob('*.jpg')))}") diff --git a/scripts/gen_random_bg.py b/scripts/gen_random_bg.py new file mode 100644 index 0000000..8313a67 --- /dev/null +++ b/scripts/gen_random_bg.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +全量生成 Random 蓝绿车牌 +blue 150K + green 100K, 覆盖全部 CCPD 背景 +""" + +import os, sys, time +from pathlib import Path + +ICE_DATASET = Path(r'C:\Users\Kirl\Desktop\ICEv2\ICE_Dataset') +os.chdir(str(ICE_DATASET)) + +sys.path.insert(0, str(ICE_DATASET / 'CCPD_SPE_GEN' / 'cv_pipeline')) +from plate_swap import PlateSwapPipeline + +CCPD2019 = ICE_DATASET / 'CCPD' / 'CCPD2019' +CCPD2020 = ICE_DATASET / 'CCPD' / 'CCPD2020' +OUT = ICE_DATASET / 'CCPD_Random_Blue150K_Green100K' + +# 全部可用 CCPD 子集 (含全量, 不限于2019) +CCPD_DIRS = [ + str(CCPD2019 / 'ccpd_base'), + str(CCPD2019 / 'ccpd_blur'), + str(CCPD2019 / 'ccpd_challenge'), + str(CCPD2019 / 'ccpd_fn'), + str(CCPD2019 / 'ccpd_rotate'), + str(CCPD2019 / 'ccpd_tilt'), + str(CCPD2019 / 'ccpd_weather'), +] + +# 只保留实际存在的 +CCPD_DIRS = [d for d in CCPD_DIRS if Path(d).is_dir()] +print(f"CCPD 背景目录: {len(CCPD_DIRS)}") + +SEED = 42 +WORKERS = 24 + +def main(): + import shutil + # 断点续传: 不删除已有数据 + total_start = time.time() + + for plate_type, target in [('blue', 150000), ('green', 100000)]: + out_dir = OUT / plate_type + out_dir.mkdir(parents=True, exist_ok=True) + existing = len(list(out_dir.glob('*.jpg'))) + if existing >= target: + print(f"\n[{plate_type}] 已有 {existing} >= {target}, 跳过") + continue + need = target - existing + print(f"\n{'='*60}") + print(f"[{plate_type}] 已有 {existing}, 需补 {need} → 目标 {target}") + print(f"{'='*60}") + + pipeline = PlateSwapPipeline(plate_type=plate_type) + + print(f" 生成中...", end=' ', flush=True) + t0 = time.time() + pipeline.process_batch( + ccpd_dirs=CCPD_DIRS, + output_dir=str(out_dir), + num_images=need, + preview=False, + seed=SEED, + workers=WORKERS, + ) + elapsed = time.time() - t0 + print(f"完成 ({elapsed:.0f}s, {need/elapsed:.0f} 张/s)") + + total_t = time.time() - total_start + blue_final = len(list((OUT / 'blue').glob('*.jpg'))) + green_final = len(list((OUT / 'green').glob('*.jpg'))) + print(f"\nDONE! blue={blue_final} green={green_final}, {total_t/60:.1f}min") + +if __name__ == '__main__': + main() diff --git a/scripts/orchestrator_322k.ps1 b/scripts/orchestrator_322k.ps1 new file mode 100644 index 0000000..bb15d0b --- /dev/null +++ b/scripts/orchestrator_322k.ps1 @@ -0,0 +1,38 @@ +# 322K 全自动: 等生成完 → 构建 → EXP_CUT → TRANS → 训练 +# 用法: 生成进程跑完后执行此脚本 +$ErrorActionPreference = "Continue" + +$PY = "C:/Program Files/Python313/python.exe" +$ICE = "C:\Users\Kirl\Desktop\ICEv2\ICE_Dataset" + +Write-Host "===== 322K Pipeline =====" -ForegroundColor Cyan +Write-Host "Waiting for SPE + Random generation to complete..." +Write-Host "SPE dir: $ICE\CCPD_SPE\ccpd_base" +Write-Host "Random dir: $ICE\CCPD_Random_Blue150K_Green100K" + +# Wait for SPE (9 types x 8000) +$spe_types = @('police','army','gangao','shiguan_old','shiguan_new','lingguan_old','lingguan_new','coach','new_energy') +do { + $done = $true + foreach ($t in $spe_types) { + $c = @(Get-ChildItem "$ICE\CCPD_SPE\ccpd_base\$t" -Filter *.jpg -ErrorAction SilentlyContinue).Count + if ($c -lt 8000) { $done = $false; break } + } + if (-not $done) { Write-Host " SPE not ready, waiting 60s..."; Start-Sleep 60 } +} while (-not $done) +Write-Host "SPE: OK" -ForegroundColor Green + +# Wait for Random +do { + $b = @(Get-ChildItem "$ICE\CCPD_Random_Blue150K_Green100K\blue" -Filter *.jpg -ErrorAction SilentlyContinue).Count + $g = @(Get-ChildItem "$ICE\CCPD_Random_Blue150K_Green100K\green" -Filter *.jpg -ErrorAction SilentlyContinue).Count + if ($b -lt 150000 -or $g -lt 100000) { + Write-Host " Random: blue=$b green=$g, waiting 60s..." + Start-Sleep 60 + } else { break } +} while ($true) +Write-Host "Random: blue=$b green=$g OK" -ForegroundColor Green + +# Now run the pipeline +Write-Host "`nAll data ready. Starting pipeline..." -ForegroundColor Cyan +& "C:/Program Files/Python313/python.exe" "$PSScriptRoot\pipeline_322k.py" diff --git a/scripts/pipeline_322k.ps1 b/scripts/pipeline_322k.ps1 new file mode 100644 index 0000000..c6290c0 --- /dev/null +++ b/scripts/pipeline_322k.ps1 @@ -0,0 +1,85 @@ +# 322K 全自动流水线: 构建数据集 → EXP_CUT → TRANS → 训练 +$ErrorActionPreference = "Continue" +$PY = "C:/Program Files/Python313/python.exe" +$ICE = "C:\Users\Kirl\Desktop\ICEv2\ICE_Dataset" +$NN = "C:\Users\Kirl\Desktop\ICEv2\ICE_NNv2" + +# ===== Step 1: Build CCPD_322K ===== +Write-Host "`n===== 1/4 构建 CCPD_322K =====" -ForegroundColor Cyan + +$SRC_SPE = "$ICE\CCPD_SPE\ccpd_base" +$SRC_RND = "$ICE\CCPD_Random_Blue150K_Green100K" +$DST = "$ICE\CCPD_322K" + +if (Test-Path $DST) { Remove-Item -Recurse -Force $DST } +New-Item -ItemType Directory "$DST\train" -Force | Out-Null +New-Item -ItemType Directory "$DST\test" -Force | Out-Null + +# SPE 9类 (9:1 split) +$types = @('police','army','gangao','shiguan_old','shiguan_new','lingguan_old','lingguan_new','coach','new_energy') +foreach ($t in $types) { + $src = "$SRC_SPE\$t" + if (-not (Test-Path $src)) { Write-Host " SKIP $t"; continue } + $files = Get-ChildItem $src -Filter *.jpg | Sort-Object Name + $n = $files.Count + $nTrain = [math]::Floor($n * 0.9) + for ($i = 0; $i -lt $n; $i++) { + $split = if ($i -lt $nTrain) { "train" } else { "test" } + $d = "$DST\$split\$t"; New-Item -ItemType Directory $d -Force | Out-Null + Copy-Item $files[$i].FullName "$d\" -Force + } + Write-Host " $t : $n (train=$nTrain test=$($n-$nTrain))" +} + +# Random蓝绿 (9:1 split) +foreach ($t in @('blue','green')) { + $src = "$SRC_RND\$t" + if (-not (Test-Path $src)) { Write-Host " SKIP $t"; continue } + $files = Get-ChildItem $src -Filter *.jpg | Sort-Object Name + $n = $files.Count + $nTrain = [math]::Floor($n * 0.9) + for ($i = 0; $i -lt $n; $i++) { + $split = if ($i -lt $nTrain) { "train" } else { "test" } + $d = "$DST\$split\$t"; New-Item -ItemType Directory $d -Force | Out-Null + Copy-Item $files[$i].FullName "$d\" -Force + } + Write-Host " $t : $n (train=$nTrain test=$($n-$nTrain))" +} + +$total = (Get-ChildItem $DST -Recurse -Filter *.jpg).Count +Write-Host " TOTAL: $total" -ForegroundColor Green + +# ===== Step 2: EXP_CUT ===== +Write-Host "`n===== 2/4 YOLO EXP_CUT =====" -ForegroundColor Cyan +$EXP_CUT = "$ICE\CCPD_322K_EXP_CUT" +if (Test-Path $EXP_CUT) { Remove-Item -Recurse -Force $EXP_CUT } + +# Patch the build script with our paths +$script = Get-Content "$ICE\CCPD_19K_EXP_CUT\build_ccpd_19k_exp_cut.py" -Raw -Encoding UTF8 +$script = $script -replace 'SRC_DIR = PROJ_ROOT / "ICE_Dataset" / "CCPD_19K"', "SRC_DIR = Path(r'$DST')" +$script = $script -replace 'DST_DIR = PROJ_ROOT / "ICE_Dataset" / "CCPD_19K_EXP_CUT"', "DST_DIR = Path(r'$EXP_CUT')" +$tmp = "$ICE\scripts\_exp_cut_322k.py" +[System.IO.File]::WriteAllText($tmp, $script, [System.Text.UTF8Encoding]::new($false)) +& $PY $tmp --full +Remove-Item $tmp + +# ===== Step 3: TRANS ===== +Write-Host "`n===== 3/4 ICE-COR TRANS =====" -ForegroundColor Cyan +$TRANS = "$ICE\CCPD_322K_EXP_CUT_TRANS" +if (Test-Path $TRANS) { Remove-Item -Recurse -Force $TRANS } + +$script = Get-Content "$ICE\CCPD_19K_EXP_CUT_TRANS\build_ccpd_19k_exp_cut_trans.py" -Raw -Encoding UTF8 +$script = $script -replace 'SRC_DIR = PROJ_ROOT / "ICE_Dataset" / "CCPD_19K_EXP_CUT"', "SRC_DIR = Path(r'$EXP_CUT')" +$script = $script -replace 'DST_DIR = PROJ_ROOT / "ICE_Dataset" / "CCPD_19K_EXP_CUT_TRANS"', "DST_DIR = Path(r'$TRANS')" +$tmp = "$ICE\scripts\_trans_322k.py" +[System.IO.File]::WriteAllText($tmp, $script, [System.Text.UTF8Encoding]::new($false)) +& $PY $tmp --full +Remove-Item $tmp + +# ===== Step 4: Training ===== +Write-Host "`n===== 4/4 ICE-OCR Training =====" -ForegroundColor Cyan +Write-Host " Data: $TRANS" +Write-Host " Launching..." +& $PY "$NN\ICE-OCR\script\train.py" --dataset ccpd_trans --epochs 100 --batch 128 --output ccpd_322k_best.pth --workers 2 --data-dir $TRANS + +Write-Host "`n===== DONE =====" -ForegroundColor Green diff --git a/scripts/pipeline_322k.py b/scripts/pipeline_322k.py new file mode 100644 index 0000000..cf379aa --- /dev/null +++ b/scripts/pipeline_322k.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""322K Pipeline Step 2-4: EXP_CUT -> TRANS -> Train""" +import os, sys, shutil, subprocess +from pathlib import Path + +ICE = Path(r'C:\Users\Kirl\Desktop\ICEv2\ICE_Dataset') +PY = r'C:/Program Files/Python313/python.exe' +NN = Path(r'C:\Users\Kirl\Desktop\ICEv2\ICE_NNv2') + +DATASET = ICE / 'CCPD_322K' +EXP_CUT = ICE / 'CCPD_322K_EXP_CUT' +TRANS = ICE / 'CCPD_322K_EXP_CUT_TRANS' + +def run(cmd): + print(f" {str(cmd)[:150]}...") + if isinstance(cmd, list): + r = subprocess.run(cmd) + else: + r = subprocess.run(cmd, shell=True) + if r.returncode != 0: print(f" FAILED ({r.returncode})"); sys.exit(1) + +# ===== Step 2: EXP_CUT ===== +print("===== 2/4 EXP_CUT =====") +shutil.rmtree(EXP_CUT, ignore_errors=True) +EXP_CUT.mkdir(parents=True) + +exp_py = ICE / 'CCPD_19K_EXP_CUT' / 'build_ccpd_19k_exp_cut.py' +code = exp_py.read_text(encoding='utf-8') +code = code.replace( + 'SRC_DIR = PROJ_ROOT / "ICE_Dataset" / "CCPD_19K" # 源数据集', + f'SRC_DIR = Path(r"{DATASET}")') +code = code.replace( + 'DST_DIR = SCRIPT_DIR # 本目录 (CCPD_19K_EXP_CUT)', + f'DST_DIR = Path(r"{EXP_CUT}")') +tmp = ICE / 'scripts' / '_e.py' +tmp.write_text(code, encoding='utf-8') +run([PY, str(tmp), '--full']) +tmp.unlink() +print(f" Images: {sum(1 for _ in EXP_CUT.rglob('*.jpg'))}") + +# ===== Step 3: TRANS ===== +print("===== 3/4 TRANS =====") +shutil.rmtree(TRANS, ignore_errors=True) +TRANS.mkdir(parents=True) + +trans_py = ICE / 'CCPD_19K_EXP_CUT_TRANS' / 'build_ccpd_19k_exp_cut_trans.py' +code = trans_py.read_text(encoding='utf-8') +code = code.replace( + 'SRC_DIR = PROJ_ROOT / "ICE_Dataset" / "CCPD_19K_EXP_CUT" # 源数据集', + f'SRC_DIR = Path(r"{EXP_CUT}")') +code = code.replace( + 'DST_DIR = PROJ_ROOT / "ICE_Dataset" / "CCPD_19K_EXP_CUT_TRANS"', + f'DST_DIR = Path(r"{TRANS}")') +tmp = ICE / 'scripts' / '_t.py' +tmp.write_text(code, encoding='utf-8') +run([PY, str(tmp), '--full']) +tmp.unlink() +print(f" Images: {sum(1 for _ in TRANS.rglob('*.jpg'))}") + +# ===== Step 4: Training ===== +print("===== 4/4 Training =====") +os.chdir(str(NN / 'ICE-OCR')) +run([PY, 'script/train.py', '--dataset', 'ccpd_trans', '--epochs', '100', '--batch', '128', '--output', 'ccpd_322k_best.pth', '--workers', '2', '--data-dir', str(TRANS)]) +print("DONE") diff --git a/scripts/rebuild_spe.ps1 b/scripts/rebuild_spe.ps1 new file mode 100644 index 0000000..289421e --- /dev/null +++ b/scripts/rebuild_spe.ps1 @@ -0,0 +1,54 @@ +# C++ 管线批量重生 CCPD_SPE (24核) +$ErrorActionPreference = "Continue" + +$env:Path = "C:\opencv\build\x64\vc16\bin;$env:Path" + +$EXE = "C:\Users\Kirl\Desktop\ICEv2\ICE_Dataset\CCPD_SPE_GEN\cpp_pipeline\build\Release\plate_swap.exe" +$POOL = "C:\Users\Kirl\Desktop\ICEv2\ICE_Dataset\cpp_pipeline\output\plate_pool" +$CCPD = "C:\Users\Kirl\Desktop\ICEv2\ICE_Dataset\CCPD\CCPD2019" +$SPE = "C:\Users\Kirl\Desktop\ICEv2\ICE_Dataset\CCPD_SPE" + +# 清旧 +if (Test-Path $SPE) { + Write-Host "删除旧 CCPD_SPE..." + Remove-Item -Recurse -Force $SPE +} + +$DIST = @{ + 'ccpd_base' = 588 + 'ccpd_blur' = 55 + 'ccpd_challenge' = 159 + 'ccpd_fn' = 58 + 'ccpd_rotate' = 30 + 'ccpd_tilt' = 82 + 'ccpd_weather' = 28 +} + +$TYPES = @('police','army','gangao','shiguan_old','shiguan_new', + 'lingguan_old','lingguan_new','coach','new_energy') + +$total = 0 +$sw = [System.Diagnostics.Stopwatch]::StartNew() + +foreach ($type in $TYPES) { + Write-Host "`n=== $type ===" -ForegroundColor Cyan + foreach ($subdir in $DIST.Keys) { + $count = $DIST[$subdir] + $ccpdDir = Join-Path $CCPD $subdir + $outDir = Join-Path $SPE "$subdir\$type" + New-Item -ItemType Directory -Force -Path $outDir | Out-Null + + Write-Host " $subdir : $count 张... " -NoNewline + & $EXE --ccpd-dir $ccpdDir --plate-type $type --plate-pool $POOL ` + --output $outDir --num $count --workers 24 --seed 42 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Host "OK" -ForegroundColor Green + $total += $count + } else { + Write-Host "FAIL" -ForegroundColor Red + } + } +} + +$sw.Stop() +Write-Host "`nDONE! $total 张, $([math]::Round($sw.Elapsed.TotalMinutes,1))min" -ForegroundColor Green diff --git a/scripts/rebuild_spe.py b/scripts/rebuild_spe.py new file mode 100644 index 0000000..1ad3d30 --- /dev/null +++ b/scripts/rebuild_spe.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" +批量重新生成 CCPD_SPE 数据集 — 全量版 +SPE 9类 × 8000 (ccpd_base) = 72,000 张 +蓝绿由独立脚本生成 150K+100K +""" + +import os +import sys +import time +from pathlib import Path + +ICE_DATASET = Path(r'C:\Users\Kirl\Desktop\ICEv2\ICE_Dataset') +os.chdir(str(ICE_DATASET)) + +sys.path.insert(0, str(ICE_DATASET / 'CCPD_SPE_GEN' / 'cv_pipeline')) +from plate_swap import PlateSwapPipeline + +# 只生成 9 类特殊车牌 (蓝绿由独立脚本处理) +SPE_TYPES = ['police','army','gangao','shiguan_old','shiguan_new', + 'lingguan_old','lingguan_new','coach','new_energy'] + +COUNT_PER_TYPE = 8000 # 每类 8000, 全部用 ccpd_base 背景 + +CCPD2019_DIR = ICE_DATASET / 'CCPD' / 'CCPD2019' +SPE_OUTPUT = ICE_DATASET / 'CCPD_SPE' + +SEED = 42 +WORKERS = 24 + +def main(): + total_start = time.time() + total_images = 0 + + import shutil + if SPE_OUTPUT.exists(): + print(f"删除旧 CCPD_SPE: {SPE_OUTPUT}") + shutil.rmtree(str(SPE_OUTPUT)) + SPE_OUTPUT.mkdir(parents=True, exist_ok=True) + + for plate_type in SPE_TYPES: + print(f"\n{'='*60}") + print(f"[{plate_type}] {COUNT_PER_TYPE} 张") + print(f"{'='*60}") + + pipeline = PlateSwapPipeline(plate_type=plate_type) + out_dir = SPE_OUTPUT / 'ccpd_base' / plate_type + out_dir.mkdir(parents=True, exist_ok=True) + + print(f" 生成中...", end=' ', flush=True) + t0 = time.time() + pipeline.process_batch( + ccpd_dirs=[str(CCPD2019_DIR / 'ccpd_base')], + output_dir=str(out_dir), + num_images=COUNT_PER_TYPE, + preview=False, + seed=SEED, + workers=WORKERS, + ) + elapsed = time.time() - t0 + total_images += COUNT_PER_TYPE + print(f"完成 ({elapsed:.0f}s, {COUNT_PER_TYPE/elapsed:.0f} 张/s)") + + total_t = time.time() - total_start + print(f"\n{'='*60}") + print(f"SPE 完成! {total_images} 张, {total_t/60:.1f}min") + print(f"{'='*60}") + +if __name__ == '__main__': + main() diff --git a/scripts/rebuild_spe_cpp.py b/scripts/rebuild_spe_cpp.py new file mode 100644 index 0000000..aad90ae --- /dev/null +++ b/scripts/rebuild_spe_cpp.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +""" +C++ 管线批量重生 CCPD_SPE (9 类 x 7 子集 = 9,000 张) +比 Python 版快 ~5x,24 线程饱和 +""" + +import os +import sys +import time +import shutil +import subprocess +from pathlib import Path + +ICE_DATASET = Path(r'C:\Users\Kirl\Desktop\ICEv2\ICE_Dataset') +CPP_EXE = ICE_DATASET / 'CCPD_SPE_GEN' / 'cpp_pipeline' / 'build' / 'Release' / 'plate_swap.exe' +PLATE_POOL = ICE_DATASET / 'cpp_pipeline' / 'output' / 'plate_pool' +CCPD2019 = ICE_DATASET / 'CCPD' / 'CCPD2019' +SPE_OUT = ICE_DATASET / 'CCPD_SPE' + +# 原始分布 +DIST = { + 'ccpd_base': 588, + 'ccpd_blur': 55, + 'ccpd_challenge': 159, + 'ccpd_fn': 58, + 'ccpd_rotate': 30, + 'ccpd_tilt': 82, + 'ccpd_weather': 28, +} + +TYPES = ['police','army','gangao','shiguan_old','shiguan_new', + 'lingguan_old','lingguan_new','coach','new_energy'] + +WORKERS = 24 # 物理核心数 +SEED = 42 + +def main(): + if not CPP_EXE.exists(): + print(f"[FATAL] C++ exe not found: {CPP_EXE}") + return 1 + if not PLATE_POOL.is_dir(): + print(f"[FATAL] Plate pool not found: {PLATE_POOL}") + return 1 + + # 清空旧数据 + if SPE_OUT.exists(): + print(f"删除旧 CCPD_SPE: {SPE_OUT}") + shutil.rmtree(str(SPE_OUT)) + + total_start = time.time() + total = 0 + + for plate_type in TYPES: + t0 = time.time() + type_count = 0 + print(f"\n{'='*50}") + print(f"[{plate_type}]") + print(f"{'='*50}") + + for subdir, count in DIST.items(): + ccpd_dir = CCPD2019 / subdir + if not ccpd_dir.is_dir(): + continue + + out_dir = SPE_OUT / subdir / plate_type + out_dir.mkdir(parents=True, exist_ok=True) + + cmd = [ + str(CPP_EXE), + '--ccpd-dir', str(ccpd_dir), + '--plate-type', plate_type, + '--plate-pool', str(PLATE_POOL), + '--output', str(out_dir), + '--num', str(count), + '--workers', str(WORKERS), + '--seed', str(SEED + hash(plate_type) % 10000), + ] + + print(f" {subdir}: {count} 张...", end=' ', flush=True) + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode == 0: + print(f"OK") + type_count += count + else: + print(f"FAIL") + print(f" stderr: {result.stderr[:200]}") + + elapsed = time.time() - t0 + print(f" [{plate_type}] {type_count} 张, {elapsed:.0f}s") + total += type_count + + total_t = time.time() - total_start + print(f"\n{'='*50}") + print(f"DONE! {total} 张, {total_t/60:.1f}min") + print(f"{'='*50}") + +if __name__ == '__main__': + os.chdir(str(ICE_DATASET)) + main() diff --git a/scripts/sample_ccpd_full.py b/scripts/sample_ccpd_full.py new file mode 100644 index 0000000..76424ea --- /dev/null +++ b/scripts/sample_ccpd_full.py @@ -0,0 +1,126 @@ +""" +CCPD原始真图 全量 TRANS (GT顶点外接框 + 12%/18%外扩) +输出: ICE_Dataset/CCPD_REAL_TRANS/train/ +全量 ~338K, 文件名 _mask_real_ 标记 (训练时屏蔽省位+城市位) +""" +import os, sys, cv2, shutil +import numpy as np +from pathlib import Path + +ICEv2 = Path(r"C:\Users\Kirl\Desktop\ICEv2") +sys.path.insert(0, str(ICEv2 / "ICE_NNv2/ICE-OCR/script/tools")) +from dataset import parse_ccpd_filename, imread_unicode + +CCPD_DIRS = [ + ICEv2 / "ICE_Dataset/CCPD/CCPD2019", + ICEv2 / "ICE_Dataset/CCPD/CCPD2020", +] +OUT_DIR = ICEv2 / "ICE_Dataset/CCPD_REAL_TRANS/train" +EXPAND_NORMAL = 1.12 +EXPAND_SMALL = 1.18 +SMALL_BOX_W = SMALL_BOX_H = 16 +MIN_PAD = 2 + + +def process_one(full_path, stem): + """GT顶点→外接框→12/18%外扩→裁切→顶点重映射→warp""" + img = imread_unicode(str(full_path)) + if img is None: + return None + h, w = img.shape[:2] + + parts = stem.split("-") + if len(parts) < 4: + return None + # GT顶点 (CCPD: br, bl, tl, tr) + verts = [] + for v in parts[3].split("_"): + x, y = v.split("&") + verts.append([float(x), float(y)]) + verts = np.float32(verts) + + # 外接框 + x1, y1 = verts[:, 0].min(), verts[:, 1].min() + x2, y2 = verts[:, 0].max(), verts[:, 1].max() + bw, bh = x2 - x1, y2 - y1 + + # 外扩 + ef = EXPAND_SMALL if (bw < SMALL_BOX_W or bh < SMALL_BOX_H) else EXPAND_NORMAL + px = max(MIN_PAD, int(round(bw * (ef - 1.0) / 2.0))) + py = max(MIN_PAD, int(round(bh * (ef - 1.0) / 2.0))) + cx1 = max(0, int(x1) - px); cy1 = max(0, int(y1) - py) + cx2 = min(w, int(x2) + px); cy2 = min(h, int(y2) + py) + if cx2 <= cx1 or cy2 <= cy1: + return None + + cropped = img[cy1:cy2, cx1:cx2] + + # 顶点重映射到裁切坐标 + OpenCV顺序 (CCPD: br,bl,tl,tr → tl,tr,br,bl) + verts_crop = verts[[2, 3, 0, 1], :] - np.array([cx1, cy1]) + verts_crop = verts_crop.astype(np.float32) + + OW, OH = 192, 64 + dst = np.float32([[0, 0], [OW - 1, 0], [OW - 1, OH - 1], [0, OH - 1]]) + M = cv2.getPerspectiveTransform(verts_crop, dst) + return cv2.warpPerspective(cropped, M, (OW, OH)) + + +if __name__ == "__main__": + # Step 0: 清空 + if OUT_DIR.exists(): + shutil.rmtree(str(OUT_DIR)) + OUT_DIR.mkdir(parents=True, exist_ok=True) + + # Step 1: 收集 + print("[1/2] Scanning all CCPD...") + all_files = [] + total = skipped = 0 + for d in CCPD_DIRS: + for root, dirs, files in os.walk(str(d)): + for f in files: + if not f.lower().endswith(".jpg"): + continue + indices, plate = parse_ccpd_filename(f) + if indices is None: + skipped += 1 + continue + total += 1 + all_files.append((os.path.join(root, f), Path(f).stem, plate)) + + print(f" Valid: {total}, Skipped: {skipped}") + for prov in sorted(set(p[2][0] for p in all_files), + key=lambda p: -sum(1 for x in all_files if x[2][0] == p)): + cnt = sum(1 for x in all_files if x[2][0] == prov) + print(f" {prov}: {cnt}") + + # Step 2: GT顶点 warp + print(f"\n[2/2] GT-vertex warp {len(all_files)} -> {OUT_DIR}...") + done = err = 0 + for i, (full_path, stem, plate) in enumerate(all_files): + try: + warped = process_one(full_path, stem) + if warped is None: + err += 1 + continue + + # _mask_real_ 标记: 训练时屏蔽 pos0(省)+pos1(城市码) + out_name = f"{stem}_mask_real_{plate}_trans.jpg" + out_path = OUT_DIR / out_name + + ret, enc = cv2.imencode(".jpg", warped, [cv2.IMWRITE_JPEG_QUALITY, 95]) + if ret: + with open(str(out_path), "wb") as wf: + wf.write(enc.tobytes()) + done += 1 + else: + err += 1 + except Exception as e: + err += 1 + if err <= 3: + print(f" [!] {Path(full_path).name}: {e}") + + if (i + 1) % 10000 == 0: + print(f" [{i+1}/{len(all_files)}] done={done}, err={err}") + + print(f"\nDone! Saved: {done}, Errors: {err}") + print(f"Output: {OUT_DIR} ({len(list(OUT_DIR.glob('*.jpg')))} files)") diff --git a/scripts/sample_ccpd_real.py b/scripts/sample_ccpd_real.py new file mode 100644 index 0000000..c6506a9 --- /dev/null +++ b/scripts/sample_ccpd_real.py @@ -0,0 +1,156 @@ +""" +CCPD原始真图 去偏采样 + YOLO EXP_CUT + 角点TRANS处理 +输出: ICE_Dataset/CCPD_REAL_TRANS/train/ +省份均衡: 每省最多 MAX_PER_PROVINCE 张 +管线: YOLO检出→12%/18%外扩→裁切→角点重映射→warp (与322K EXP_CUT一致) +""" +import os, sys, cv2, random, shutil +import numpy as np +from pathlib import Path +from collections import defaultdict + +ICEv2 = Path(r"C:\Users\Kirl\Desktop\ICEv2") +OCR_TOOLS = ICEv2 / "ICE_NNv2/ICE-OCR/script/tools" +sys.path.insert(0, str(OCR_TOOLS)) +from dataset import parse_ccpd_filename, imread_unicode + +CCPD_DIRS = [ + ICEv2 / "ICE_Dataset/CCPD/CCPD2019", + ICEv2 / "ICE_Dataset/CCPD/CCPD2020", +] +OUT_DIR = ICEv2 / "ICE_Dataset/CCPD_REAL_TRANS/train" +MAX_PER_PROVINCE = 300 +SEED = 42 + +YOLO_PATH = str(ICEv2 / "ICE_NNv2/yolov5t/output/1_CCPD_19K/weights/best.pt") +EXPAND_NORMAL = 1.12 # 常规外扩 12% +EXPAND_SMALL = 1.18 # 小车牌外扩 18% +SMALL_BOX_W = SMALL_BOX_H = 16 +MIN_PAD = 2 + +random.seed(SEED) + + +def process_one(img_path, plate, model, img_size=640): + """YOLO→外扩→裁切→角点重映射→warp (仿 EXP_CUT 管线)""" + img = imread_unicode(str(img_path)) + if img is None: + return None + + h, w = img.shape[:2] + results = model(img_path, imgsz=img_size, verbose=False) + if len(results) == 0 or results[0].boxes is None or len(results[0].boxes) == 0: + return None + + boxes = results[0].boxes + # 取置信度最高的框 (YOLO 输出 xyxy 格式) + best_idx = boxes.conf.argmax() + x1, y1, x2, y2 = boxes.xyxy[best_idx].cpu().numpy().astype(int) + bw, bh = x2 - x1, y2 - y1 + + # 外扩 (与 EXP_CUT 完全一致) + expand_factor = EXPAND_SMALL if (bw < SMALL_BOX_W or bh < SMALL_BOX_H) else EXPAND_NORMAL + pad_x = max(MIN_PAD, int(round(bw * (expand_factor - 1.0) / 2.0))) + pad_y = max(MIN_PAD, int(round(bh * (expand_factor - 1.0) / 2.0))) + + cx1 = max(0, int(x1) - pad_x) + cy1 = max(0, int(y1) - pad_y) + cx2 = min(w, int(x2) + pad_x) + cy2 = min(h, int(y2) + pad_y) + if cx2 <= cx1 or cy2 <= cy1: + return None + + cropped = img[cy1:cy2, cx1:cx2] + + # 角点: 用 YOLO 检出框的四角 (原始图坐标), 重映射到裁切图坐标 + corners_orig = np.float32([[x1, y1], [x2, y1], [x2, y2], [x1, y2]]) # TL, TR, BR, BL + corners_crop = (corners_orig - np.array([cx1, cy1])).astype(np.float32) + + # 透视变换 → 192x64 + OW, OH = 192, 64 + dst = np.float32([[0, 0], [OW - 1, 0], [OW - 1, OH - 1], [0, OH - 1]]) + M = cv2.getPerspectiveTransform(corners_crop, dst) + warped = cv2.warpPerspective(cropped, M, (OW, OH)) + return warped + + +# ---- Step 0: 清空旧输出 ---- +if OUT_DIR.exists(): + shutil.rmtree(str(OUT_DIR)) +OUT_DIR.mkdir(parents=True, exist_ok=True) + + +# ---- Step 1: 收集CCPD真图, 按省份分组 ---- +print("[1/3] Scanning CCPD directories...") +prov_files = defaultdict(list) # province -> [(full_path, stem, plate)] +total = skipped = 0 + +for ccpd_dir in CCPD_DIRS: + for root, dirs, files in os.walk(str(ccpd_dir)): + for f in files: + if not f.lower().endswith(".jpg"): + continue + indices, plate = parse_ccpd_filename(f) + if indices is None: + skipped += 1 + continue + total += 1 + prov = plate[0] + full_path = os.path.join(root, f) + stem = Path(f).stem + prov_files[prov].append((full_path, stem, plate)) + +print(f" Valid: {total}, Skipped: {skipped}, Provinces: {len(prov_files)}") +for prov in sorted(prov_files, key=lambda p: -len(prov_files[p])): + print(f" {prov}: {len(prov_files[prov])}") + + +# ---- Step 2: 去偏采样 ---- +print(f"\n[2/3] Sampling max {MAX_PER_PROVINCE} per province...") +sampled = [] +for prov, items in prov_files.items(): + if len(items) <= MAX_PER_PROVINCE: + sampled.extend(items) + else: + sampled.extend(random.sample(items, MAX_PER_PROVINCE)) +random.shuffle(sampled) +print(f" Sampled: {len(sampled)}") +prov_dist = defaultdict(int) +for _, _, plate in sampled: + prov_dist[plate[0]] += 1 +for prov in sorted(prov_dist): + print(f" {prov}: {prov_dist[prov]}") + + +# ---- Step 3: YOLO EXP_CUT + warp + 保存 ---- +print(f"\n[3/3] YOLO EXP_CUT (12%/18%) + warp {len(sampled)} images -> {OUT_DIR}...") +print(f" YOLO: {YOLO_PATH}") +from ultralytics import YOLO +model = YOLO(YOLO_PATH) +done = err = 0 +for i, (full_path, stem, plate) in enumerate(sampled): + try: + warped = process_one(full_path, plate, model) + if warped is None: + err += 1; continue + + out_name = f"{stem}_real_blue_{plate}_trans.jpg" + out_path = OUT_DIR / out_name + + ret, enc = cv2.imencode(".jpg", warped, [cv2.IMWRITE_JPEG_QUALITY, 95]) + if ret: + with open(str(out_path), "wb") as wf: + wf.write(enc.tobytes()) + done += 1 + else: + err += 1 + except Exception as e: + err += 1 + if err <= 5: + print(f" [!] {Path(full_path).name}: {e}") + + if (i + 1) % 200 == 0: + print(f" [{i+1}/{len(sampled)}] done={done}, err={err}") + +print(f"\nDone! Saved: {done}, Errors: {err}") +print(f"Output: {OUT_DIR} ({len(list(OUT_DIR.glob('*.jpg')))} files)")