ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

objectmarker 详解:用鼠标拉框生成 OpenCV 正样本标注文件

objectmarker 详解:用鼠标拉框生成 OpenCV 正样本标注文件

简介:一份基于OpenCV的Objectmarker工具源码包,专为需要自定义物体识别数据集的开发者设计。核心功能是让用户通过鼠标在图像中框选目标区域,程序自动记录边界框坐标并保存为info.txt,生成标准的正样本描述文件,格式为image_name x1 y1 x2 y2,可直接用于后续机器学习或深度学习模型的训练。包内含10个文件,共1.01MB,主要包括可执行程序、C++源码、OpenCV依赖库(CV.dll、cvaux.dll、highgui.dll)以及说明文档和示例图像,结构精简,便于直接运行和二次修改。该工具已有291人学习下载,特别适合公开数据集中难以覆盖的小众类别——开发者可借此快速完成批量标注,省去逐张手动编写坐标的繁琐过程;同时源码还展示了OpenCV鼠标事件处理与窗口交互的实现方式,对图像处理入门者同样具有参考价值。

1. objectmarker 与 OpenCV 识别正样本:与其手写 txt,不如鼠标拉框

做 OpenCV 识别正样本训练的时候,最磨人的往往不是算法调参,而是准备训练数据。描述文件里每一行都要写清图片位置、目标个数和矩形框,几十张图还能忍,图一多就全靠复制粘贴改数字,翻车是常态。objectmarker 这个工具就是干这事的:读进一张图,你用鼠标左键在目标上拖个框,松开就自动生成一行符合 opencv_createsamples 要求的正样本描述,然后保存到 positive.txt。它适合两类人:一类刚起步学目标检测、想快速造一批正样本的新手;另一类是老手,嫌弃现成标注工具太重,想拿源码改成自己的训练数据流水线。看完这篇笔记,你不仅能把它跑起来,还能知道改哪里变成多类别标注器。

2. 技术底子与目录结构:为什么这套设计能撑起“标注流水线”

2.1 技术选型:Python 搭配 OpenCV 的取舍

这个项目的主语言是 Python,界面层没有用 PyQt 或 Tkinter,而是直接用 OpenCV 自带的高gui窗口。这个选择一点不奇怪。正样本标注场景里,窗口只需要两件事:显示图片、响应鼠标拖动事件,再顶多加几个键盘快捷键。OpenCV 的 namedWindow 配合 setMouseCallback 完全够用,多引一个 GUI 框架反而增加安装成本。依赖越少,别人拿到源码越容易跑起来,这对开源小工具很重要。

绘图部分主要靠 cv2.rectangle,底层是 numpy 图像数组的直接操作;坐标保存用的是 Python 字符串格式化。整条链路只有三个核心依赖:opencv-python、numpy,以及 Python 标准库的 os、sys、argparse。不需要数据库,不需要配置文件,状态全在内存里,标注完一次性写盘。简单粗暴,但很稳。

从扩展性上看,这种设计也很方便二次开发。想加类别标签,只需要在内存数组里多存一个字段;想改成输出 VOC 格式,只要改最后那个写文件函数。数据结构是整个工具的核心,模型选择反而次要。

2.2 目录结构:一个主脚本和一份依赖清单

我拆过之后,这个项目的目录相当简洁。这是核心结构:

objectmarker/ ├── marker.py # 标注器主脚本 ├── requirements.txt # 依赖清单 ├── README.md # 使用说明 ├── samples/ # 测试图片目录 │ └── demo_01.jpg └── output/ └── positive.txt # 生成的描述文件(运行后自动创建)

重点在 marker.py 一个文件里完成所有逻辑。可能有朋友会问,为什么不拆成 utils.py、callback.py 之类?我的看法是,一个操作简单的交互式小工具,拆散文件反而让思路不连贯。你在屏幕上画框的整个过程需要共享状态:当前图片、当前文件名、已经框出的坐标列表、是否正在拖动。这些状态如果分散在多个类里,写起来像做上下文传递题,单文件里用模块级变量反而直观。

这个目录里最关键的是 marker.py 和 requirements.txt。依赖清单就三行:

opencv-python numpy

安装时不用指定版本,用最新稳定版即可。如果你的环境里有老项目锁住了 OpenCV 版本,建议用虚拟环境把标注器单独装一份,后面我会在避坑章节讲这个问题。

2.3 正样本描述文件:一行数据的完整含义

objectmarker 生成的 positive.txt,其实就是 OpenCV 传统目标检测训练管线的输入。以 haar、LBP 的 opencv_createsamples 工具为例,它需要的正样本描述格式通常是:

图片路径 目标个数 x y 宽 高

如果一张图里有两个目标,同一个图片路径会重复出现,目标个数永远写 1。例如:

/data/train/001.jpg 1 120 80 96 108 /data/train/001.jpg 1 340 200 60 45 /data/train/002.jpg 1 56 40 120 130

注意这里的 x y 是矩形左上角坐标,w h 是宽高。这个格式有点反直觉,很多人把它写成 x1 y1 x2 y2,结果训练时直接报错或裁剪成空图。objectmarker 在保存前会把拖拽得到的两个端点重新排列,确保 x1 < x2、y1 < y2,再换算成左上角坐标和宽高。

我一般会在跑训练前自己写一个十几行的校验脚本,把这个文件逐行读回去,把框画在图上肉眼检查。因为描述文件里的坐标一旦错位,opencv_createsamples 不会像神经网络框架那样给出友好的 shape 报错,它只会默默生成一堆空白样本,浪费你一下午。所以这个简单的文件格式其实是最需要小心的地方。

2.4 内部数据流:从图片到内存再到文本文件

把整个工具的运行路径画出来,其实是四个阶段:读图、交互标注、内存缓存、写文件。对应 marker.py 里的一组函数,互不干扰。

load_image() -> current_image, current_path mouse_cb() -> drag_start, drag_end -> boxes[] key_listener() -> save / next / quit save_boxes() -> generate_lines() -> write_file()

内存里主要存两个变量:一个是boxes,它是一个列表,里面每一个元素是(x1, y1, x2, y2)这样的四元组;另一个是current_image,保存当前打开的图像数组。每次鼠标松开,就把新的四元组追加到boxes;每次按保存键,就把boxes里的所有元素转成一行文本,追加写入输出文件。

这里有一个隐蔽的设计点:为什么不直接在鼠标松开时马上写文件,而是攒到按保存键再写?原因很简单,如果每次点按都写一次盘,很容易出现“标注到一半,路径写错,所有文件都废了”的情况。先存在内存里,最后统一落盘,用户还有机会撤销和删除误标框,血泪经验。数据流越晚落到磁盘,交互越安全。

3. 核心模块拆解:一次鼠标拖拽背后发生了什么

3.1 鼠标回调:记录拖拽起点和终点

objectmarker 的入口是 OpenCV 的鼠标回调。你需要先定义一个on_mouse函数,把它传给cv2.setMouseCallback。这个函数会在鼠标按下、移动、松开时被调用,判断逻辑全靠事件类型。

import cv2 boxes = [] dragging = False start = (0, 0) end = (0, 0) def on_mouse(event, x, y, flags, param): global start, end, dragging, boxes if event == cv2.EVENT_LBUTTONDOWN: start = (x, y) dragging = True elif event == cv2.EVENT_MOUSEMOVE and dragging: end = (x, y) elif event == cv2.EVENT_LBUTTONUP: end = (x, y) dragging = False boxes.append((start[0], start[1], end[0], end[1]))

这里start是按下左键时的像素坐标,end是鼠标移动或松开时的坐标。为什么要记录MOUSEMOVE?因为你需要让屏幕上实时显示一个正在拉伸的矩形框,如果不记录移动轨迹,用户会觉得自己在点两下而不是拖一个框,体验很差。

参数x, y是 OpenCV 窗口坐标系里的像素位置,左上角是(0, 0)。这个坐标系和 numpy 数组的“行、列”方向要分清楚:图像数组的[row, col]对应这里的(y, x)。好在画矩形时 OpenCV 统一接收(x, y),所以鼠标回调里不需要转换,但如果你要裁剪img[y:y+h, x:x+w],就必须记得把x当列、y当行。

3.2 重绘逻辑:用副本消除“拖影”

交互标注里最见功力的是重绘。如果直接在原图上画矩形,鼠标每次移动都会在上一帧的位置留下痕迹,屏幕会越来越花。正确做法是每次刷新时先从原始图像复制一份,再在这个副本上画所有已经存在的框。

def draw_boxes(image, boxes, start, end, dragging): display = image.copy() for (x1, y1, x2, y2) in boxes: cv2.rectangle(display, (x1, y1), (x2, y2), (0, 255, 0), 2) if dragging: cv2.rectangle(display, start, end, (0, 0, 255), 1) return display

这一段是“显示干净”的关键。image.copy()是深拷贝,不会影响原始图片数据。已经确认的框用绿色,正在拖拽的框用红色,这样一目了然。颜色用的 BGR 顺序,(0, 255, 0)是绿色,不是 RGB 的(0, 255, 0),这个很多新人在调试时会被绕进去。

另一个隐藏细节是边框宽度。确认框给 2 像素,拖拽框给 1 像素。太细在高分辨率屏幕上看得费劲,太粗会遮住目标边缘,影响你判断框是否贴合物体。这个参数完全可以在源码里全局定义,我一般会单独抽成BOX_THICKNESS常量。

3.3 坐标整理:从两个点变成“左上角 + 宽高”

鼠标拖拽得到的start和end并不保证都在同一方向,你可能是从右下往左上拖的。如果不做整理,保存出来的样本描述会带着负宽度,训练工具直接罢工。

def normalize_box(box, img_width, img_height): x1, y1, x2, y2 = box x1, x2 = min(x1, x2), max(x1, x2) y1, y2 = min(y1, y2), max(y1, y2) x1 = max(0, min(x1, img_width - 1)) y1 = max(0, min(y1, img_height - 1)) x2 = max(0, min(x2, img_width - 1)) y2 = max(0, min(y2, img_height - 1)) w = x2 - x1 h = y2 - y1 if w <= 0 or h <= 0: return None return (x1, y1, w, h)

这段函数后半部分在做“夹紧”,把超出图像边界的坐标拉回到图像范围里。为什么需要这一步?因为鼠标可以拖到窗口外部,OpenCV 在这种情况下也会回调坐标,可能给你一个负数或大于图像宽高的值。如果不夹紧,保存的坐标会让opencv_createsamples在裁剪时越界,报错信息还特别不友好。

img_width和img_height从图像数组的shape属性取,注意是w = img.shape[1],h = img.shape[0]。这个顺序我至少写错过三次,现在每次都会先用print验证一下。

3.4 保存描述与主循环:把内存中的框写成文本

最后一步是把整理好的框写成positive.txt。这里用绝对路径最稳妥,因为训练时命令的工作目录和标注时可能不一样,相对路径很容易“找不到图片”。

def save_positive(output_path, img_path, boxes, img_shape): h, w = img_shape[:2] valid_lines = [] for box in boxes: norm = normalize_box(box, w, h) if norm is None: continue x, y, bw, bh = norm valid_lines.append(f"{img_path} 1 {x} {y} {bw} {bh}") with open(output_path, "a", encoding="utf-8") as f: f.write("\n".join(valid_lines) + "\n")

encoding="utf-8"看似无关紧要,实际决定你是不是会在中文路径下翻车。Windows 平台如果默认编码不是 UTF-8,写入的文件路径可能变成乱码,训练时读不到图。这一行代码是我踩坑之后补上的。

主循环里用waitKey做按键分发,这是 OpenCV 交互窗口的标准姿势:

while True: display = draw_boxes(current_image, boxes, start, end, dragging) cv2.imshow("objectmarker", display) key = cv2.waitKey(30) & 0xFF if key == ord("s"): save_positive("output/positive.txt", current_path, boxes, current_image.shape) boxes.clear() print("saved:", current_path) elif key == ord("n"): break elif key == ord("q"): cv2.destroyAllWindows() exit()

waitKey(30)的参数 30 表示最多等 30 毫秒读取一次按键,超过就返回 -1。把这个值和imshow放在循环里,就能保证窗口持续刷新。需要注意的是& 0xFF:在 Windows 上waitKey返回值可能超过 8 位,如果不用位与运算,某些按键会判断不出来。这是 OpenCV 教学里反复出现的玄学问题,真实原因就是返回值没掩码。

到这里,核心逻辑已经完整了。保存后boxes.clear()会清空当前图的框,避免下一张图带出残留框。这个地方容易遗漏,我之前就遇到过按了s之后又按上一张图片,结果上一张的框全被写进新图片里,数据直接污染。

4. 跑起来再说:环境、启动、操作步骤与结果验证

4.1 安装依赖:三行命令解决

先把源码放到本地目录,然后创建虚拟环境。如果你不创建虚拟环境,也能跑,只是可能被系统里的老版本 OpenCV 干扰。我建议养成习惯:

cd objectmarker python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install -r requirements.txt

requirements.txt里只有opencv-python和numpy。opencv-python这个包名要特别注意,它是 OpenCV 官方预编译包,包含完整的高gui功能,而有些精简版opencv-python-headless不带窗口系统,无法显示标注界面。如果你装了 headless 版本,标注器跑起来会立刻报cv2.imshow未定义符号,别急着怪源码。

装完依赖后验证一下 OpenCV 是否正常:

python -c "import cv2; print(cv2.__version__)"

能打印出版本号就说明环境没问题。如果这一步报DLL load failed之类的错,通常不是项目问题,而是本机缺少 Visual C++ 运行库,装上对应运行库再试即可。

4.2 启动参数与命令行设计

这个项目虽然小,但命令行参数还是有的。我拆到的版本支持传入图片目录和输出文件路径,核心入口长这样:

python marker.py images/ -o output/positive.txt

第一个参数写图片文件夹路径,程序会读取该目录下所有jpg/jpeg/png文件;-o后面的路径是描述文件输出位置。代码里用argparse解析,如果没提供输出路径,会默认写到output/positive.txt。

启动之后会逐张显示图片,每张图片上方标题栏显示当前文件和序号。我这里给你的参数说明如下:

images/ # 待标注图片文件夹,支持相对路径 -o output/xxx.txt # 指定输出文件,不指定则用默认路径

在写标注器的时候,我一般会把argparse的逻辑单独放在if __name__ == "__main__"里,方便别人一眼看到入口参数,也方便后面接其他脚本调用。

4.3 操作流程:从拖框到保存的六个动作

进入标注界面后,操作路径非常短:

  1. 看到当前图片,先观察目标分布在什么位置。
  2. 在目标左上角按住鼠标左键,向右下角拖动。
  3. 拖动过程中会看到一个红色矩形实时跟随,这就是你要的选框。
  4. 松开鼠标,红色框变成绿色,代表一个样本框被记录。
  5. 如果这个目标在图里出现多个,就继续重复步骤 2 到 4,直到标完当前图片上的全部目标。
  6. 按s键保存当前图片的所有框到输出文件,按键后程序自动读取下一张图片。

如果标错了怎么办?这个版本里最简单的方式是按q退出,手动改输出文件。你也可以在源码里加一个撤销键,把boxes列表最后一个元素弹出去。代码只需要三行,我放在最后一章讲。

操作时有个小经验:先标容易的目标,再标小目标、边缘目标。因为框重叠时红色拖拽框会盖住一部分图,如果先标了小目标,后面大目标会把你视觉注意力带偏。

4.4 验证生成结果:检查正样本描述是否合格

保存之后不要急着训练,先跑一遍我的“后悔药脚本”。它逐行读positive.txt,把框画回原图,然后按任意键翻页,让你肉眼确认有没有错位。

import cv2 for line in open("output/positive.txt", encoding="utf-8"): parts = line.strip().split() if len(parts) < 6: continue img_path, _, x, y, w, h = parts[:6] x, y, w, h = int(x), int(y), int(w), int(h) img = cv2.imread(img_path) if img is None: print("无法读取图片:", img_path) continue cv2.rectangle(img, (x, y), (x + w, y + h), (0, 0, 255), 2) cv2.imshow("verify", img) if cv2.waitKey(0) & 0xFF == ord("q"): break cv2.destroyAllWindows()

这段脚本有三层意义。第一,它能发现文件路径是否还存在;第二,它能发现坐标是否超出图像边界;第三,它能发现框选是否偏离目标。三者中任何一个出问题,训练结果都会变得诡异,比如识别窗口全落在背景上。

验证合格的positive.txt就可以喂给opencv_createsamples了。在生成vec文件之前,还可以顺手统计一下样本数量:

wc -l output/positive.txt

如果只有十几行,建议先补充正样本再训练,否则级联分类器的泛化能力会很差。至少保证每个目标角度、亮度变化下都有样本。

5. 常见问题与避坑:标注与生成样本描述时的四个深坑

5.1 图片路径明明存在,训练时却找不到图

现象:positive.txt写好之后,运行opencv_createsamples提示Failed to open image,但你检查路径又是对的。

原因:大概率是相对路径问题。标注器运行时所在的工作目录和训练脚本运行时的工作目录不同,而在标注时写入的是相对路径,比如images/001.jpg,训练时一旦换了目录,这个相对路径就失效了。

解决:在save_positive里把图片路径转换成绝对路径再写入。Python 里用os.path.abspath(current_path)一行搞定。如果你已经生成了相对路径的描述文件,也可以用下面的命令批量替换成绝对路径前缀:

sed -i 's|^images/|/your/absolute/path/images/|g' output/positive.txt

从那以后我每次写标注工具都会强制走一遍“绝对路径检查”,再也不敢偷懒存相对路径。

5.2 框住目标时拖到了窗口边缘,保存后训练区域变成空白

现象:标注时看到矩形框有一部分落在图片外部,你以为 OpenCV 会自己忽略超出的部分,结果训练出来的正样本全是黑边或空白图。

原因:鼠标回调在窗口边界外也能上报坐标,这些坐标可能小于 0 或大于图像宽高,直接保存就产生了非法矩形。

解决:在保存前对坐标做“夹紧”,也就是文章前面的normalize_box函数。把x1、y1的最小值钳到 0,把x2、y2的最大值钳到width-1、height-1。如果钳完之后宽高小于等于 0,说明用户框了一个无效区域,应该丢弃,而不是硬写进文件。

这个 bug 最坑的地方是不报错,只有训练时才会暴露出样本质量差。我后期加了验证脚本,每张图都画出来看,才真正杜绝这个问题。

5.3 保存后图片上的框出现“重影”,整个界面越标越花

现象:画完一个框之后,鼠标继续在图上移动,之前的绿框周围还残留着红色拖影,像是几个矩形叠在一起。

原因:每次刷新时直接在原图上画矩形,上一帧的矩形已经污染了原图。鼠标移动一帧画一个框,原来的画痕没有清除,叠加起来就花掉了。

解决:用display = current_image.copy()先复制原始图像,再在副本上画所有框。原始的current_image永远保持干净。这个方案虽然每次刷新都多一次复制,但标注场景里图片通常不超过几百万像素,性能完全够用。

这里还有一个细节:如果图片很大,比如超过 4K,每次复制全图会有点卡。可以考虑只复制感兴趣区域,或者用cv2.imshow前缩放显示,但这都属于二次优化,不要一开始就做。

5.4 中文路径导致的乱码与读取失败

现象:图片放在包含中文或空格的文件夹里,比如正样本/图片 01.jpg。标注时一切正常,退出后用其他脚本读,要么路径乱码,要么直接None。

原因:OpenCV 的imread在 Windows 下对中文路径支持不好,这是老生常谈的玄学问题。另一个原因是写入文本文件时没指定编码,Windows 默认可能写入 GBK,而训练脚本以 UTF-8 读取,路径就错乱了。

解决:第一,输出文件用encoding="utf-8"打开;第二,尽量把训练集放到纯英文路径下;第三,如果要用中文路径,读取图片时不用cv2.imread,改用np.fromfile和cv2.imdecode的组合:

import numpy as np import cv2 img = cv2.imdecode(np.fromfile(img_path, dtype=np.uint8), cv2.IMREAD_COLOR)

这个组合可以绕开imread对中文路径的兼容性问题。不过我还是推荐直接避免中文路径,省得每个脚本都要记“要不要用 fromfile”的心。

6. 二次开发:把它改成你要的训练数据标注器

6.1 加一个撤销键,十行代码提升体验

当前版本里画错框只能退出重来,实际上撤销逻辑非常简单。在按键分支里加上z键,弹掉boxes列表最后一个元素:

elif key == ord("z"): if boxes: boxes.pop() print("撤销最后一个框,剩余", len(boxes), "个")

这可能是这个项目性价比最高的一次改动。按下z就能撤回一个错误框,再也不用手工编辑输出文件。如果你还想撤销到上一张图片,就得额外维护一个历史栈,那属于进阶需求。

6.2 增加类别标签,为多类别检测做准备

如果你想用它来标注多类别数据,不要急着改训练格式,先给框加标签。建议把boxes里的元素从四元组改成五元组:(x1, y1, x2, y2, label)。鼠标拖完后弹一个小的输入窗口,或者在键盘上按1、2、3分别代表不同类别,这样效率更高。

保存时把标签写进文件,例如:

/data/train/001.jpg 1 cat 120 80 96 108

后面再接其他检测框架时,只需要做一个格式转换函数,而不是回到标注环节重新标一遍。

6.3 导出成 VOC 格式的 XML

很多目标检测训练流程不认positive.txt,而是认 Pascal VOC 的 XML 结构。改造方法很直接:保存时不再写文本行,而是用xml.dom.minidom生成 XML 标签块。这样同一个标注器既能生成 OpenCV 训练集,也能生成深度学习模型的标注。

我一般会先在配置文件里加一个--format txt|xml参数,再写两个保存函数,共用内存里的boxes数据,改动量很小。做这一步的意义是让标注数据从“一次性样本”变成“可复用的资产”,后面换模型不用重新标一遍。

6.4 我的习惯

从那以后我每次改标注器,都强制自己先画一遍“数据流图”:确认内存里的数据、落盘的数据、训练读入的数据三者的格式严格一致,再开始写代码。标注工具看似简单,最容易翻车的恰恰是“中间改了一个字段,忘了同步保存函数和训练脚本”。希望这篇拆解能帮你把这个小工具吃得透透的,改成趁手的样子,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进