很多人学完深度学习后,都会处在一个很尴尬的位置:课程能听懂,Notebook 能跑通,Ultralytics 示例也能顺利框出几个目标,但一旦脱离练习环境,让自己独立搭一套能处理真实图片的 CV 系统,就不知道该从哪里下手。YOLOv11 古籍上色项目正好用来补上这个鸿沟。它不是一个跑通 demo 就结束的小练习,而是把目标检测、图像生成、数据标注、结果评估和模型部署放在同一条链路上。这套系统的核心思路是:YOLOv11 负责从古籍扫描图中找出需要上色的插画、印章、文字等区域,另一个上色模型负责生成颜色,再通过后处理把彩色结果拼回原图。最终你得到的是一套可复现、可排查、可扩展的完整 CV 系统,而不是一个只能对固定图片产生输出的单点函数。
1. 为什么用 YOLOv11 做古籍上色:先把系统架构想清楚
1.1 YOLOv11 不会上色,它负责的是版面元素检测
看到“YOLOv11 古籍上色项目”这个标题,很多人会误以为 YOLOv11 本身能把黑白古籍变成彩色古籍。这是一个需要立刻纠正的预期。
YOLOv11 是一个目标检测模型,它的输出是“图像里有哪些目标、每个目标在哪个位置、属于哪个类别”。例如它可以在一张古籍扫描图中标出:
- 插画区域:坐标框
x1,y1,x2,y2,类别illustration - 文字区域:坐标框,类别
text_region - 印章区域:坐标框,类别
seal - 破损区域:坐标框,类别
damage
而上色是图像生成任务,输入是一张灰度图或线稿图,输出是一张彩色图。它属于像素级别的预测,和“框出目标”完全不是同一类问题。
所以在这套系统里,YOLOv11 承担的是“版面理解”的作用。它先把复杂的古籍页面拆成不同区域,再决定哪些区域需要送去上色模型,哪些区域应该保持原样。没有这一步,直接对整页图做上色,文字会被染上奇怪颜色,印章颜色会被覆盖,破损区域也可能被错误加重。
1.2 完整管线:检测、抠图、上色、融合
整套系统的主流程可以表达成下面这个链路:
古籍扫描图 | v 版面预处理:转灰度、去噪、纠偏、切分大图 | v YOLOv11 元素检测 | +----> text_region / seal / damage 保留原样或进入修复分支 | +----> illustration / line_art 裁剪成小图 | v 上色模型生成彩色通道 | v Alpha 融合回原图 | v 输出彩色页面 + 可视化检测框把检测和上色分开,而不是直接用一个端到端模型把整页图变成彩色图,有三个实际好处。
第一是错误隔离。如果整页直接上色,文字和印章被污染时,你很难判断是模型问题还是数据问题。拆分以后,检测模型负责位置,上色模型负责颜色,哪个环节出错就排查哪个环节。
第二是数据效率。目标检测模型只需要框的标注,上色模型只需要成对的灰度图和彩色图。两类数据可以分别生产和迭代,不需要做像素级的全图上色标注。
第三是部署灵活。检测模型可以用 ONNX 或 TensorRT 导出,上色模型也可以单独优化。两个模型之间用缓存、消息队列或者文件接口连接,升级其中一个不会影响另一个。
1.3 项目验收标准:不是训练跑完,而是整套流程能稳定输出
玩具式学习的典型标志是“模型能训练完”就算完成。真实 CV 系统的验收标准要严格得多。
这套古籍上色项目至少需要满足以下条件:
- 输入一张任意分辨率的古籍扫描图,程序能自动完成检测、上色、拼接。
- 输出结果包含两个文件:彩色古籍页和带检测框的可视化图。
- 检测模型有明确的指标记录,例如 mAP50、mAP50-95。
- 上色结果不会破坏文字区域,也不会把印章染成蓝色或绿色。
- 训练好的模型能导出成 ONNX 或 TensorRT 等部署格式。
- 在 CPU 或 GPU 上都能跑通推理,且预处理和后处理逻辑与训练时保持一致。
只有把这些都串起来,才算真正从“调包跑 demo”走向“搭建完整 CV 系统”。
2. 环境准备与工程目录:先让项目可复现,再谈训练
2.1 推荐环境
这种多模型项目最怕两件事:一是 PyTorch 和 CUDA 版本不匹配,二是依赖版本在换机器后发生变化。所以第一步不是急着写训练代码,而是把环境固定下来。
推荐环境如下表所示:
| 项目 | 推荐配置 | 说明 |
|---|---|---|
| 操作系统 | Windows 10/11、Ubuntu 20.04+ | 训练服务器优先 Linux |
| Python | 3.10 | 对 PyTorch 和 ultralytics 兼容较好 |
| PyTorch | 2.x | 需要按本机 CUDA 版本安装 |
| GPU | 8GB 显存起步 | 4GB 可以调通代码,不适合完整训练 |
| ultralytics | 最新稳定版 | 提供 YOLOv11 训练和导出接口 |
| ONNX Runtime | 最新稳定版 | 用于导出后的推理验证 |
创建环境时,建议先使用 conda 或 venv 隔离项目依赖:
conda create -n book-cv python=3.10 -y conda activate book-cv pip install ultralytics如果不想使用 conda,也可以用 Python 自带的 venv:
python -m venv book-cv source book-cv/bin/activate # Windows 下是 book-cv\Scripts\activate pip install ultralytics安装 PyTorch 时不要直接用默认命令覆盖已有环境。先检查本机 GPU 驱动和 CUDA 情况:
nvidia-smi python -c "import torch; print(torch.__version__, torch.cuda.is_available())"如果 CUDA 不可用,说明 PyTorch 装成了 CPU 版本,或者驱动版本太低。训练虽然可以用 CPU,但速度会非常慢,调试代码阶段可以接受,正式训练不建议。
2.2 项目目录结构
不要把所有脚本堆在一个文件里。下面这个目录结构适合“检测 + 上色”的复合项目:
cv_ancient_book/ ├── configs/ │ ├── data.yaml │ ├── train_det.yaml │ └── deploy.yaml ├── data/ │ ├── images/ │ │ ├── train/ │ │ └── val/ │ ├── labels/ │ │ ├── train/ │ │ └── val/ │ └── color_pairs/ │ ├── train/ │ └── val/ ├── scripts/ │ ├── prepare_data.py │ ├── train_det.py │ ├── train_color.py │ └── run_pipeline.py ├── models/ │ ├── detection/ │ └── colorizer/ ├── runs/ └── app/runs目录保存训练日志和权重,models目录保存最终导出的部署文件,configs目录集中管理数据路径和超参数。这样换机器时只需要复制项目目录,重新安装依赖,再调整配置里的路径,就能恢复训练环境。
2.3 数据配置:data.yaml
YOLOv11 训练时依赖一个配置文件告诉框架“图片在哪里、标签在哪里、类别叫什么”。在configs/data.yaml中写入:
path: ../data train: images/train val: images/val names: 0: illustration 1: line_art 2: text_region 3: seal 4: damage这里path是相对configs目录的路径,所以实际数据目录是../data。names的顺序必须和标注文件里的类别 ID 一一对应。如果后面新增类别,一定要重新检查和修改所有标签,否则训练时类别错乱会出现很难排查的指标异常。
3. 数据准备与标注:YOLOv11 的输入决定训练上限
3.1 类别设计:先少后多
古籍扫描图非常复杂,一页上可能有插画、手写批注、印刷文字、印章、虫蛀破损、折痕、噪点。如果一开始就把类别设计得很细,标注成本会急剧上升,模型也容易混淆。
建议第一版只保留四个类别:
| 类别 | 含义 | 在系统中的角色 |
|---|---|---|
| illustration | 插画区域,可能已经有部分颜色 | 上色模型的重点候选 |
| line_art | 线稿区域,黑白线条 | 上色模型的重点候选 |
| text_region | 文字区块 | 不送上色模型,避免文字被染色 |
| seal | 印章区域 | 保持红色特征,不强行修改 |
破损区域可以放在第二个版本再加入。第一版先把“哪些区域要上色”和“哪些区域不能动”分开,系统就能跑起来。
3.2 标注格式:YOLO 的 txt 标签
使用 LabelImg、CVAT 或 X-AnyLabeling 标注后,导出为 YOLO 格式。每张图片对应一个同名 txt 文件,每一行表示一个目标:
0 0.498 0.512 0.216 0.364 2 0.125 0.080 0.150 0.040这一行数据的含义是:类别 ID、目标中心点的 x 坐标、目标中心点的 y 坐标、目标宽度、目标高度。所有数值都归一化到 0 到 1 之间,除以图像宽高。
标注完成后要检查:
- 标签文件是否和图片同名。
- 坐标是否都在 0 到 1 范围内。
- 是否存在空标签文件。
- 同一张图是否有重复标注。
一个常见错误是使用绝对坐标导出,YOLO 训练时坐标超出边界,导致 loss 异常。标注工具一般会提供输出格式选择,导出时务必选 YOLO。
3.3 上色模型的成对数据准备
上色模型不能用“灰度图 -> 彩色图”这种简单映射来训练,还要考虑颜色空间的表示。经典做法是使用 Lab 颜色空间:
- L 通道表示亮度,作为模型输入。
- ab 通道表示颜色信息,作为模型预测目标。
准备数据时,把一张彩色古籍图拆成两个文件:
import cv2 img = cv2.imread("data/color_pairs/raw/001_color.jpg") lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB) l_channel = lab[:, :, 0] ab_channels = lab[:, :, 1:] cv2.imwrite("data/color_pairs/train/001_gray.jpg", l_channel) cv2.imwrite("data/color_pairs/train/001_ab.npy", ab_channels)注意,001_gray.jpg是亮度图,不是把原图用cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)转出来的普通灰度图。两者虽然看起来差不多,但它们和 ab 通道的对应关系不同,会影响训练损失的计算。
3.4 数据划分:按页面拆分,不按随机裁剪拆分
检测数据和上色数据都要划分训练集、验证集。最稳妥的方式是按“页面”划分,而不是按“随机裁剪块”划分。
如果同一页的大图既出现在训练集又出现在验证集,模型很容易通过背景和纹理“记住”图片,导致验证指标虚高。真实项目里,建议用脚本统计所有页面,然后按页面名划分:
python scripts/prepare_data.py --data data --val-ratio 0.2 --seed 42划分完成后,打印训练集和验证集的类别分布。如果某个类别只出现在验证集,没有出现在训练集,这类目标基本不可能被检测出来。
4. 训练 YOLOv11 元素检测模型:用参数理解替换黑盒运行
4.1 先用最小数据集跑通,再全量训练
很多人在正式训练前没有验证“代码能跑”就直接投入全量数据。一旦报错,排查成本非常高。建议先用少量图片和少量轮次跑通整个流程:
cd cv_ancient_book yolo detect train \ model=yolo11n.pt \ data=configs/data.yaml \ epochs=3 \ imgsz=640 \ batch=4 \ device=0这个命令会下载 YOLOv11 的预训练权重,并基于自定义数据集微调。如果数据集只有几十张图,也会很快跑完。跑通后再进入正式训练:
yolo detect train \ model=yolo11n.pt \ data=configs/data.yaml \ epochs=100 \ imgsz=640 \ batch=16 \ device=0训练结束后,最优权重通常保存在:
runs/detect/train/weights/best.pt验证模型时运行:
yolo detect val \ model=runs/detect/train/weights/best.pt \ data=configs/data.yaml这里要注意:model=yolo11n.pt是官方预训练模型,类别是 COCO 的 80 类。加载后会在自定义数据集上微调,最终输出类别数由data.yaml决定,不需要手工改模型结构。
4.2 关键训练参数
YOLO 系列训练参数很多,但新手不需要全部理解,先掌握下面几个就够:
| 参数 | 常见值 | 影响 |
|---|---|---|
| epochs | 100 | 训练总轮数。不是越大越好,要观察验证集是否过拟合 |
| imgsz | 640 | 训练输入尺寸。越大越能检测小目标,但显存占用也越高 |
| batch | 8/16/32 | 批大小。越大梯度越平滑,但显存压力越大 |
| lr0 | 0.01 | 初始学习率。过高容易发散,过低收敛慢 |
| patience | 50 | 验证集指标连续多少轮不提升就提前停止 |
| device | 0 | GPU 编号。CPU 推理用cpu,训练不建议用 CPU |
| seed | 42 | 固定随机种子,让实验可复现 |
如果显存溢出,优先降低batch,其次降低imgsz。如果降低后精度下降明显,再考虑使用自动混合精度 AMP。Ultralytics 默认会启用 AMP,因此大部分情况下不需要额外设置。
4.3 训练结果怎么看
训练结束后,打开runs/detect/train/目录,重点看这几个文件:
results.png:包含 loss 曲线和 mAP 曲线。confusion_matrix.png:每类目标的预测混淆情况。val_batch_pred.jpg:验证集图片上的预测框可视化。weights/best.pt:验证集指标最好的权重。
判断训练是否正常,不要只看 loss 是否下降。还要看:
- 训练 loss 下降,验证 loss 回升,说明过拟合。
- mAP50 很高但 mAP50-95 很低,说明框的位置可能不够准确。
- 某个类别的 Recall 很低,说明漏检严重。
val_batch_pred.jpg中检测框明显偏移,说明标签或后处理有误。
在古籍扫描图上,大插画一般容易检测,小印章和细线稿容易漏检。因此不能只看整体 mAP,要单独看每个类别的精确率和召回率。
4.4 小目标和漏检怎么优化
古籍扫描图分辨率经常是几千乘几千,而训练时为了节省显存,常常被压缩到 640 乘 640。原本很小的印章和线稿,经过缩放后可能只有几个像素,模型自然检测不到。
可以从下面几个方向优化:
- 提高
imgsz,例如imgsz=1280。 - 把大图切成 640 或 1024 的 patch,分别送入模型,再把框坐标映射回原图。
- 使用 SAHI(Slicing Aided Hyper Inference)这类切片推理库。
- 降低推理时的
conf阈值,从 0.25 降到 0.1 或 0.05,观察漏检目标是否出现。 - 检查标签是否漏标。小目标如果本身没有标注,模型不可能学会。
最有效的方法通常是“切图 + 提高输入尺寸”。但切图会带来重叠区域的重复检测,需要做 NMS 合并,不能直接把结果叠加回原图。
5. 把检测结果接到上色模型:组成一套可运行管线
5.1 检测推理与结果保存
训练完成 YOLOv11 后,先写一个独立的检测脚本,确认输出格式。下面这段代码读取一张古籍扫描图,解析所有检测框,并保存标框图:
from ultralytics import YOLO import cv2 model = YOLO("runs/detect/train/weights/best.pt") image_path = "data/samples/page_001.jpg" image = cv2.imread(image_path) results = model.predict( source=image, conf=0.25, iou=0.45, imgsz=640, device="cpu" ) annotated = image.copy() for r in results: for i, box in enumerate(r.boxes): cls_id = int(box.cls[0]) conf = float(box.conf[0]) x1, y1, x2, y2 = map(int, box.xyxy[0].tolist()) label = model.names[cls_id] print(label, round(conf, 3), x1, y1, x2, y2) if label in ["illustration", "line_art"]: color = (0, 255, 0) else: color = (0, 0, 255) cv2.rectangle(annotated, (x1, y1), (x2, y2), color, 2) cv2.putText( annotated, f"{label} {conf:.2f}", (x1, max(0, y1 - 6)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2 ) cv2.imwrite("output/detection.jpg", annotated)这里有一个容易被忽略的点:YOLO 的默认坐标是浮点数,坐标值可能超出原图边界。绘制前必须用int转换,并且用max(0, y1 - 6)防止文字画在图片外面。复杂项目里,还要对框做边界裁剪。
5.2 上色模型的接口设计
上色模型不一定要和 YOLOv11 写在同一个类里。更好的做法是定义统一的Colorizer接口,内部再决定使用 ONNX、PyTorch 还是 TensorRT。
下面是一个基于 ONNX Runtime 的最小接口:
import cv2 import numpy as np import onnxruntime as ort class Colorizer: def __init__(self, onnx_path): providers = ["CUDAExecutionProvider", "CPUExecutionProvider"] self.session = ort.InferenceSession(onnx_path, providers=providers) def preprocess(self, bgr_crop): gray = cv2.cvtColor(bgr_crop, cv2.COLOR_BGR2GRAY) gray = cv2.resize(gray, (256, 256)) gray = gray.astype(np.float32) / 255.0 return np.expand_dims(gray, axis=(0, 1)) def postprocess(self, output, target_size): ab = output[0] # shape [2, 256, 256] ab = np.transpose(ab, (1, 2, 0)) ab = cv2.resize(ab, target_size) return ab def colorize(self, bgr_crop): input_tensor = self.preprocess(bgr_crop) output = self.session.run(None, {"input": input_tensor})[0] target_size =