feat: 特殊车牌生成管线 + C++/多进程/色温校正

This commit is contained in:
kirl committed 2026-06-21 10:32:37 +08:00
1 parent 3808b9f035
commit 3101992f51
29 files changed
+2336 -80

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
# CCPD_SPE_GEN — 特殊车牌数据集生成工具
基于 CCPD 真实车辆照片,将普通蓝牌替换为 9 种特殊车牌(警车/军车/港澳/使馆/领馆/教练/新能源)。
---
## 目录结构
```
CCPD_SPE_GEN/
├── README.md
│
├── cv_pipeline/ # 传统 CV 管线(无需 GPU/训练)
│ ├── plate_swap.py # 主体:车牌替换全流程
│ ├── extract_real_plates.py # 从 CCPD 提取真实车牌 ROI
│ └── gen_pure_plates.py # 生成纯合成车牌(无背景)
│
├── gan_pipeline/ # GAN 管线(CUT 纹理迁移)
│ ├── dataset.py # L 通道 DataLoader + 推理封装
│ ├── models.py # Generator + PatchGAN + PatchNCE Loss
│ ├── train.py # 训练脚本
│ └── requirements.txt # PyTorch 依赖
│
└── scripts/ # 一键运行脚本
├── quick_test.sh # 快速测试 20 张警车牌
├── preview_all.sh # 预览全部 9 种类型
└── gan_data_prep.sh # GAN 训练数据准备
```
## 两套算法
### CV 管线 (`cv_pipeline/`)
**经典计算机视觉,零训练成本,即开即用。**
流程:CCPD 原图 → 解析文件名角点 → 生成特殊车牌 → Lab 色彩对齐 → 透视回贴 → 距离变换羽化 → 噪声/模糊匹配 → JPEG 退化模拟 → 输出
```bash
cd ICE_Dataset
python CCPD_SPE_GEN/cv_pipeline/plate_swap.py \
--ccpd-dir ./CCPD/CCPD2019/ccpd_base \
--plate-type police \
--num 500 \
--output ./output/police_dataset
```
### GAN 管线 (`gan_pipeline/`)
**基于 CUT (Contrastive Unpaired Translation),需 PyTorch + GPU 训练。**
仅对车牌 L (亮度) 通道做纹理迁移,保留 AB 颜色通道不变。训练后将 GAN 推理接入 CV 管线,替换硬编码的退化模拟步骤。
```bash
# 训练
cd CCPD_SPE_GEN/gan_pipeline
python train.py \
--domain-a ../../gan_data/domain_A_synthetic/police \
--domain-b ../../gan_data/domain_B_real \
--epochs 100 --batch 4 \
--output ../../output/cut_police
# 推理 (在 CV 管线中调用)
# from CCPD_SPE_GEN.gan_pipeline.dataset import LChannelInference
```
## 依赖
| 管线 | 依赖 |
|------|------|
| CV | `numpy`, `opencv-python`, `tqdm`, `chinese_license_plate_generator/` |
| GAN | 以上 + `torch>=1.12`, `torchvision`, `tensorboard` |
## 支持的特殊车牌类型
| 类型 | 底板 | 说明 |
|------|------|------|
| `police` | 白底 | 警用车牌 (末位"警"红字) |
| `army` | 白底 | 军车/武警 |
| `gangao` | 黑底 | 港澳入出境 |
| `shiguan_old` | 黑底 | 使馆旧版 ("使"红字) |
| `shiguan_new` | 黑底 | 使馆新版 (白字) |
| `lingguan_old` | 黑底 | 领馆旧版 ("领"红字) |
| `lingguan_new` | 黑底 | 领馆新版 (白字) |
| `coach` | 黄底 | 教练车 (末位"学") |
| `new_energy` | 黄绿双拼 | 大型新能源 (8 位) |
+24
View File
@@ -0,0 +1,24 @@
cmake_minimum_required(VERSION 3.16)
project(ice_plate_swap LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# OpenCV
find_package(OpenCV REQUIRED)
# 源文件
add_executable(plate_swap
main.cpp
plate_swap.cpp
plate_swap.hpp
)
target_link_libraries(plate_swap ${OpenCV_LIBS})
# 优化选项
if(MSVC)
target_compile_options(plate_swap PRIVATE /O2 /arch:AVX2 /utf-8)
else()
target_compile_options(plate_swap PRIVATE -O3 -march=native -fopenmp)
endif()
+42
View File
@@ -0,0 +1,42 @@
# C++ 管线编译脚本
# 前置条件: Visual Studio 2022 + C++ workload + OpenCV
# OpenCV 安装: 下载 https://opencv.org/releases/ 解压到 C:\opencv
param(
[string]$OpenCVDir = "C:\opencv\build"
)
$ErrorActionPreference = "Stop"
Write-Host "=== ICE C++ Plate Swap 编译 ===" -ForegroundColor Cyan
# 检查 OpenCV
if (-not (Test-Path "$OpenCVDir\OpenCVConfig.cmake")) {
Write-Host "[ERROR] OpenCV 未找到: $OpenCVDir" -ForegroundColor Red
Write-Host " 请从 https://opencv.org/releases/ 下载 Windows 版"
Write-Host " 解压到 C:\opencv 或使用 -OpenCVDir 指定路径"
exit 1
}
# 创建 build 目录
$buildDir = "build"
if (Test-Path $buildDir) { Remove-Item -Recurse -Force $buildDir }
New-Item -ItemType Directory $buildDir | Out-Null
# CMake 配置 (使用 VS 2022 生成器)
Write-Host "CMake 配置..."
cmake -B $buildDir -G "Visual Studio 17 2022" -A x64 `
-DOpenCV_DIR="$OpenCVDir"
# 编译 Release
Write-Host "编译 Release..."
cmake --build $buildDir --config Release --parallel
Write-Host ""
Write-Host "=== 编译完成 ===" -ForegroundColor Green
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"
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
预生成合成车牌池 (供 C++ 管线使用)
===================================
为每种特殊车牌类型批量生成纯车牌图,C++ 运行时直接读内存池,
避免每张 CCPD 原图都重新渲染车牌。
用法:
python gen_plate_pool.py --num 5000 --output ./output/plate_pool
输出目录结构:
plate_pool/
yellow/ 教练车牌 (440x140)
green_truck/ 新能源车牌 (480x140)
white/ 警车车牌
white_army/ 军车车牌
black/ 港澳/领馆车牌
black_shi/ 使馆车牌
"""
import os
import sys
import argparse
from pathlib import Path
import cv2
import numpy as np
from tqdm import tqdm
# 路径
_SCRIPT_DIR = Path(__file__).resolve().parent
_PROJECT_DIR = _SCRIPT_DIR.parent.parent
_GENERATOR_DIR = _PROJECT_DIR / 'chinese_license_plate_generator'
sys.path.insert(0, str(_GENERATOR_DIR))
from generate_multi_plate import MultiPlateGenerator
from plate_number import (
generate_plate_number_white,
generate_plate_number_army,
generate_plate_number_black_gangao,
generate_plate_number_black_shi_old,
generate_plate_number_black_shi_new,
generate_plate_number_black_ling_old,
generate_plate_number_black_ling_new,
generate_plate_number_yellow_xue,
generate_plate_number_blue,
)
# 类型配置
PLATE_TYPES = [
('police', 'white', generate_plate_number_white),
('army', 'white_army', generate_plate_number_army),
('gangao', 'black', generate_plate_number_black_gangao),
('shiguan_old', 'black_shi', generate_plate_number_black_shi_old),
('shiguan_new', 'black_shi', generate_plate_number_black_shi_new),
('lingguan_old','black', generate_plate_number_black_ling_old),
('lingguan_new','black', generate_plate_number_black_ling_new),
('coach', 'yellow', generate_plate_number_yellow_xue),
('new_energy', 'green_truck', lambda: generate_plate_number_blue(length=8)),
]
def main():
parser = argparse.ArgumentParser(description='预生成合成车牌池')
parser.add_argument('--num', type=int, default=5000,
help='每种类型生成数量 (默认: 5000)')
parser.add_argument('--output', type=str, default='./output/plate_pool',
help='输出根目录')
args = parser.parse_args()
# 初始化生成器
pm = str(_GENERATOR_DIR / 'plate_model')
fm = str(_GENERATOR_DIR / 'font_model')
gen = MultiPlateGenerator(pm, fm)
for plate_type, bg_color, num_func in PLATE_TYPES:
out_dir = Path(args.output) / bg_color
out_dir.mkdir(parents=True, exist_ok=True)
existing = len(list(out_dir.glob('*.jpg')))
if existing >= args.num:
print(f"[SKIP] {plate_type:12s} ({bg_color:12s}): 已有 {existing} 张")
continue
need = args.num - existing
print(f"[GEN] {plate_type:12s} ({bg_color:12s}): {need} 张...", end=' ', flush=True)
success = 0
pbar = tqdm(total=need, desc=f'{plate_type}', unit='张', leave=False)
attempts = 0
while success < need and attempts < need * 3:
attempts += 1
try:
number = num_func()
plate = gen.generate_plate_special(
number, bg_color, is_double=False, enhance=False
)
# 用 imencode 支持中文文件名
fname = f"{number}.jpg"
ok, buf = cv2.imencode('.jpg', plate, [cv2.IMWRITE_JPEG_QUALITY, 95])
if ok:
with open(str(out_dir / fname), 'wb') as f:
f.write(buf.tobytes())
success += 1
pbar.update(1)
except Exception:
pass
pbar.close()
print(f"OK ({success} 张)")
print(f"\n完成! 输出: {args.output}")
# 统计
for bg_color in ['yellow','green_truck','white','white_army','black','black_shi']:
d = Path(args.output) / bg_color
count = len(list(d.glob('*.jpg'))) if d.is_dir() else 0
print(f" {bg_color:15s}: {count} 张")
if __name__ == '__main__':
import os
os.chdir(str(_PROJECT_DIR))
main()
+145
View File
@@ -0,0 +1,145 @@
# ICE Plate Swap — C++ 高性能特殊车牌替换引擎
基于 CCPD 真实街景照片,将普通蓝牌替换为 9 种特殊车牌。
C++ / OpenCV 实现,32 线程满血,~53 张/秒。
---
## 快速开始
### 1. 环境要求
| 组件 | 说明 |
|------|------|
| Windows 10/11 x64 | — |
| [Visual Studio 2022 BuildTools](https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022) | 含 C++ 桌面开发 workload |
| [OpenCV 4.10.0](https://github.com/opencv/opencv/releases/tag/4.10.0) | 解压到 `C:\opencv` |
### 2. 预编译版 (推荐)
直接下载 `plate_swap.exe`(已通过 Git LFS 管理):
```powershell
# 设置 OpenCV DLL 路径
$env:PATH = "C:\opencv\build\x64\vc16\bin;$env:PATH"
# 运行
.\install\plate_swap.exe --help
```
### 3. 从源码编译
```powershell
# 打开 VS Developer Command Prompt
# 然后:
cd CCPD_SPE_GEN\cpp_pipeline
cmake -B build -G "Visual Studio 17 2022" -A x64 -DOpenCV_DIR=C:\opencv\build
cmake --build build --config Release --parallel
```
---
## 用法
```powershell
$env:PATH = "C:\opencv\build\x64\vc16\bin;$env:PATH"
.\plate_swap.exe `
--ccpd-dir ./CCPD/CCPD2019/ccpd_base,./CCPD/CCPD2019/ccpd_tilt `
--plate-type police `
--plate-pool ./output/plate_pool_v2 `
--output ./CCPD_SPE `
--num 1000 `
--workers 24
```
### 参数
| 参数 | 必需 | 默认 | 说明 |
|------|------|------|------|
| `--ccpd-dir` | ✅ | — | CCPD 图片目录,多个用逗号分隔 |
| `--plate-type` | ✅ | — | 特殊车牌类型 |
| `--plate-pool` | ✅ | — | 预生成车牌池路径(含 `pool.bin`) |
| `--output` | ✅ | — | 输出根目录 |
| `--num` | | 500 | 生成图片数量 |
| `--workers` | | 全部核心 | 并行线程数 |
| `--seed` | | 42 | 随机种子 |
| `--fast` | | false | 快速模式(跳过退化模拟,不建议) |
| `--h-shift` | | 0 | 底板色相偏移 (GUI调色) |
| `--s-scale` | | 100 | 底板饱和度缩放% |
| `--v-scale` | | 100 | 底板明度缩放% |
### 支持的类型
```
police 警用车牌 (白底, JING)
army 军车/武警 (白底)
gangao 港澳入出境 (黑底, GANG/AO)
shiguan_old 使馆旧版 (黑底, SHI)
shiguan_new 使馆新版 (黑底)
lingguan_old 领馆旧版 (黑底, LING)
lingguan_new 领馆新版 (黑底)
coach 教练车 (黄底, XUE)
new_energy 大型新能源 (黄绿双拼, 8位)
```
---
## 预生成车牌池
```powershell
cd ICE_Dataset
python CCPD_SPE_GEN\cpp_pipeline\gen_plate_pool.py --num 1000 --output ./output/plate_pool_v2
```
生成 `pool.bin` (~1.6GB),含 9 种类型各 1000 张 + 车牌号元数据。
---
## 输出结构
```
CCPD_SPE/
├── ccpd_base/
│ ├── police/
│ ├── gangao/
│ └── ...
├── ccpd_tilt/
└── ...
```
文件名: `{CCPD原名}__{类型}__{车牌号拼音}.jpg`
---
## 性能
| 指标 | 值 |
|------|-----|
| 单线程 | ~7 张/秒 |
| 24 线程 | ~53 张/秒 |
| 35.5 万张全量 | ~1.9 小时 |
---
## 调色 GUI
```powershell
python CCPD_SPE_GEN\cv_pipeline\color_tuner.py
```
H/S/V 滑块实时预览 → 按 `S` 保存 → `plate_color_profile.py` → C++ 管线 `--h-shift` `--s-scale` `--v-scale` 读取。
---
## 构建
```powershell
# 安装 OpenCV
Invoke-WebRequest "https://github.com/opencv/opencv/releases/download/4.10.0/opencv-4.10.0-windows.exe" -OutFile "$env:TEMP\opencv.exe"
Start-Process "$env:TEMP\opencv.exe" -Args "-oC:\opencv -y" -Wait
# 编译
cd CCPD_SPE_GEN\cpp_pipeline
.\build.ps1
```
Binary file not shown.
+203
View File
@@ -0,0 +1,203 @@
#include "plate_swap.hpp"
#include <iostream>
#include <thread>
#include <mutex>
#include <atomic>
#include <chrono>
#include <filesystem>
namespace fs = std::filesystem;
using namespace std;
using namespace ice;
// ── 配置 ──
struct Config {
vector<string> ccpd_dirs;
string plate_type;
string output_dir;
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;
};
// ── 参数解析 ──
Config parse_args(int argc, char* argv[]) {
Config cfg;
cfg.workers = thread::hardware_concurrency();
for (int i = 1; i < argc; i++) {
string arg = argv[i];
if (arg == "--ccpd-dir" && i+1 < argc) {
string dirs = argv[++i];
size_t pos = 0;
while (pos < dirs.size()) {
size_t comma = dirs.find(',', pos);
string d = (comma == string::npos) ? dirs.substr(pos) : dirs.substr(pos, comma-pos);
// trim
while (!d.empty() && d.front() == ' ') d.erase(0,1);
while (!d.empty() && d.back() == ' ') d.pop_back();
if (!d.empty()) cfg.ccpd_dirs.push_back(d);
if (comma == string::npos) break;
pos = comma + 1;
}
}
else if (arg == "--plate-type" && i+1 < argc) cfg.plate_type = argv[++i];
else if (arg == "--output" && i+1 < argc) cfg.output_dir = argv[++i];
else if (arg == "--plate-pool" && i+1 < argc) cfg.plate_pool_dir = argv[++i];
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]);
}
return cfg;
}
// ── 收集并预解析 CCPD 图片(过滤无效,预存角点) ──
struct IndexedImage {
string path;
CCPDMeta meta;
};
vector<IndexedImage> collect_and_index(const vector<string>& dirs, mt19937& rng) {
vector<IndexedImage> result;
for (auto& d : dirs) {
error_code ec;
if (!fs::is_directory(d, ec)) {
cerr << "[WARN] Dir not found: " << d << endl;
continue;
}
for (auto& entry : fs::directory_iterator(d, ec)) {
if (ec) break;
if (!entry.is_regular_file()) continue;
string ext = entry.path().extension().string();
for (auto& c : ext) c = (char)tolower(c);
if (ext != ".jpg" && ext != ".jpeg" && ext != ".png") continue;
string fname = entry.path().filename().string();
CCPDMeta meta = parse_ccpd_filename(fname);
if (!meta.valid) continue;
IndexedImage ii;
ii.path = entry.path().string();
ii.meta = meta;
result.push_back(ii);
}
}
shuffle(result.begin(), result.end(), rng);
return result;
}
// ── 主函数 ──
int main(int argc, char* argv[]) {
Config cfg = parse_args(argc, argv);
if (cfg.ccpd_dirs.empty() || cfg.plate_type.empty()) {
cerr << "Usage: plate_swap --ccpd-dir <dirs> --plate-type <type> [options]" << endl;
cerr << " --plate-pool <dir> Plate pool directory (required)" << endl;
cerr << " --num <N> Number of images (default 500)" << endl;
cerr << " --workers <N> Thread count (default all cores)" << endl;
cerr << " --fast Fast mode (skip degradation)" << endl;
return 1;
}
cout << "=== ICE C++ Plate Swap ===" << endl;
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;
return 1;
}
// Load plate pool
auto t0 = chrono::steady_clock::now();
cout << "Loading plate pool..." << flush;
PlatePool pool;
string bin_path = cfg.plate_pool_dir + "/pool.bin";
if (fs::exists(bin_path))
pool.load_bin(bin_path);
else
pool.load(cfg.plate_pool_dir);
size_t pool_count = pool.count(cfg.plate_type);
auto t1 = chrono::steady_clock::now();
cout << " (" << pool_count << " available, "
<< chrono::duration<double>(t1-t0).count() << "s)" << endl;
if (pool_count == 0) {
cerr << "[ERROR] No plates of type " << cfg.plate_type << " in pool" << endl;
return 1;
}
// Collect & pre-parse CCPD (filter invalid, store corners)
mt19937 rng(cfg.seed);
cout << "Scanning & indexing CCPD..." << flush;
vector<IndexedImage> images = collect_and_index(cfg.ccpd_dirs, rng);
auto t2 = chrono::steady_clock::now();
cout << " (" << images.size() << " valid, "
<< chrono::duration<double>(t2-t1).count() << "s)" << endl;
if (images.empty()) {
cerr << "[ERROR] No valid CCPD images found" << endl;
return 1;
}
fs::create_directories(cfg.output_dir);
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;
// Parallel processing with pre-parsed metadata
atomic<int> success{0}, failed{0};
atomic<int> idx{0};
int total = min((int)images.size(), cfg.num_images);
mutex cout_mtx;
auto t_proc = chrono::steady_clock::now();
vector<thread> threads;
for (unsigned t = 0; t < cfg.workers; t++) {
threads.emplace_back([&](unsigned seed_offset) {
mt19937 local_rng(cfg.seed + seed_offset * 10007);
while (true) {
int i = idx.fetch_add(1);
if (i >= total) break;
bool ok = process_single(images[i].path, swap_cfg, pool,
local_rng, &images[i].meta);
if (ok) {
int s = success.fetch_add(1) + 1;
if (s % 100 == 0 || s == total) {
lock_guard<mutex> lk(cout_mtx);
cout << "\rProgress: " << s << "/" << total << flush;
}
} else {
failed.fetch_add(1);
}
}
}, t);
}
for (auto& th : threads) th.join();
auto t_done = chrono::steady_clock::now();
double elapsed = chrono::duration<double>(t_done - t_proc).count();
cout << "\rDone! Success: " << success << ", Failed: " << failed << endl;
cout << "Process time: " << elapsed << "s (" << (success/max(elapsed,0.01)) << " img/s)" << endl;
cout << "Output: " << cfg.output_dir << endl;
return 0;
}
+692
View File
@@ -0,0 +1,692 @@
#include "plate_swap.hpp"
#include <fstream>
#include <algorithm>
#include <numeric>
#include <cmath>
#include <cstring>
#include <filesystem>
namespace fs = std::filesystem;
using namespace cv;
using namespace std;
namespace ice {
// ═══════════════════════════════════════════════════════════
// CCPD 文件名解析
// ═══════════════════════════════════════════════════════════
CCPDMeta parse_ccpd_filename(const string& filename) {
CCPDMeta meta;
meta.valid = false;
// 提取不带扩展名的部分
fs::path p(filename);
string stem = p.stem().string();
// 按 '-' 分割
vector<string> parts;
size_t pos = 0;
while (pos < stem.size()) {
size_t dash = stem.find('-', pos);
if (dash == string::npos) {
parts.push_back(stem.substr(pos));
break;
}
parts.push_back(stem.substr(pos, dash - pos));
pos = dash + 1;
}
if (parts.size() < 5) return meta;
try {
// parts[3] = 4角点
string& coords = parts[3];
vector<Point2f> pts;
size_t start = 0;
while (start < coords.size()) {
size_t us = coords.find('_', start);
string pt = (us == string::npos) ? coords.substr(start) : coords.substr(start, us - start);
size_t amp = pt.find('&');
if (amp == string::npos) break;
float x = stof(pt.substr(0, amp));
float y = stof(pt.substr(amp + 1));
pts.push_back({x, y});
if (us == string::npos) break;
start = us + 1;
}
if (pts.size() != 4) return meta;
// 几何排序: TL=min(x+y), TR=max(x-y), BR=max(x+y), BL=min(x-y)
float sums[4], diffs[4];
for (int i = 0; i < 4; i++) {
sums[i] = pts[i].x + pts[i].y;
diffs[i] = pts[i].x - pts[i].y;
}
int tl = (int)(min_element(sums, sums+4) - sums);
int tr = (int)(max_element(diffs, diffs+4) - diffs);
int br = (int)(max_element(sums, sums+4) - sums);
int bl = (int)(min_element(diffs, diffs+4) - diffs);
meta.corners[0] = pts[tl];
meta.corners[1] = pts[tr];
meta.corners[2] = pts[br];
meta.corners[3] = pts[bl];
meta.valid = true;
} catch (...) {
meta.valid = false;
}
return meta;
}
// ═══════════════════════════════════════════════════════════
// 合成车牌池
// ═══════════════════════════════════════════════════════════
void PlatePool::load(const string& dir) {
// Fallback: load from directory
for (auto& entry : fs::directory_iterator(dir)) {
if (!entry.is_directory()) continue;
string bg_color = entry.path().filename().string();
vector<PlateEntry>& vec = pools_[bg_color];
for (auto& f : fs::directory_iterator(entry.path())) {
if (!f.is_regular_file()) continue;
string ext = f.path().extension().string();
if (ext != ".jpg" && ext != ".png" && ext != ".PNG") continue;
Mat img = imread_unicode(f.path().string());
if (img.empty()) continue;
PlateEntry pe;
pe.image = img;
vec.push_back(pe);
}
}
}
void PlatePool::load_bin(const string& bin_path) {
ifstream f(bin_path, ios::binary);
if (!f) { cerr << "[WARN] pool.bin not found" << endl; return; }
char magic[4];
f.read(magic, 4);
if (memcmp(magic, "IPPL", 4) != 0) { cerr << "[WARN] bad magic" << endl; return; }
uint32_t num_types;
f.read((char*)&num_types, 4);
for (uint32_t ti = 0; ti < num_types; ti++) {
uint32_t name_len;
f.read((char*)&name_len, 4);
string plate_type(name_len, 0);
f.read(&plate_type[0], name_len);
uint32_t count;
f.read((char*)&count, 4);
vector<PlateEntry>& vec = pools_[plate_type];
vec.reserve(count);
for (uint32_t pi = 0; pi < count; pi++) {
uint32_t w, h, data_len;
f.read((char*)&w, 4);
f.read((char*)&h, 4);
f.read((char*)&data_len, 4);
vector<uint8_t> buf(data_len);
f.read((char*)buf.data(), data_len);
PlateEntry pe;
pe.image = Mat(h, w, CV_8UC3, buf.data()).clone();
// Read plate number metadata
uint32_t num_len;
f.read((char*)&num_len, 4);
pe.plate_number.resize(num_len);
f.read(&pe.plate_number[0], num_len);
vec.push_back(pe);
}
}
}
const PlateEntry& PlatePool::random(const string& plate_type, mt19937& rng) {
auto& vec = pools_[plate_type];
if (vec.empty()) {
for (auto& [k, v] : pools_) {
if (!v.empty()) {
uniform_int_distribution<size_t> dist(0, v.size()-1);
return v[dist(rng)];
}
}
throw runtime_error("PlatePool is empty");
}
uniform_int_distribution<size_t> dist(0, vec.size()-1);
return vec[dist(rng)];
}
size_t PlatePool::count(const string& plate_type) const {
auto it = pools_.find(plate_type);
return (it != pools_.end()) ? it->second.size() : 0;
}
// Helper: plate_type → bg_color for color matching
static string bg_color_of(const string& plate_type) {
if (plate_type == "police") return "white";
if (plate_type == "army") return "white_army";
if (plate_type == "gangao") return "black";
if (plate_type == "shiguan_old" || plate_type == "shiguan_new") return "black_shi";
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";
return "white";
}
// ═══════════════════════════════════════════════════════════
// Unicode 文件读写
// ═══════════════════════════════════════════════════════════
Mat imread_unicode(const string& path) {
ifstream f(fs::path(path), ios::binary);
if (!f) return Mat();
vector<uint8_t> data((istreambuf_iterator<char>(f)), istreambuf_iterator<char>());
return imdecode(data, IMREAD_COLOR);
}
bool imwrite_unicode(const string& path, const Mat& img, const vector<int>& params) {
vector<uint8_t> buf;
fs::path p(path);
string ext = p.extension().string();
if (!imencode(ext, img, buf, params)) return false;
// Use fs::path directly for proper Unicode support on Windows
ofstream f(p, ios::binary);
if (!f) return false;
f.write(reinterpret_cast<const char*>(buf.data()), buf.size());
return f.good();
}
// ═══════════════════════════════════════════════════════════
// 透视提取车牌 ROI
// ═══════════════════════════════════════════════════════════
Mat extract_plate_roi(const Mat& img, const Point2f corners[4],
int plate_w, int plate_h) {
Point2f dst[4] = {
{0, 0},
{(float)(plate_w - 1), 0},
{(float)(plate_w - 1), (float)(plate_h - 1)},
{0, (float)(plate_h - 1)}
};
Mat M = getPerspectiveTransform(corners, dst);
Mat roi;
warpPerspective(img, roi, M, Size(plate_w, plate_h),
INTER_LINEAR, BORDER_REPLICATE);
return roi;
}
// ═══════════════════════════════════════════════════════════
// 色温匹配
// ═══════════════════════════════════════════════════════════
Mat match_plate_color(const Mat& synthetic, const Mat& orig_roi,
const string& bg_color) {
Mat syn, orig;
synthetic.convertTo(syn, CV_32F);
orig_roi.convertTo(orig, CV_32F);
// 尺寸对齐
if (syn.size() != orig.size())
resize(syn, syn, orig.size(), 0, 0, INTER_LINEAR);
if (bg_color == "black" || bg_color == "black_shi") {
Mat orig_gray;
cvtColor(orig_roi, orig_gray, COLOR_BGR2GRAY);
double thresh_val;
{
Mat sorted;
orig_gray.reshape(1, 1).copyTo(sorted);
cv::sort(sorted, sorted, SORT_EVERY_ROW + SORT_ASCENDING);
int idx = (int)(sorted.cols * 0.90);
thresh_val = sorted.at<uint8_t>(0, idx);
}
Mat white_mask = orig_gray > thresh_val;
if (countNonZero(white_mask) < 50) return synthetic;
Scalar white_ref = mean(orig, white_mask);
float ref_mean = (float)(white_ref[0] + white_ref[1] + white_ref[2]) / 3.0f;
if (ref_mean < 10) return synthetic;
Scalar color_cast(white_ref[0]/ref_mean, white_ref[1]/ref_mean, white_ref[2]/ref_mean);
Scalar amplified(1.0f + (color_cast[0]-1.0f), 1.0f+(color_cast[1]-1.0f), 1.0f+(color_cast[2]-1.0f));
amplified[0] = std::clamp((double)amplified[0], 0.85, 1.15);
amplified[1] = std::clamp((double)amplified[1], 0.85, 1.15);
amplified[2] = std::clamp((double)amplified[2], 0.85, 1.15);
Mat syn_gray;
cvtColor(synthetic, syn_gray, COLOR_BGR2GRAY);
Mat alpha;
syn_gray.convertTo(alpha, CV_32F, 1.0/128.0);
alpha = max(0.0f, min(1.0f, alpha));
Mat result = syn.clone();
for (int c = 0; c < 3; c++) {
Mat ch_syn, ch_res;
extractChannel(syn, ch_syn, c);
ch_res = ch_syn.mul(1.0f + ((float)amplified[c] - 1.0f) * alpha);
ch_res.setTo(0, ch_res < 0);
ch_res.setTo(255, ch_res > 255);
insertChannel(ch_res, result, c);
}
result.convertTo(result, CV_8U);
return result;
}
// 亮底牌:提取白参考
Mat orig_gray;
cvtColor(orig_roi, orig_gray, COLOR_BGR2GRAY);
double thresh_val;
{
Mat sorted;
orig_gray.reshape(1, 1).copyTo(sorted);
cv::sort(sorted, sorted, SORT_EVERY_ROW + SORT_ASCENDING);
int idx = (int)(sorted.cols * 0.90);
thresh_val = sorted.at<uint8_t>(0, idx);
}
Mat white_mask = orig_gray > thresh_val;
if (countNonZero(white_mask) < 50) return synthetic;
Scalar white_ref = mean(orig, white_mask);
float ref_mean = (float)(white_ref[0] + white_ref[1] + white_ref[2]) / 3.0f;
if (ref_mean < 10) return synthetic;
if (bg_color == "yellow") {
Scalar gain = white_ref / 255.0;
gain[0] = std::clamp((float)gain[0], 0.6f, 1.3f);
gain[1] = std::clamp((float)gain[1], 0.6f, 1.3f);
gain[2] = std::clamp((float)gain[2], 0.6f, 1.3f);
Mat res;
multiply(syn, gain, res);
res.setTo(0, res < 0);
res.setTo(255, res > 255);
res.convertTo(res, CV_8U);
return res;
}
// 白/绿牌
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;
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);
amplified[2] = std::clamp((float)amplified[2], 0.85f, 1.15f);
Mat syn_gray;
cvtColor(synthetic, syn_gray, COLOR_BGR2GRAY);
Mat alpha;
syn_gray.convertTo(alpha, CV_32F, 1.0/128.0);
alpha = max(0.0f, min(1.0f, alpha));
Mat result = syn.clone();
for (int c = 0; c < 3; c++) {
Mat ch_syn, ch_res;
extractChannel(syn, ch_syn, c);
ch_res = ch_syn.mul(1.0f + (amplified[c] - 1.0f) * alpha);
ch_res.setTo(0, ch_res < 0);
ch_res.setTo(255, ch_res > 255);
insertChannel(ch_res, result, c);
}
result.convertTo(result, CV_8U);
return result;
}
// ═══════════════════════════════════════════════════════════
// 底板色校正
// ═══════════════════════════════════════════════════════════
Mat fix_bg_color(const Mat& plate, const string& bg_color,
int h_shift, int s_scale, int v_scale) {
if (h_shift == 0 && s_scale == 100 && v_scale == 100)
return plate;
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; }
Mat hsv;
cvtColor(plate, hsv, COLOR_BGR2HSV);
hsv.convertTo(hsv, CV_32F);
vector<Mat> ch(3);
split(hsv, ch);
Mat &H = ch[0], &S = ch[1], &V = ch[2];
Mat bg_mask = (H > h_lo) & (H < h_hi) & (S > 80) & (V > 100);
if (countNonZero(bg_mask) < 200) return plate;
// 距离变换羽化
Mat bg_u8, dist;
bg_mask.convertTo(bg_u8, CV_8U, 255);
distanceTransform(bg_u8, dist, DIST_L2, DIST_MASK_PRECISE);
Mat soft;
dist.convertTo(soft, CV_32F, 1.0/4.0);
soft = max(0.0f, min(1.0f, soft));
H = H + h_shift * soft;
S = S.mul(1.0f + (s_scale / 100.0f - 1.0f) * soft);
V = V.mul(1.0f + (v_scale / 100.0f - 1.0f) * soft);
H.setTo(0, H < 0); H.setTo(180, H > 180);
S.setTo(0, S < 0); S.setTo(255, S > 255);
V.setTo(0, V < 0); V.setTo(255, V > 255);
Mat result;
merge(ch, hsv);
hsv.convertTo(hsv, CV_8U);
cvtColor(hsv, result, COLOR_HSV2BGR);
return result;
}
// ═══════════════════════════════════════════════════════════
// 透视回贴
// ═══════════════════════════════════════════════════════════
pair<Mat, Mat> warp_plate_back(const Mat& plate, const Point2f dst_corners[4],
Size output_size) {
int h = plate.rows, w = plate.cols;
Point2f src[4] = {
{0, 0}, {(float)(w-1), 0}, {(float)(w-1), (float)(h-1)}, {0, (float)(h-1)}
};
Mat M = getPerspectiveTransform(src, dst_corners);
Mat warped, mask_warped;
warpPerspective(plate, warped, M, output_size, INTER_LINEAR, BORDER_CONSTANT, Scalar(0,0,0));
Mat mask(h, w, CV_8UC1, Scalar(255));
warpPerspective(mask, mask_warped, M, output_size, INTER_LINEAR, BORDER_CONSTANT, Scalar(0));
return {warped, mask_warped};
}
// ═══════════════════════════════════════════════════════════
// 羽化贴合
// ═══════════════════════════════════════════════════════════
Mat feather_blend(const Mat& original, const Mat& warped,
const Mat& mask, int edge_width) {
Mat mask_u8;
mask.convertTo(mask_u8, CV_8U);
if (countNonZero(mask_u8) < 100) {
Mat result = original.clone();
warped.copyTo(result, mask_u8);
return result;
}
// Fast blend: erode mask slightly for edge transition, no expensive distanceTransform
Mat eroded;
int ksize = max(3, edge_width) | 1; // ensure odd
Mat kernel = getStructuringElement(MORPH_ELLIPSE, Size(ksize, ksize));
erode(mask_u8, eroded, kernel);
Mat alpha;
eroded.convertTo(alpha, CV_32F, 1.0/255.0);
// Soften edges with a small blur
GaussianBlur(alpha, alpha, Size(ksize, ksize), ksize/3.0);
Mat result(original.size(), CV_32FC3);
Mat warped_f, orig_f;
warped.convertTo(warped_f, CV_32F);
original.convertTo(orig_f, CV_32F);
for (int c = 0; c < 3; c++) {
Mat wc, oc;
extractChannel(warped_f, wc, c);
extractChannel(orig_f, oc, c);
Mat rc = wc.mul(alpha) + oc.mul(1.0f - alpha);
rc.setTo(0, rc < 0);
rc.setTo(255, rc > 255);
insertChannel(rc, result, c);
}
result.convertTo(result, CV_8U);
return result;
}
// ═══════════════════════════════════════════════════════════
// 噪声匹配
// ═══════════════════════════════════════════════════════════
Mat match_noise(const Mat& blended, const Mat& original, const Mat& mask) {
Mat mask_u8;
mask.convertTo(mask_u8, CV_8U);
if (countNonZero(mask_u8) < 100) return blended;
// Crop plate region for noise estimation (like Python's plate_crop)
vector<Point> pts;
findNonZero(mask_u8, pts);
if (pts.size() < 200) return blended;
int bw = blended.cols, bh = blended.rows;
int xmin = bw, xmax = 0, ymin = bh, ymax = 0;
for (auto& p : pts) {
if (p.x < xmin) xmin = p.x;
if (p.x > xmax) xmax = p.x;
if (p.y < ymin) ymin = p.y;
if (p.y > ymax) ymax = p.y;
}
int x1 = max(0, xmin - 5), x2 = min(bw, xmax + 5);
int y1 = max(0, ymin - 5), y2 = min(bh, ymax + 5);
Mat plate_crop = original(Rect(x1, y1, x2-x1, y2-y1));
// 1) Noise sigma from original plate crop
Mat crop_blurred, crop_residual;
GaussianBlur(plate_crop, crop_blurred, Size(5,5), 1.0);
Mat crop_f32, blur_f32;
plate_crop.convertTo(crop_f32, CV_32F);
crop_blurred.convertTo(blur_f32, CV_32F);
crop_residual = crop_f32 - blur_f32;
Scalar stddev;
meanStdDev(crop_residual, Scalar(), stddev);
float noise_sigma = (float)max(stddev[0], max(stddev[1], max(stddev[2], 1.5)));
// 2) Blur sigma from original plate crop
Mat crop_gray;
cvtColor(plate_crop, crop_gray, COLOR_BGR2GRAY);
Mat lap;
Laplacian(crop_gray, lap, CV_64F);
Scalar lap_m, lap_s;
meanStdDev(lap, lap_m, lap_s);
float lap_var = (float)(lap_s[0] * lap_s[0]);
float blur_sigma = 0;
if (lap_var < 50) blur_sigma = 2.0f;
else if (lap_var < 200) blur_sigma = 1.2f;
else if (lap_var < 500) blur_sigma = 0.5f;
// Apply blur to plate area
Mat result = blended.clone();
if (blur_sigma > 0.1f) {
Mat blurred_full;
GaussianBlur(result, blurred_full, Size(0,0), blur_sigma);
blurred_full.copyTo(result, mask_u8);
}
// Add noise to plate area only
Mat result_f;
result.convertTo(result_f, CV_32F);
Mat noise(result.size(), CV_32FC3);
randn(noise, Scalar(0,0,0), Scalar(noise_sigma, noise_sigma, noise_sigma));
Mat noisy = result_f + noise;
// Alpha blend: noise in plate area, clean outside
Mat alpha;
mask_u8.convertTo(alpha, CV_32F, 1.0/255.0);
Mat alpha_3;
merge(vector<Mat>{alpha, alpha, alpha}, alpha_3);
Mat one_minus_a;
subtract(Scalar::all(1.0), alpha_3, one_minus_a);
noisy = noisy.mul(alpha_3) + result_f.mul(one_minus_a);
// Clip AFTER blend (matching Python np.clip order)
noisy.setTo(0, noisy < 0);
noisy.setTo(255, noisy > 255);
noisy.convertTo(result, CV_8U);
return result;
}
Mat degrade_plate(const Mat& plate) {
Mat result = plate.clone();
// JPEG 极限压缩
vector<uint8_t> buf;
imencode(".jpg", result, buf, {IMWRITE_JPEG_QUALITY, 2});
result = imdecode(buf, IMREAD_COLOR);
// 降采样升采样
int w = result.cols, h = result.rows;
Mat small;
resize(result, small, Size(w/4, h/4), 0, 0, INTER_LINEAR);
resize(small, result, Size(w, h), 0, 0, INTER_NEAREST);
// 模糊
GaussianBlur(result, result, Size(5,5), 1.5);
return result;
}
// ═══════════════════════════════════════════════════════════
// 单张处理
// ═══════════════════════════════════════════════════════════
bool process_single(const string& img_path, const SwapConfig& cfg,
PlatePool& pool, mt19937& rng, const CCPDMeta* pre_meta) {
// 1. 解析文件名 (use pre-parsed if available)
fs::path fp(img_path);
CCPDMeta meta;
if (pre_meta && pre_meta->valid) {
meta = *pre_meta;
} else {
meta = parse_ccpd_filename(fp.filename().string());
if (!meta.valid) return false;
}
// 2. 读原图
Mat img_orig = imread_unicode(img_path);
if (img_orig.empty()) return false;
int h_img = img_orig.rows, w_img = img_orig.cols;
// 校验角点
for (int i = 0; i < 4; i++) {
auto& c = meta.corners[i];
if (c.x < 0 || c.y < 0 || c.x > w_img*1.5 || c.y > h_img*1.5)
return false;
}
// 3. Pick from pool
string bg_color = bg_color_of(cfg.plate_type);
const PlateEntry& entry = pool.random(cfg.plate_type, rng);
Mat synthetic = entry.image.clone();
int plate_w = synthetic.cols, plate_h = synthetic.rows;
string plate_number = entry.plate_number;
// 4. Extract original ROI
Mat orig_roi = extract_plate_roi(img_orig, meta.corners, plate_w, plate_h);
// 5. Fix bg color
synthetic = fix_bg_color(synthetic, bg_color,
cfg.h_shift, cfg.s_scale, cfg.v_scale);
// 6. Color match
Mat matched = match_plate_color(synthetic, orig_roi, bg_color);
// 7. Brightness match
Mat orig_gray, matched_gray;
cvtColor(orig_roi, orig_gray, COLOR_BGR2GRAY);
cvtColor(matched, matched_gray, COLOR_BGR2GRAY);
auto p95 = [](const Mat& m) -> float {
Mat sorted;
m.reshape(1,1).copyTo(sorted);
cv::sort(sorted, sorted, SORT_EVERY_ROW + SORT_ASCENDING);
return sorted.at<uint8_t>(0, (int)(sorted.cols * 0.95));
};
float target_w = p95(orig_gray);
float current_w = p95(matched_gray);
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; }
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);
Mat alpha;
matched_gray.convertTo(alpha, CV_32F, 1.0/128.0);
alpha = max(0.3f, min(1.0f, alpha));
Mat matched_f;
matched.convertTo(matched_f, CV_32F);
for (int c = 0; c < 3; c++) {
Mat ch;
extractChannel(matched_f, ch, c);
ch = ch.mul(1.0f + (l_ratio - 1.0f) * alpha);
ch.setTo(0, ch < 0); ch.setTo(255, ch > 255);
insertChannel(ch, matched_f, c);
}
matched_f.convertTo(matched, CV_8U);
}
// 8. Degradation
if (!cfg.fast_mode)
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
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 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);
return imwrite_unicode((out_dir / out_name).string(), final, {IMWRITE_JPEG_QUALITY, 75});
}
} // namespace ice
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include <opencv2/opencv.hpp>
#include <string>
#include <vector>
#include <random>
#include <unordered_map>
namespace ice {
// ── CCPD 角点解析 ──
struct CCPDMeta {
cv::Point2f corners[4]; // TL, TR, BR, BL
bool valid = false;
};
CCPDMeta parse_ccpd_filename(const std::string& filename);
// ── 合成车牌池 ──
struct PlateEntry {
cv::Mat image; // BGR
std::string plate_number; // 车牌号 (e.g. "粤Z1234港")
};
class PlatePool {
public:
void load(const std::string& dir);
void load_bin(const std::string& bin_path);
const PlateEntry& random(const std::string& plate_type, std::mt19937& rng);
size_t count(const std::string& plate_type) const;
private:
std::unordered_map<std::string, std::vector<PlateEntry>> pools_;
};
// ── 管线核心 ──
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 避免重复解析)
bool process_single(const std::string& img_path,
const SwapConfig& cfg,
PlatePool& pool,
std::mt19937& rng,
const CCPDMeta* pre_meta = nullptr);
// 透视提取车牌ROI
cv::Mat extract_plate_roi(const cv::Mat& img, const cv::Point2f corners[4],
int plate_w, int plate_h);
// 色温匹配
cv::Mat match_plate_color(const cv::Mat& synthetic, const cv::Mat& orig_roi,
const std::string& bg_color);
// 底板色校正
cv::Mat fix_bg_color(const cv::Mat& plate, const std::string& bg_color,
int h_shift, int s_scale, int v_scale);
// 透视回贴: 返回 (warped, mask)
std::pair<cv::Mat, cv::Mat> warp_plate_back(const cv::Mat& plate,
const cv::Point2f dst_corners[4],
cv::Size output_size);
// 羽化贴合
cv::Mat feather_blend(const cv::Mat& original, const cv::Mat& warped,
const cv::Mat& mask, int edge_width = 8);
// 噪声匹配
cv::Mat match_noise(const cv::Mat& blended, const cv::Mat& original,
const cv::Mat& mask);
// 退化模拟
cv::Mat degrade_plate(const cv::Mat& plate);
// ── 工具 ──
cv::Mat imread_unicode(const std::string& path);
bool imwrite_unicode(const std::string& path, const cv::Mat& img,
const std::vector<int>& params = {});
} // namespace ice
+295
View File
@@ -0,0 +1,295 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
车牌底板色手动调色 GUI
======================
用 HSV 滑块实时调整合成车牌底板色,预览效果,保存参数供管线调用。
用法:
python color_tuner.py
控件:
H 偏移 — 色相偏移量 (-30 ~ +30)
S 缩放%% — 饱和度缩放 (10% ~ 150%)
V 缩放%% — 明度缩放 (10% ~ 150%)
[1-9] — 切换车牌类型
[R] — 复位
[S] — 保存当前参数到 plate_color_profile.py
[ESC] — 退出
"""
import sys
from pathlib import Path
import cv2
import numpy as np
# ── 路径 ──
_SCRIPT_DIR = Path(__file__).resolve().parent
_PROJECT_DIR = _SCRIPT_DIR.parent.parent
_GENERATOR_DIR = _PROJECT_DIR / 'chinese_license_plate_generator'
sys.path.insert(0, str(_GENERATOR_DIR))
from generate_multi_plate import MultiPlateGenerator
from plate_number import (
generate_plate_number_white,
generate_plate_number_army,
generate_plate_number_black_gangao,
generate_plate_number_black_shi_old,
generate_plate_number_black_shi_new,
generate_plate_number_black_ling_old,
generate_plate_number_black_ling_new,
generate_plate_number_yellow_xue,
generate_plate_number_blue,
)
# ── 车牌类型 ──
PLATE_TYPES = [
('yellow', '教练车', generate_plate_number_yellow_xue),
('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),
('black_shi', '使馆旧', generate_plate_number_black_shi_old),
('black_shi', '使馆新', generate_plate_number_black_shi_new),
('black', '领馆旧', generate_plate_number_black_ling_old),
('black', '领馆新', generate_plate_number_black_ling_new),
]
# ── 参考图 ──
REF_PATH = Path(r"C:\Users\Kirl\Downloads\R-C (3).jpg")
# ── 全局状态 ──
generator = None
current_type_idx = 0
synthetic_plate = None
ref_image = None
# 各类型的默认参数 (H偏移, S缩放%, V缩放%)
params = {}
def init_generator():
global generator
pm = str(_GENERATOR_DIR / 'plate_model')
fm = str(_GENERATOR_DIR / 'font_model')
generator = MultiPlateGenerator(pm, fm)
def load_ref():
global ref_image
if REF_PATH.is_file():
ref = cv2.imread(str(REF_PATH))
if ref is not None:
h, w = ref.shape[:2]
ref = cv2.resize(ref, (int(w * 0.6), int(h * 0.6)))
ref_image = ref
def generate_plate(type_idx):
global synthetic_plate
bg_color, label, gen_func = PLATE_TYPES[type_idx]
number = gen_func()
plate_w = 480 if len(number) == 8 else 440
plate = generator.generate_plate_special(number, bg_color, is_double=False, enhance=False)
synthetic_plate = plate
return plate, number, label
def load_params(bg_color):
"""从已保存的 profile 加载参数"""
key = bg_color
if key not in params:
params[key] = {'h_shift': 0, 's_scale': 100, 'v_scale': 100}
return params[key]
def adjust_plate(plate_bgr, bg_color, h_shift, s_scale, v_scale):
"""
在 HSV 空间调整底板色。
仅作用于底板区域(高S + 对应色相范围 + 高V),字符不受影响。
"""
if h_shift == 0 and s_scale == 100 and v_scale == 100:
return plate_bgr.copy()
hue_ranges = {
'yellow': (15, 50), 'green_truck': (40, 85),
'white': (0, 180), 'white_army': (0, 180),
'black': (0, 180), 'black_shi': (0, 180),
}
h_lo, h_hi = hue_ranges.get(bg_color, (0, 180))
hsv = cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2HSV).astype(np.float32)
h, s, v = hsv[:, :, 0], hsv[:, :, 1], hsv[:, :, 2]
# 底板区域 mask
hue_in = (h > h_lo) & (h < h_hi)
bg_mask = hue_in & (s > 80) & (v > 100)
if bg_mask.sum() < 200:
return plate_bgr.copy()
# 羽化
bg_u8 = bg_mask.astype(np.uint8) * 255
dist = cv2.distanceTransform(bg_u8, cv2.DIST_L2, cv2.DIST_MASK_PRECISE)
soft = np.clip(dist / 4.0, 0.0, 1.0)
h_new = h + h_shift * soft
s_new = s * (1.0 + (s_scale / 100.0 - 1.0) * soft)
v_new = v * (1.0 + (v_scale / 100.0 - 1.0) * soft)
hsv[:, :, 0] = np.clip(h_new, 0, 180)
hsv[:, :, 1] = np.clip(s_new, 0, 255)
hsv[:, :, 2] = np.clip(v_new, 0, 255)
return cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2BGR)
def refresh():
global synthetic_plate
_, number, label = generate_plate(current_type_idx)
update_display()
def update_display(*_args):
bg_color, label, _ = PLATE_TYPES[current_type_idx]
p = load_params(bg_color)
# trackbar 可能在创建过程中尚未就绪,容错读取
try:
h_shift = cv2.getTrackbarPos('H offset', 'Color Tuner') - 30
s_scale = cv2.getTrackbarPos('S scale', 'Color Tuner')
v_scale = cv2.getTrackbarPos('V scale', 'Color Tuner')
except cv2.error:
h_shift = p.get('h_shift', 0)
s_scale = p.get('s_scale', 100)
v_scale = p.get('v_scale', 100)
p['h_shift'] = h_shift
p['s_scale'] = s_scale
p['v_scale'] = v_scale
adjusted = adjust_plate(synthetic_plate, bg_color, h_shift, s_scale, v_scale)
# 并排显示:原始 | 调整后
h, w = synthetic_plate.shape[:2]
display = np.hstack([synthetic_plate, adjusted])
# 加标签
cv2.putText(display, f"Original ({bg_color})", (10, 25),
cv2.FONT_HERSHEY_SIMPLEX, 0.55, (200, 200, 200), 1)
cv2.putText(display, f"Adjusted H:{h_shift:+d} S:{s_scale} V:{v_scale}",
(w + 10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 200), 1)
# 类型标签
cv2.putText(display, f"[{current_type_idx+1}/9] {label}",
(10, display.shape[0] - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (180, 180, 180), 1)
# 如果参考图存在,放在下方
if ref_image is not None:
rw = display.shape[1]
rh = ref_image.shape[0] * rw // ref_image.shape[1]
ref_rs = cv2.resize(ref_image, (rw, rh))
display = np.vstack([display, np.ones((4, rw, 3), dtype=np.uint8) * 30, ref_rs])
cv2.putText(display, "Reference", (10, display.shape[0] - rh + 25),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 1)
cv2.imshow('Color Tuner', display)
def save_params():
"""保存当前所有类型的参数到 plate_color_profile.py"""
profile_path = _SCRIPT_DIR / 'plate_color_profile.py'
lines = ['# Auto-generated by color_tuner.py',
'# Plate background color correction profiles',
'# 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']:
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('}')
lines.append('')
profile_path.write_text('\n'.join(lines), encoding='utf-8')
print(f"\n[SAVED] {profile_path}")
print(f" Params: { {k: v for k, v in params.items()} }")
def on_key(key):
global current_type_idx, synthetic_plate
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('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}
# Reset trackbars
cv2.setTrackbarPos('H offset', 'Color Tuner', 30)
cv2.setTrackbarPos('S scale', 'Color Tuner', 100)
cv2.setTrackbarPos('V scale', 'Color Tuner', 100)
update_display()
elif key == ord('s') or key == ord('S'):
save_params()
elif key == ord('n') or key == ord('N'):
refresh()
def main():
global ref_image
init_generator()
load_ref()
# 初始化第一张车牌
_, number, label = generate_plate(current_type_idx)
# 先建窗口和滑动条,再刷新显示
cv2.namedWindow('Color Tuner', cv2.WINDOW_NORMAL)
cv2.resizeWindow('Color Tuner', 1000, 500)
bg_color = PLATE_TYPES[current_type_idx][0]
p = load_params(bg_color)
cv2.createTrackbar('H offset', 'Color Tuner', p['h_shift'] + 30, 60, update_display)
cv2.createTrackbar('S scale', 'Color Tuner', p['s_scale'], 150, update_display)
cv2.createTrackbar('V scale', 'Color Tuner', p['v_scale'], 150, update_display)
update_display()
print("=" * 60)
print(" 车牌底板色手动调色 GUI")
print("=" * 60)
print(" 按键:")
print(" 1-9 切换车牌类型")
print(" R 复位当前类型参数")
print(" N 生成新随机车牌")
print(" S 保存参数到 plate_color_profile.py")
print(" ESC 退出")
print("=" * 60)
print(f" 当前类型: {PLATE_TYPES[current_type_idx][1]}")
print("=" * 60)
while True:
key = cv2.waitKey(50) & 0xFF
if key == 27: # ESC
break
if key != 255:
on_key(key)
cv2.destroyAllWindows()
if __name__ == '__main__':
import os
os.chdir(str(_PROJECT_DIR))
main()
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CCPD 真实车牌 ROI 提取工具
==========================
从 CCPD 图片中透视校正并裁剪车牌区域,保存为统一尺寸的 ROI 图片。
用于 CycleGAN/CUT 风格对齐训练的真实车牌域数据。
用法:
python extract_real_plates.py --ccpd-dir ./CCPD/CCPD2019/ccpd_base --num 500 --output ./gan_data/real_plates
输出:
440×140 的校正车牌 ROI (BGR .jpg)
"""
import os
import sys
import argparse
import random
from pathlib import Path
from typing import Optional, Tuple
import cv2
import numpy as np
from tqdm import tqdm
def parse_ccpd_corners(filename: str) -> Optional[np.ndarray]:
"""
从 CCPD 文件名解析4角点,几何排序为 [TL, TR, BR, BL]。
"""
base = Path(filename).stem
parts = base.split('-')
if len(parts) < 5:
return None
try:
coords_str = parts[3]
pts = []
for pt in coords_str.split('_'):
x, y = pt.split('&')
pts.append((float(x), float(y)))
if len(pts) != 4:
return None
a = np.array(pts)
sums = a[:, 0] + a[:, 1]
diffs = a[:, 0] - a[:, 1]
tl = a[np.argmin(sums)]
tr = a[np.argmax(diffs)]
br = a[np.argmax(sums)]
bl = a[np.argmin(diffs)]
return np.float32([tl, tr, br, bl])
except (ValueError, IndexError):
return None
def extract_plate_roi(image_bgr: np.ndarray,
corners: np.ndarray,
output_size: Tuple[int, int] = (440, 140)) -> Optional[np.ndarray]:
"""
透视校正提取车牌 ROI。
"""
h, w = image_bgr.shape[:2]
# 校验角点在图像范围内
if corners.min() < -10 or corners[:, 0].max() > w + 10 or corners[:, 1].max() > h + 10:
return None
dst = np.float32([
[0, 0],
[output_size[0] - 1, 0],
[output_size[0] - 1, output_size[1] - 1],
[0, output_size[1] - 1],
])
M = cv2.getPerspectiveTransform(corners, dst)
roi = cv2.warpPerspective(image_bgr, M, output_size,
flags=cv2.INTER_CUBIC,
borderMode=cv2.BORDER_REPLICATE)
return roi
def main():
parser = argparse.ArgumentParser(description='CCPD 真实车牌 ROI 提取')
parser.add_argument('--ccpd-dir', type=str, required=True,
help='CCPD 图片目录(多个用逗号分隔)')
parser.add_argument('--num', type=int, default=500,
help='提取数量 (默认: 500)')
parser.add_argument('--output', type=str, default='./gan_data/real_plates',
help='输出目录')
parser.add_argument('--seed', type=int, default=42)
args = parser.parse_args()
random.seed(args.seed)
np.random.seed(args.seed)
# 收集图片
ccpd_dirs = [d.strip() for d in args.ccpd_dir.split(',') if d.strip()]
all_images = []
for d in ccpd_dirs:
dd = Path(d)
if not dd.is_dir():
print(f"[WARN] 目录不存在: {d}")
continue
for ext in ('*.jpg', '*.jpeg', '*.png'):
all_images.extend(dd.glob(ext))
random.shuffle(all_images)
print(f"CCPD 图片总数: {len(all_images)}")
print(f"目标提取数: {args.num}")
print(f"输出目录: {args.output}")
print("-" * 50)
os.makedirs(args.output, exist_ok=True)
success = 0
for img_path in tqdm(all_images, desc='提取真实车牌ROI', unit='张'):
if success >= args.num:
break
corners = parse_ccpd_corners(img_path.name)
if corners is None:
continue
img = cv2.imread(str(img_path))
if img is None:
continue
roi = extract_plate_roi(img, corners)
if roi is None:
continue
out_name = f"real_{img_path.stem}.jpg"
cv2.imwrite(os.path.join(args.output, out_name), roi,
[cv2.IMWRITE_JPEG_QUALITY, 95])
success += 1
print(f"\n完成! 提取: {success} 张")
if __name__ == '__main__':
main()
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
生成纯合成车牌(无场景背景)作为 CUT Domain A
============================================
独立于 CCPD 原图,直接调用车牌生成器渲染纯车牌图像。
"""
import sys
import os
import random
from pathlib import Path
import cv2
import numpy as np
# 路径解析:定位 chinese_license_plate_generator
_SCRIPT_DIR = Path(__file__).resolve().parent # .../CCPD_SPE_GEN/cv_pipeline/
_PROJECT_DIR = _SCRIPT_DIR.parent.parent # .../ICE_Dataset/
_GENERATOR_DIR = _PROJECT_DIR / 'chinese_license_plate_generator'
if str(_GENERATOR_DIR) not in sys.path:
sys.path.insert(0, str(_GENERATOR_DIR))
from generate_multi_plate import MultiPlateGenerator
random.seed(42)
np.random.seed(42)
g = MultiPlateGenerator(
str(_GENERATOR_DIR / 'plate_model'),
str(_GENERATOR_DIR / 'font_model')
)
out = 'gan_data/domain_A_synthetic/police_pure'
os.makedirs(out, exist_ok=True)
for i in range(500):
num, bg, _ = g.generate_plate_number()
# 强制police类型
if '警' not in num:
num = num[:6] + '警'
img = g.generate_plate_special(num, 'white', False)
cv2.imwrite(f'{out}/synth_{i:04d}.jpg', img)
print(f'Done: {out} ({len(os.listdir(out))} images)')
@@ -0,0 +1,12 @@
# Auto-generated by color_tuner.py
# Plate background color correction profiles
# Format: (h_shift, s_scale_percent, v_scale_percent)
PLATE_COLOR_PROFILE = {
'yellow': (-2, 150, 100),
'green_truck': (0, 100, 100),
'white': (0, 100, 100),
'white_army': (0, 100, 100),
'black': (0, 100, 100),
'black_shi': (0, 100, 100),
}
File diff suppressed because it is too large. Load diff
+131
View File
@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
"""
L通道车牌数据集加载器
=====================
Domain A: 合成车牌 (140×440) → 提取 L 通道
Domain B: 真实车牌 (140×440) → 提取 L 通道
"""
import os
import random
from pathlib import Path
from typing import Tuple
import cv2
import numpy as np
import torch
from torch.utils.data import Dataset
class LChannelPlateDataset(Dataset):
"""
车牌 L 通道数据集。
Args:
root: 图片根目录
img_size: 统一输出尺寸 (W, H)
is_real: True=真实车牌域, False=合成车牌域
"""
def __init__(self, root: str, img_size=(440, 140), is_real=False):
self.root = Path(root)
self.img_size = img_size
self.is_real = is_real
self.paths = []
if self.root.is_dir():
for ext in ('*.jpg', '*.jpeg', '*.png'):
self.paths.extend(self.root.glob(ext))
def __len__(self):
return len(self.paths)
def __getitem__(self, idx) -> torch.Tensor:
path = self.paths[idx]
img = cv2.imread(str(path))
if img is None:
return self.__getitem__((idx + 1) % len(self))
# 统一尺寸
img = cv2.resize(img, self.img_size, interpolation=cv2.INTER_LINEAR)
# 提取 L 通道: BGR → LAB → L
lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
l_channel = lab[:, :, 0].astype(np.float32) # (H, W) [0, 255]
# 归一化到 [-1, 1]
l_channel = (l_channel / 127.5) - 1.0
return torch.from_numpy(l_channel).unsqueeze(0) # (1, H, W)
def make_loader(root: str, batch_size=4, img_size=(440, 140),
is_real=False, shuffle=True, num_workers=2):
"""创建 DataLoader"""
from torch.utils.data import DataLoader
ds = LChannelPlateDataset(root, img_size, is_real)
return DataLoader(ds, batch_size=batch_size, shuffle=shuffle,
num_workers=num_workers, pin_memory=True,
drop_last=True)
class LChannelInference:
"""
L通道 CUT 推理封装:合成车牌 → 真实风格车牌。
用法:
infer = LChannelInference('checkpoint.pth')
result_bgr = infer(synthetic_plate_bgr)
"""
def __init__(self, checkpoint_path: str, device=None, n_res=6):
from models import LChannelGenerator, init_weights
self.device = device or torch.device(
"cuda" if torch.cuda.is_available() else "cpu"
)
self.G = LChannelGenerator(n_res=n_res).to(self.device)
ckpt = torch.load(checkpoint_path, map_location=self.device)
self.G.load_state_dict(ckpt.get('G_state', ckpt))
self.G.eval()
# 期望输入尺寸
self.img_size = (440, 140)
def __call__(self, plate_bgr: np.ndarray) -> np.ndarray:
"""
Args:
plate_bgr: (H, W, 3) 合成车牌 BGR
Returns:
真实风格车牌 BGR (H, W, 3)
"""
h, w = plate_bgr.shape[:2]
img = cv2.resize(plate_bgr, self.img_size, interpolation=cv2.INTER_LINEAR)
# BGR → LAB
lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB).astype(np.float32)
# L → tensor → G → tensor → L
l_tensor = torch.from_numpy(lab[:, :, 0] / 127.5 - 1.0)
l_tensor = l_tensor.unsqueeze(0).unsqueeze(0).to(self.device) # (1,1,H,W)
with torch.no_grad():
l_out = self.G(l_tensor).squeeze().cpu().numpy() # (H, W)
# 还原 L 到 [0, 255]
l_out = (l_out + 1.0) * 127.5
l_out = np.clip(l_out, 0, 255)
lab[:, :, 0] = l_out
# a, b 通道不变 → 保护颜色
result = cv2.cvtColor(lab.astype(np.uint8), cv2.COLOR_LAB2BGR)
# 还原原始尺寸
if (h, w) != (self.img_size[1], self.img_size[0]):
result = cv2.resize(result, (w, h), interpolation=cv2.INTER_LINEAR)
return result
+217
View File
@@ -0,0 +1,217 @@
# -*- coding: utf-8 -*-
"""
CUT L通道 模型定义
==================
轻量级 Generator + PatchGAN Discriminator + PatchNCE 对比损失。
仅处理单通道 L (亮度) 图像,输入尺寸 140×440。
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
# ============================================================
# Generator: UNet 风格 encoder-decoder
# ============================================================
class ResBlock(nn.Module):
"""残差块"""
def __init__(self, ch):
super().__init__()
self.conv = nn.Sequential(
nn.ReflectionPad2d(1),
nn.Conv2d(ch, ch, 3),
nn.InstanceNorm2d(ch),
nn.ReLU(True),
nn.ReflectionPad2d(1),
nn.Conv2d(ch, ch, 3),
nn.InstanceNorm2d(ch),
)
def forward(self, x):
return x + self.conv(x)
class LChannelGenerator(nn.Module):
"""
L通道生成器: 1ch → 1ch
输入: (B, 1, 140, 440) 合成车牌L通道
输出: (B, 1, 140, 440) 真实风格L通道
"""
def __init__(self, n_res=6):
super().__init__()
# Encoder
self.enc1 = self._conv_block(1, 64, 7, 1, 3) # 140×440
self.enc2 = self._conv_block(64, 128, 3, 2, 1) # 70×220
self.enc3 = self._conv_block(128, 256, 3, 2, 1) # 35×110
# Bottleneck
self.res_blocks = nn.Sequential(*[ResBlock(256) for _ in range(n_res)])
# Decoder
self.dec2 = self._upconv_block(256, 128) # 70×220
self.dec1 = self._upconv_block(128, 64) # 140×440
self.out = nn.Sequential(
nn.ReflectionPad2d(3),
nn.Conv2d(64, 1, 7),
nn.Tanh(),
)
def _conv_block(self, ci, co, k, s, p):
return nn.Sequential(
nn.ReflectionPad2d(p),
nn.Conv2d(ci, co, k, s),
nn.InstanceNorm2d(co),
nn.ReLU(True),
)
def _upconv_block(self, ci, co):
return nn.Sequential(
nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True),
nn.ReflectionPad2d(1),
nn.Conv2d(ci, co, 3),
nn.InstanceNorm2d(co),
nn.ReLU(True),
)
def forward(self, x, return_features=False):
"""
Args:
x: (B, 1, H, W) 归一化到 [-1, 1]
return_features: 返回中间特征用于 PatchNCE loss
"""
features = []
e1 = self.enc1(x) # (B, 64, 140, 440)
features.append(e1)
e2 = self.enc2(e1) # (B, 128, 70, 220)
features.append(e2)
e3 = self.enc3(e2) # (B, 256, 35, 110)
features.append(e3)
b = self.res_blocks(e3) # (B, 256, 35, 110)
d2 = self.dec2(b) # (B, 128, 70, 220)
d1 = self.dec1(d2) # (B, 64, 140, 440)
out = self.out(d1) # (B, 1, 140, 440)
if return_features:
return out, features
return out
# ============================================================
# Discriminator: PatchGAN 70×70
# ============================================================
class PatchGANDiscriminator(nn.Module):
"""
PatchGAN 判别器,70×70 感受野。
输入: (B, 1, 140, 440) L通道
输出: (B, 1, H/16, W/16) 真假概率图
"""
def __init__(self):
super().__init__()
self.model = nn.Sequential(
nn.Conv2d(1, 64, 4, 2, 1), # 70×220
nn.LeakyReLU(0.2, True),
nn.Conv2d(64, 128, 4, 2, 1), # 35×110
nn.InstanceNorm2d(128),
nn.LeakyReLU(0.2, True),
nn.Conv2d(128, 256, 4, 2, 1), # 17×55
nn.InstanceNorm2d(256),
nn.LeakyReLU(0.2, True),
nn.Conv2d(256, 512, 4, 1, 1), # 16×54
nn.InstanceNorm2d(512),
nn.LeakyReLU(0.2, True),
nn.Conv2d(512, 1, 4, 1, 1), # 15×53
)
def forward(self, x):
return self.model(x)
# ============================================================
# PatchNCE 对比学习
# ============================================================
class PatchNCELoss(nn.Module):
"""
PatchNCE 对比损失。
对编码器的每一层特征图做 patch-wise 对比学习。
"""
def __init__(self, nce_layers=(0, 1, 2), nce_t=0.07, n_patches=256):
super().__init__()
self.nce_layers = nce_layers
self.nce_t = nce_t
self.n_patches = n_patches
self.cross_entropy = nn.CrossEntropyLoss()
def forward(self, feat_q, feat_k):
"""
Args:
feat_q: 生成图编码特征列表 [(B,C,H,W), ...]
feat_k: 原图编码特征列表 [(B,C,H,W), ...]
Returns:
loss: scalar
"""
total_loss = 0.0
n_layers = 0
for layer_idx in self.nce_layers:
if layer_idx >= len(feat_q) or layer_idx >= len(feat_k):
continue
fq = feat_q[layer_idx] # (B, C, H, W)
fk = feat_k[layer_idx] # (B, C, H, W)
B, C, H, W = fq.shape
n_patches = min(self.n_patches, H * W)
# 随机采样 patch 位置
idx_h = torch.randint(0, H, (n_patches,), device=fq.device)
idx_w = torch.randint(0, W, (n_patches,), device=fq.device)
# query patches
q = fq[:, :, idx_h, idx_w] # (B, C, N)
q = q.permute(0, 2, 1).reshape(B * n_patches, C) # (B*N, C)
# key patches (positive pairs)
k = fk[:, :, idx_h, idx_w] # (B, C, N)
k = k.permute(0, 2, 1).reshape(B * n_patches, C) # (B*N, C)
# 负样本:同一 batch 内其他图片的同一位置
q_norm = F.normalize(q, dim=1) # (B*N, C)
k_norm = F.normalize(k, dim=1) # (B*N, C)
# 计算相似度矩阵
logits = torch.mm(q_norm, k_norm.T) / self.nce_t # (B*N, B*N)
labels = torch.arange(B * n_patches, device=q.device)
loss = self.cross_entropy(logits, labels)
total_loss += loss
n_layers += 1
return total_loss / max(n_layers, 1)
# ============================================================
# 模型工具
# ============================================================
def init_weights(model):
"""Xavier 初始化"""
for m in model.modules():
if isinstance(m, (nn.Conv2d, nn.ConvTranspose2d)):
nn.init.xavier_normal_(m.weight, 0.02)
if m.bias is not None:
nn.init.constant_(m.bias, 0)
def count_params(model):
return sum(p.numel() for p in model.parameters() if p.requires_grad)
@@ -0,0 +1,6 @@
torch>=1.12
torchvision
opencv-python>=4.5
numpy
tqdm
tensorboard
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CUT L通道 训练脚本
==================
仅对车牌 L (亮度) 通道做纹理迁移训练,a,b 颜色通道不动。
用法:
python train.py \
--domain-a ./gan_data/domain_A_synthetic/police \
--domain-b ./gan_data/domain_B_real \
--epochs 100 \
--batch 4 \
--output ./output/cut_police
"""
import os
import sys
import argparse
import time
from pathlib import Path
import torch
import torch.nn as nn
import torch.optim as optim
# 添加 tools 目录到路径
sys.path.insert(0, str(Path(__file__).resolve().parent))
from models import (
LChannelGenerator, PatchGANDiscriminator,
PatchNCELoss, init_weights, count_params,
)
from dataset import make_loader
def train(args):
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {device}")
# ---- 数据 ----
loader_a = make_loader(args.domain_a, args.batch, (args.img_w, args.img_h),
is_real=False, num_workers=args.workers)
loader_b = make_loader(args.domain_b, args.batch, (args.img_w, args.img_h),
is_real=True, num_workers=args.workers)
print(f"Domain A (合成): {len(loader_a.dataset)} 张")
print(f"Domain B (真实): {len(loader_b.dataset)} 张")
print(f"Batch size: {args.batch}")
print(f"Epochs: {args.epochs}")
# ---- 模型 ----
G = LChannelGenerator(n_res=args.n_res).to(device)
D = PatchGANDiscriminator().to(device)
init_weights(G)
init_weights(D)
print(f"G params: {count_params(G):,}")
print(f"D params: {count_params(D):,}")
# ---- 损失 & 优化器 ----
criterion_gan = nn.MSELoss() # LSGAN
criterion_nce = PatchNCELoss(
nce_layers=args.nce_layers,
nce_t=args.nce_t,
n_patches=args.n_patches,
).to(device)
criterion_idt = nn.L1Loss()
opt_G = optim.Adam(G.parameters(), lr=args.lr, betas=(0.5, 0.999))
opt_D = optim.Adam(D.parameters(), lr=args.lr, betas=(0.5, 0.999))
# ---- 日志 ----
os.makedirs(args.output, exist_ok=True)
# ---- 训练循环 ----
for epoch in range(1, args.epochs + 1):
epoch_start = time.time()
G_loss_total = 0.0
D_loss_total = 0.0
n_batches = 0
for real_a, real_b in zip(loader_a, loader_b):
real_a = real_a.to(device) # (B, 1, H, W) 合成L
real_b = real_b.to(device) # (B, 1, H, W) 真实L
B = real_a.size(0)
# ---- 1. 更新 D ----
fake_b = G(real_a).detach()
# G 不用返回特征
pred_real = D(real_b)
pred_fake = D(fake_b)
loss_D_real = criterion_gan(pred_real,
torch.ones_like(pred_real))
loss_D_fake = criterion_gan(pred_fake,
torch.zeros_like(pred_fake))
loss_D = (loss_D_real + loss_D_fake) * 0.5
opt_D.zero_grad()
loss_D.backward()
opt_D.step()
# ---- 2. 更新 G ----
fake_b, feat_q = G(real_a, return_features=True)
# GAN loss
pred_fake = D(fake_b)
loss_G_gan = criterion_gan(pred_fake,
torch.ones_like(pred_fake))
# PatchNCE loss (对比学习)
_, feat_k = G(real_b, return_features=True)
loss_G_nce = criterion_nce(feat_q, feat_k)
# Identity loss: 裁剪输出到输入尺寸(UNet可能多出几像素)
identity = G(real_a)
if identity.shape != real_a.shape:
identity = identity[:, :, :real_a.shape[2], :real_a.shape[3]]
loss_G_idt = criterion_idt(identity, real_a) * args.lambda_idt
loss_G = loss_G_gan + loss_G_nce * args.lambda_nce + loss_G_idt
opt_G.zero_grad()
loss_G.backward()
opt_G.step()
G_loss_total += loss_G.item()
D_loss_total += loss_D.item()
n_batches += 1
# ---- Epoch 统计 ----
G_avg = G_loss_total / max(n_batches, 1)
D_avg = D_loss_total / max(n_batches, 1)
elapsed = time.time() - epoch_start
print(f"Epoch {epoch:3d}/{args.epochs} | "
f"G: {G_avg:.4f} D: {D_avg:.4f} | "
f"{elapsed:.1f}s")
pass # 日志仅print,不使用tensorboard
# ---- 保存 ----
if epoch % args.save_every == 0:
ckpt = {
'epoch': epoch,
'G_state': G.state_dict(),
'D_state': D.state_dict(),
'opt_G': opt_G.state_dict(),
'opt_D': opt_D.state_dict(),
}
torch.save(ckpt, os.path.join(args.output, f'epoch_{epoch}.pth'))
# 最终保存
final_ckpt = {
'G_state': G.state_dict(),
'D_state': D.state_dict(),
}
torch.save(final_ckpt, os.path.join(args.output, 'final.pth'))
print(f"\n训练完成! 模型保存在: {args.output}")
def main():
parser = argparse.ArgumentParser(description='CUT L通道训练')
# 数据
parser.add_argument('--domain-a', type=str, required=True,
help='合成车牌目录')
parser.add_argument('--domain-b', type=str, required=True,
help='真实车牌目录')
# 训练
parser.add_argument('--epochs', type=int, default=100)
parser.add_argument('--batch', type=int, default=4)
parser.add_argument('--lr', type=float, default=2e-4)
parser.add_argument('--workers', type=int, default=2)
# 模型
parser.add_argument('--n-res', type=int, default=6,
help='残差块数量')
# 损失权重
parser.add_argument('--lambda-nce', type=float, default=1.0)
parser.add_argument('--lambda-idt', type=float, default=0.5)
# PatchNCE
parser.add_argument('--nce-layers', type=int, nargs='+', default=[0, 1, 2])
parser.add_argument('--nce-t', type=float, default=0.07)
parser.add_argument('--n-patches', type=int, default=128)
# 保存
parser.add_argument('--output', type=str, default='./output/cut_train')
parser.add_argument('--img-w', type=int, default=220)
parser.add_argument('--img-h', type=int, default=70)
parser.add_argument('--save-every', type=int, default=20)
args = parser.parse_args()
train(args)
if __name__ == '__main__':
main()
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# ============================================================
# GAN 数据准备:合成车牌(A) + 真实车牌(B)
# 用法: cd ICE_Dataset && bash CCPD_SPE_GEN/scripts/gan_data_prep.sh
# ============================================================
source /home/huawei/rknn_env/bin/activate # TODO: 替换为你的虚拟环境路径
cd /mnt/d/ICEv2/ICE_Dataset # TODO: 替换为你的 ICE_Dataset 路径
OUT_A=./gan_data/domain_A_synthetic
OUT_B=./gan_data/domain_B_real
CCPD=./CCPD/CCPD2019/ccpd_base,./CCPD/CCPD2019/ccpd_tilt,./CCPD/CCPD2019/ccpd_rotate,./CCPD/CCPD2019/ccpd_fn
echo "============================================"
echo " Phase 1: 生成合成车牌 (9类型 x 1000张)"
echo "============================================"
for p in police army gangao shiguan_old shiguan_new lingguan_old lingguan_new coach new_energy; do
echo "--- $p ---"
python3 CCPD_SPE_GEN/cv_pipeline/plate_swap.py \
--ccpd-dir ./CCPD/CCPD2019/ccpd_base \
--plate-type $p \
--num 1000 \
--output $OUT_A/$p \
--seed 42 2>&1 | tail -2
done
echo ""
echo "============================================"
echo " Phase 2: 提取真实车牌ROI (1000张)"
echo "============================================"
python3 CCPD_SPE_GEN/cv_pipeline/extract_real_plates.py \
--ccpd-dir "$CCPD" \
--num 1000 \
--output $OUT_B \
--seed 42
echo ""
echo "============================================"
echo " GAN 数据准备完成!"
echo " Domain A (合成): $OUT_A"
echo " Domain B (真实): $OUT_B"
echo "============================================"
ls -l $OUT_A/*/ | wc -l
ls -l $OUT_B/ | wc -l
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# 一键预览全部 9 种特殊车牌类型 (每种 2 张,保存中间结果)
# 用法: cd ICE_Dataset && bash CCPD_SPE_GEN/scripts/preview_all.sh
source /home/huawei/rknn_env/bin/activate # TODO: 替换为你的虚拟环境
cd /mnt/d/ICEv2/ICE_Dataset # TODO: 替换为你的 ICE_Dataset 路径
for p in police army gangao shiguan_old shiguan_new lingguan_old lingguan_new coach new_energy; do
echo "=== $p ==="
python3 CCPD_SPE_GEN/cv_pipeline/plate_swap.py --ccpd-dir ./CCPD/CCPD2019/ccpd_base --plate-type $p --num 2 --output ./output/preview/$p --preview --seed 42 2>&1 | tail -3
echo ""
done
echo "DONE"
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
# 快速测试:生成 20 张警车特殊车牌
# 用法: cd ICE_Dataset && bash CCPD_SPE_GEN/scripts/quick_test.sh
source /home/huawei/rknn_env/bin/activate # TODO: 替换为你的虚拟环境
cd /mnt/d/ICEv2/ICE_Dataset # TODO: 替换为你的 ICE_Dataset 路径
python3 CCPD_SPE_GEN/cv_pipeline/plate_swap.py --ccpd-dir ./CCPD/CCPD2019/ccpd_base --plate-type police --num 20 --output ./output/preview/police --seed 42 2>&1 | tail -2
echo DONE