两周前,我手里有一套训练好的YOLOv4权重,测试集里躺着一千多张图片。需要做的事听起来很简单:批量喂给darknet,跑完检测,把每个带检测框的结果图保存下来。结果我在darknet自带命令上卡了一整个晚上。darknet detector test一次只能传一张图,想批量就得传图片列表文件,可它跑完之后只在终端里刷坐标信息,压根不自动保存可视化图。网上最常见的解决方案是改C源码、在test函数里加save_image再重新编译,可我不想为了一个保存功能把整个编译环境重新折腾一遍,更何况我手里那份darknet.exe还是别人预先编译好的,我连源码都没法改。
这篇文章就是我最终绕开编译、用Python纯调用的完整方案。核心思路是把darknet当黑盒推理引擎,Python负责构造命令、抓取标准输出、解析检测坐标、再用OpenCV画框保存。整个过程不碰C代码、不重新编译,手头只要有一个能正常运行的darknet可执行文件就能实现批量测试与结果保存,适合和我一样拿预编译版干活、又急需批量出图的人。
1. 自带命令的尴尬:单张能跑、批量就废,保存更别提
1.1 detector test的单图模式为什么让人抓狂
darknet自带的最基础测试命令长这样:
darknet detector test data/obj.data cfg/yolov4.cfg backup/yolov4_last.weights images/dog.jpg这条命令会加载模型、推理单张图片,然后把检测到的类别、置信度、坐标框打印到终端。说实话,单张图推理本身没什么问题,模型加载完以后速度还挺快。但它的交互设计非常讨厌:跑完一张图后,程序不会主动退出,而是在终端里等着你继续输入下一张图片的路径。如果我在Shell里一条一条手动喂,一千多张图能喂到天亮。
更麻烦的是输出信息。不加额外参数的情况下,darknet只会打印类似person: 87%、car: 92%这样简短的类别和置信度,坐标信息藏在另外的输出格式里,日志一刷屏就根本分不清哪行对应哪张图。这个信息密度对单图调参没问题,对批量验证就是灾难。
1.2 test命令的列表模式:能批量跑,但结果不落盘
后来我试了列表模式,把图片路径逐行写进一个test.txt,然后直接把它作为参数传给darknet:
darknet detector test data/obj.data cfg/yolov4.cfg backup/yolov4_last.weights test.txt -dont_show这一招确实能一次处理整个列表里的所有图片,终端里检测信息噌噌往外滚,看起来很有批量测试的感觉。但跑完之后我一看输出目录,傻了:一张标注图都没有。darknet的列表模式只是把每张图的检测结果打印出来,可视化图片它根本不保存。官方的-dont_show参数只是抑制了弹窗,并没有自动保存的功能。
网上很多教程让你改源码,说得轻描淡写:定位到检测函数,在循环里加一句save_image,然后重新编译。问题是这句话背后是完整的编译链路——你得有CUDA和OpenCV版本匹配的开发环境,得处理一堆依赖报错,编译一台机器可能就得折腾半天。而且如果你用的是别人编译好的darknet.exe,根本没有改源码的入口。我当时的结论很明确:这条路不适合想快速产出结果的人。
1.3 改源码重编译:一次为了保存而引发的环境连锁反应
我之所以一开始就排斥改源码,是因为以前踩过类似的坑。darknet的编译不是简单的make就能过的,它强依赖CUDA版本、cuDNN版本、OpenCV版本,三者的匹配关系稍微错一点,编译过程就给你一堆莫名其妙的报错。为了加几行保存代码,把整个环境重新对齐一遍,投入产出比太低了。
更隐蔽的问题是,修改源码后,即使编译成功,新的二进制文件可能会改变原有模型的推理行为。有人改代码时不小心动了预处理流程,导致同样的权重检测效果变差,还以为是权重的问题。这种"为了一个小功能破坏了一个能用的环境"的事情,在工程里其实经常发生。所以我决定走一条完全不同的路:不碰源码,不重新编译,把所有逻辑放在Python层。darknet能跑就行,main函数里发生了什么跟我无关。
2. 绕过编译的调度思路:darknet可执行文件当黑盒推理服务
2.1 核心思路:命令行调用加标准输出解析
我的方案其实朴素得很:darknet本质上是一个命令行程序,它有标准输入、标准输出和标准错误输出。我只要能从Python里启动它、给它传参数、再拿到它的输出文本,就等于拿到了推理结果。darknet里的main函数怎么画的框、怎么做的NMS,我完全不关心,我只关心它吐出来的那几行文本。
流程拆解成四步:
- 用Python扫描测试图片目录,把所有图片绝对路径写进一个文本列表文件。
- 用
subprocess启动darknet,传列表文件路径,加上-dont_show和-ext_output参数,一次跑完整个列表,并把stdout和stderr一起捕获。 - 解析输出文本,按图片路径切分检测信息,提取class_id、类别名和归一化坐标。
- 用OpenCV读取原图,把归一化坐标换算成像素坐标,画框写文字,保存到指定输出目录。
这个思路之所以成立,是因为darknet的-ext_output参数会输出一种非常规整的检测文本格式。每一张图片前会打一行Detection: 图片路径,紧接着是Objects:,下面每行一个目标,包含class_id、类别名、left、top、width、height、score。这几乎就是为了外部程序解析而设计的。
2.2 为什么不直接走Python绑定或者OpenCV DNN
和darknet打交道的常见方式还有另外两种,我也都评估过,最终没有采用。
第一种是用darknet的Python绑定。官方仓库里确实有Python接口,但那个接口本身需要编译libdarknet.so或者libdarknet.dll,而这又回到了编译的老路。我手头如果连源码都没有,这条路直接堵死。就算有源码,编译绑定的坑比编译可执行文件还多,Python版本、GIL、内存释放都是隐患。
第二种是用OpenCV的DNN模块加载YOLOv4权重。OpenCV的cv2.dnn.readNetFromDarknet确实能读cfg和weights文件,推理也不需要darknet本身。但问题有两个:一是OpenCV对YOLOv4某些算子的实现和darknet原生实现存在细微差异,尤其是mish激活函数和某些上采样层的细节,实测下来检测精度会有轻微波动,特征图对齐也不完全一样;二是OpenCV DNN模块的预处理和后处理逻辑要自己写,包括letterbox resize、scale变换、NMS,工作量并不小。既然darknet原生的检测效果已经被验证过,直接用它的可执行文件反而是最稳妥的。
2.3 动手前的准备清单与目录约定
在写Python脚本之前,先把依赖和文件路径理清楚。这个方案的依赖极少:
| 依赖项 | 说明 |
|---|---|
| darknet可执行文件 | darknet.exe或darknet,版本建议是支持-ext_output的主流分支 |
| cfg配置文件 | 训练时用的YOLOv4配置文件,如yolov4.cfg |
| data配置 | obj.data,其中指定了类别数量和names文件路径 |
| weights权重 | 训练好的权重文件 |
| names文件 | 类别名列表,一行一个类名 |
| Python环境 | 需要opencv-python、pathlib、re、subprocess,后三者都是标准库 |
目录约定上,我建议把测试图片单独放在一个文件夹里,输出目录和输入目录分离。不要把所有图片和输出结果混在一起,否则跑第二遍时会覆盖第一遍的结果。我自己习惯这样组织:
D:\work\test_imgs\ # 测试图片 D:\work\det_output\ # 检测结果输出 D:\work\list_file.txt # 图片列表 D:\tools\darknet\ # darknet相关文件3. Python主控实现:列表文件、子进程调用与输出文本解析
3.1 第一步:扫描图片目录并生成列表文件
列表文件是darknet批量处理的入口。要注意的是,里面必须写图片的绝对路径,相对路径在darknet工作目录不一致时非常容易出问题。我直接用pathlib遍历目录,按扩展名过滤图片。这一步的逻辑很简单,但做好了能让后续一步省心很多。
from pathlib import Path input_dir = Path(r"D:\work\test_imgs") list_file = Path(r"D:\work\list_file.txt") image_exts = {".jpg", ".jpeg", ".png", ".bmp"} images = [p.resolve() for p in input_dir.iterdir() if p.suffix.lower() in image_exts] with open(list_file, "w", encoding="utf-8") as f: for img_path in images: f.write(str(img_path) + "\n") print(f"共找到 {len(images)} 张测试图片,已写入 {list_file}")这里有个细节值得说:用resolve()把路径转成绝对路径,是为了同时规避两个问题,一是Windows下相对路径解析时的转义问题,二是darknet在某些环境下对../这种相对路径不敏感的问题。反正列表文件是纯文本,多花这一行代码非常值。
3.2 第二步:一次子进程批量检测的关键参数
列表文件准备好之后,调用darknet就一句话的事情,但这句话里藏着本文最大的几个坑。如果只是简单执行,大概率会遇到两种让你怀疑人生的卡死现场。我给出一份正确的调用方式:
import subprocess import os darknet_exe = r"D:\tools\darknet\darknet.exe" data_file = r"D:\tools\darknet\cfg\obj.data" cfg_file = r"D:\tools\darknet\cfg\yolov4.cfg" weights_file = r"D:\models\yolov4_last.weights" threshold = 0.25 cmd = [ darknet_exe, "detector", "test", data_file, cfg_file, weights_file, str(list_file), "-thresh", str(threshold), "-dont_show", "-ext_output", ] proc = subprocess.run( cmd, capture_output=True, stdin=subprocess.DEVNULL, timeout=600, cwd=os.path.dirname(darknet_exe), ) raw_output = proc.stdout.decode("utf-8", errors="ignore") raw_error = proc.stderr.decode("utf-8", errors="ignore") full_text = raw_output + "\n" + raw_error参数解释一下:
-dont_show:不弹OpenCV窗口。不加这个参数,在Windows下每检测一张图就弹一个窗口,等待按键才能继续,批量跑直接卡死。-ext_output:输出详细检测信息。没有这个参数,darknet不会输出完整的坐标框,后面解析就没有数据可用。stdin=subprocess.DEVNULL:这是最容易忽略的一步。darknet处理完所有图片后不会主动退出,它会继续执行等待Enter Image Path:输入的逻辑。如果你不重定向stdin,subprocess.run会一直挂在那里等一个永远不会出现的输入,眼睁睁看着程序卡死。把stdin指向DEVNULL,darknet读到EOF才会干净利落地退出。cwd=os.path.dirname(darknet_exe):让darknet的工作目录变成它自己所在的目录。很多cfg文件内部引用了相对路径,比如data/obj.names,如果工作目录不对,darknet会报找不到文件。切换到exe所在目录是最省心的做法。
3.3 第三步:按图片切分解析输出文本
-ext_output的输出文本格式大致是这样的片段:
Detection: D:\work\test_imgs\a.jpg FPS: 23.5 Objects: class_id: 0, class_name: person, left: 0.123, top: 0.456, width: 0.234, height: 0.567, score: 0.889 class_id: 2, class_name: car, left: 0.345, top: 0.234, width: 0.456, height: 0.123, score: 0.902解析策略是寻找Detection:行作为图片的分隔边界,把后面紧随的检测行按正则提取。我见过一些脚本直接全局匹配所有class_id:行,然后把它们堆到一起。这种做法在处理单张图时没问题,但在列表模式下完全无法区分检测框属于哪张图。正确做法必须先切分图片块,再逐块解析。
import re detection_line = re.compile( r"class_id:\s*(\d+),\s*class_name:\s*([^,]+),\s*" r"left:\s*([0-9.]+),\s*top:\s*([0-9.]+),\s*" r"width:\s*([0-9.]+),\s*height:\s*([0-9.]+),\s*" r"score:\s*([0-9.]+)" ) image_detections = {} current_image = None current_dets = [] for line in full_text.splitlines(): line = line.strip() match = re.match(r"Detection:\s*(.+)", line) if match: if current_image is not None: image_detections[current_image] = current_dets current_image = match.group(1).strip() current_dets = [] elif current_image is not None: m = detection_line.match(line) if m: current_dets.append({ "class_id": int(m.group(1)), "class_name": m.group(2).strip(), "left": float(m.group(3)), "top": float(m.group(4)), "width": float(m.group(5)), "height": float(m.group(6)), "score": float(m.group(7)), }) if current_image is not None: image_detections[current_image] = current_dets这个解析循环有个隐含假设:Detection:出现之后,才开始收集检测框。如果darknet版本输出的文本里没有Detection:行,而是直接输出图片路径加冒号,那么这段代码就匹配不到任何东西。遇到这种情况,我会退回到逐张调用darknet的模式,虽然慢一点,但至少能跑通。
3.4 第四步:归一化坐标换算成像素坐标
darknet输出的left、top、width、height都是归一化数值,范围在0到1之间,相对于原图像的宽高。想要画框,必须乘以原图的实际宽高。这里有个容易想当然的坑:千万不要用cfg里设置的输入尺寸去乘,比如cfg里写了width=416,但实际图片是1920x1080,那画出来的框全错。
import cv2 def draw_detections(image_path, detections): img = cv2.imread(str(image_path)) if img is None: print("无法读取图片:", image_path) return None h, w = img.shape[:2] for det in detections: x1 = int(det["left"] * w) y1 = int(det["top"] * h) x2 = int((det["left"] + det["width"]) * w) y2 = int((det["top"] + det["height"]) * h) x1 = max(0, min(x1, w - 1)) y1 = max(0, min(y1, h - 1)) x2 = max(0, min(x2, w - 1)) y2 = max(0, min(y2, h - 1)) color = (0, 255, 0) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) label = f"{det['class_name']} {det['score']:.2f}" cv2.putText(img, label, (x1, max(0, y1 - 10)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) return img坐标越界裁剪这一步不能省。darknet在某些极端情况下输出的left + width会略大于1,或者left本身就是负数,直接画上去会超出图片边界,OpenCV不会报错,但画出来的框会残缺或者整个飘出画面。加几行max/min能让结果干净很多。
完整脚本跑起来的流程就是:生成列表文件、子进程批量推理、解析文本、逐图读原图画框保存。这四步分别独立,哪一步出问题都能单独排查。
4. 结果落盘的两条线:可视化标注图与坐标标签文件
4.1 可视化图片的保存策略
批量测试最直接的需求就是把检测框画到原图上保存。我上面的draw_detections函数已经返回了画好框的图像,保存时要注意命名不能和原图冲突。我的习惯是保留原文件名,加一个_det后缀,输出到独立目录。
output_dir = Path(r"D:\work\det_output") output_dir.mkdir(parents=True, exist_ok=True) for img_path, dets in image_detections.items(): img = draw_detections(img_path, dets) if img is None: continue out_path = output_dir / (Path(img_path).stem + "_det.jpg") cv2.imwrite(str(out_path), img) print("可视化图片已保存到", output_dir)这里有一个很实用的建议:如果你准备做多轮参数对比,比如不同阈值下的检测效果,建议在输出目录里再按阈值分子目录。output/thresh0.25/、output/thresh0.5/这样,后面对比起来一目了然,不用手动改文件名。
4.2 同步生成YOLO格式的坐标标签文件
除了可视化图片,批量测试通常还期望拿到结构化的坐标信息,方便后续做评估、筛选或者转换标注。从darknet输出的归一化left、top、width、height转换成YOLO训练格式非常容易,因为YOLO格式需要的也是归一化的中心点坐标和宽高:
def save_yolo_label(detections, label_path, img_w, img_h): with open(label_path, "w", encoding="utf-8") as f: for det in detections: cx = det["left"] + det["width"] / 2 cy = det["top"] + det["height"] / 2 w = det["width"] h = det["height"] cx = max(0.0, min(cx, 1.0)) cy = max(0.0, min(cy, 1.0)) w = max(0.0, min(w, 1.0)) h = max(0.0, min(h, 1.0)) f.write(f"{det['class_id']} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}\n")保存为YOLO格式txt文件的额外好处是,如果你对这批检测结果不满意,想用它们作为伪标签继续训练,可以直接把输出目录当作标注目录喂给训练脚本,不需要任何格式转换。
如果你想用Excel或者表格工具分析,还可以顺手生成一份CSV,列出文件路径、类别、置信度、四个角点坐标。CSV的好处是可以用筛选功能快速找出所有置信度低于某个值的检测结果,或者统计每张图片的检出目标数量。
4.3 输出目录组织与断点续跑
测试图片一多,跑一次可能要好几分钟甚至更久,中间一旦停电、报错、手滑关掉终端,已经跑完的结果就全没了。我建议在保存图片时增加一个"跳过已存在结果"的逻辑:
for img_path, dets in image_detections.items(): out_path = output_dir / (Path(img_path).stem + "_det.jpg") if out_path.exists(): print("跳过已存在结果:", out_path.name) continue img = draw_detections(img_path, dets) if img is None: continue cv2.imwrite(str(out_path), img)这样即使程序中途挂了,重新启动之后已经跑完的图片不会重新画一遍,节省时间。断点续跑在数据结构上要求以图片为单位处理,而不是批量处理到一半再落盘。前面解析出的image_detections字典天然满足这个条件,所以实现成本几乎为零。
5. 实测最容易翻车的五个细节与对策
5.1 进程卡死的两个隐蔽原因
我在调试这个方案时遇到过两次卡死,一次比一次隐蔽。
第一次是没加-dont_show。Windows下darknet每检测一张图片就弹出一个OpenCV窗口,窗口需要按键才能关闭。批量模式下,这个过程会逐个弹窗阻塞,看起来就像程序死掉了。解决方法是给命令加上-dont_show,一套命令里绝对不能漏掉它。
第二次是加了-dont_show之后仍然卡死。排查了很久才定位到问题出在stdin。darknet处理完列表文件里的所有图片之后,并不会直接退出,而是重新进入"等待用户输入图片路径"的交互状态。如果Python的subprocess.run没有设置stdin=subprocess.DEVNULL,这个交互状态会把父进程挂住,永远不返回。加上DEVNULL一行命令就解决了。
这个问题的本质是darknet自己设计的交互模型:它把"命令行参数指定的图片"和"运行时手动输入的图片"混在同一个循环里处理。外部调用方必须显式关闭stdin,告诉它没有更多输入了。
5.2 编码乱码问题
darknet在Linux下通常输出UTF-8,但在Windows下,根据系统区域设置,输出编码可能是GBK或者其他编码。如果你直接proc.stdout.decode("utf-8"),遇到GBK编码的中文字符会直接抛UnicodeDecodeError,导致整个程序崩溃。
我的处理方式是用errors="ignore"容错解码:
raw_output = proc.stdout.decode("utf-8", errors="ignore") raw_error = proc.stderr.decode("utf-8", errors="ignore")这样即使有部分字节无法解码,也不会中断流程。代价是中文类别名可能变成乱码。如果你的names文件里有中文类别名,又需要在画框时正确显示中文,单靠OpenCV的putText也搞不定,它默认不支持中文渲染。这里的通用做法是用PIL绘制中文,或者干脆把类别名映射成英文ID号。我在实际项目里是把中文名映射成英文缩写,避开了整套编码问题。
5.3 路径细节与相对路径陷阱
darknet对路径的处理很任性。同一个命令,在某个目录下执行时cfg文件里的相对路径能正常解析,换一个工作目录就报找不到文件。我在第二章节已经设置了cwd=darknet.exe所在目录,这是最稳妥的做法,但仍然建议在正式跑批之前先跑一张测试图验证一下整个链路通不通。
另外,Windows下路径里的反斜杠和空格是两大隐形杀手。用subprocess的列表传参形式,空格会被自动处理,不需要手动加引号;但如果你为了调试方便,把命令拼成一个字符串再传给shell=True,那就得自己处理转义,坑非常多。我强烈建议全程使用列表传参,不要用shell=True。
5.4 没有任何检测目标的图片也要落盘
一个容易被忽略的场景:图片里一个目标都没有。darknet会老老实实输出Detection:和Objects:,但Objects:下面没有任何检测行。解析结果是空列表,画框代码跳过图片,保存逻辑却有两条分支:
- 可视化图片:确实可以不保存,因为没框可画。
- 坐标标签文件:必须保存为空文件。
如果你的下游脚本要读取每张图片对应的标签文件,缺了空文件会导致文件数量不匹配、下标错位、甚至训练程序直接报错。所以在保存标签文件的循环里,即使检测框列表是空的,也要创建空的txt文件。这个习惯我在踩过坑后彻底养成了。
5.5 性能实测与提速思路
列表文件模式比逐张调用子进程快很多,因为只启动了一次darknet进程,省掉了反复加载权重和初始化CUDA的时间。实测下来,每张图大概几百毫秒到一两秒不等,取决于图片分辨率和显卡。
但如果你手头的图片实在太多,比如上万张,单进程跑完也需要不少时间。在不编译源码的前提下,最简单的加速方式是多进程并行。把图片列表切成几份,每个Python进程各生成一个子列表文件、各调用一次darknet,最后合并结果目录。
from multiprocessing import Pool def process_batch(image_subset): # 生成临时列表文件 # 调用darknet # 解析并保存 pass # 按图片数量切成4份,分别处理要注意的是,同时启动多个darknet进程时,每个进程都会初始化CUDA上下文,显存占用会成倍增加。如果你的显卡只有6GB显存,建议控制在2到3个进程以内,否则可能直接CUDA out of memory。
6. 从批量测试到评估:扩展用法与我的经验收尾
6.1 用批量测试结果快速计算mAP
检测结果都变成结构化的坐标文件之后,做评估就是顺理成章的事。darknet官方工具链里有个评估命令,但那个命令同样需要编译配套工具,不符合本文"零编译"的初衷。自己写一个Python评估脚本难度也不大。
核心公式就三个:IoU、Precision、Recall。假设你已经有了预测框和真实标注框,评估流程是:
- 对每一张图片,按置信度从高到低排序预测框。
- 对每个预测框,和同类的真实框计算IoU,取IoU最大的那个真实框。
- 如果IoU大于设定阈值(常用0.5),记为TP,否则记为FP。
- 没有被任何预测框匹配的真实框记为FN。
- 按置信度阈值滑动计算Precision和Recall,画出PR曲线,曲线下面积就是AP。
我自己写过一次,核心代码也就一百多行。有了上一章保存的YOLO格式预测标签,评估脚本读起来非常顺手,不需要再跑一遍darknet了。
6.2 几个值得尝试的扩展方向
批量测试框架搭好之后,很多需求都可以往里塞。比如你想按类别统计检测数量,只需要在解析循环里维护一个defaultdict(int),遍历所有检测结果累加即可,一两分钟就能出统计报表。
比如你手头是一堆视频文件,想抽帧检测。可以先写脚本把视频按固定帧率抽帧保存,再套用这一套批量检测流程。检测完之后,把结果帧按时间顺序拼回去,就得到一个带检测框的视频。这个流程我自己用过,效果不错,但要注意抽帧的帧率不要太高,否则检测速度跟不上,会很浪费时间。
再比如你想做置信度筛选。darknet的-thresh参数直接在推理阶段过滤低置信度结果,但有些场景你需要保留所有检测框、在后处理阶段再决定怎么筛。解析流程本来就是Python控制的,你可以轻松按score >= 0.5或者class_name == "person"做条件过滤,比重新跑一遍darknet灵活得多。
6.3 关于这套方法的个人体会
回看这个方案,它的核心价值不是"Python比C代码高级",而是把"推理"和"调度"分开了。darknet负责它擅长的推理部分,Python负责它擅长的文件管理和文本解析部分,各干各的活,互不干扰。这种做法在维护旧工程时尤其有用:不想动一个能跑的底层程序,就在上层包一层胶水代码。
我实际用下来,最大的时间节省其实不是画图保存,而是排查错误的路径。批量测试一旦跑起来,最怕的不是检测效果差,而是跑到一半卡死或者输出全乱掉。把第二章的stdin=DEVNULL、-dont_show、-ext_output三个关键点先配好,后面几乎不会遇到问题。希望这篇踩坑记录能帮你省下一个晚上的时间。