labelImg保存IndexError:classes文件排查与修复
上周帮一位同事排查他那边卡住的目标检测数据集现象很典型labelImg 里画框、切图、翻页都正常唯独一按 CtrlS 就抛IndexError: list index out of range弹窗关掉后标注框还在但保存目录里始终见不到 classes 文件。他当时已经标了两百多张以为要全部重来。实际上这个问题几乎不涉及数据损坏九成以上是类别列表在写盘那一刻是空的或者和已有标注对不上号。这篇就把这套排查思路从头到尾捋一遍classes 文件到底在什么时机被写入、报错栈的几种形态分别指向什么、怎么用五分钟止血、怎么改源码做防御、以及怎么把目录和类别顺序固化成一套不会返工的规范。刚接触标注工具的新手可以照着步骤抄已经踩过这个坑的老手可以直接跳到第 3 节的脚本和第 5 节的速查表。1. 先定位classes 文件是在哪个动作里被写下去的很多人以为 classes 文件是新建工程时生成的其实不是。它在 labelImg 里属于附产品跟着每次保存动作顺带落盘。搞清楚这个时机排查方向就清晰了一大半。1.1 两条完全不同的存盘分支labelImg 支持两种标注格式走的是两套逻辑。Pascal VOC 分支把类别名直接写进 XML 的name节点里类别信息是自包含的所以这个分支压根不会生成 classes 文件——如果你发现classes 文件怎么都存不出来先确认右下角格式开关是不是停在 PascalVOC 上这种情况不是 bug是设计如此。YOLO 分支就不一样了。YOLO 的 txt 标签每行是类别编号 x_center y_center w h只存整数编号名字和编号的映射必须单独放一个文件这就是 classes 文件的由来。于是每次保存都隐含两步先把当前框的类别名映射成编号写进 txt再检查类别表有没有新增项有就整体重写一次 classes。报错就发生在第二步——重写的时候需要按编号去列表里取名字列表是空的取不到越界。这也解释了一个容易被忽略的现象报错弹出后txt 标签其实是写成功的只有 classes 没更新。所以千万别急着删标注数据大概率还在。1.2 报错栈的三种形态指向三个不同方向同样是IndexError: list index out of range栈顶落在哪一行含义完全不同。我在排查时习惯先看终端里打印的最后一帧文件名和行号。栈顶落点典型含义处理方向labelImg.py中保存/写类别相关的函数内存里的类别列表是空的写盘时按编号取值越界补预定义类别文件或补已有 classes 文件labelFile.py中读取/切分相关的函数旧文件读进来后行数和编号对不上切分结果为空清洗 classes 文件里的空行、BOM、CRLF没有 Python 栈只有 Qt 层弹窗或静默失败根本不是索引问题是路径或权限被吞了异常检查保存目录是否存在、是否只读第三行特别值得说一句。有些人看到保存失败就默认是代码 bug实际上把保存目录设成一个不存在的路径、只读盘、或者被同步软件占用的目录异常在更外层就被吃掉了最终表现形式和索引越界几乎一样。我的一般做法是先手动在那个目录里新建一个空文件能建成就排除权限问题建不成就先去修路径。2. 四类真实成因按命中率从高到低排把上面三种形态拆细实际遇到的就是下面四类。我把它们按现场命中率排序你可以照着顺序试。2.1 成因一类别列表是空的写盘时直接越界这是最常见的一类。labelImg 启动时会从两个地方拿类别一是启动参数里指定的预定义类别文件二是保存目录下已经存在的 classes 文件。两个都没有列表就是空的。空列表本身不致命画框的时候你输入类别名列表会动态增长。问题出在保存逻辑上——某些版本的代码在写 classes 前会先取当前第一个类别做校验或者做默认值填充写成self.classes[0]这种形式。列表为空这一句直接炸。换句话说你一个框都没画就按保存或者画的框还没提交类别名就按保存就可能触发。实操上最稳的规避方式是启动时就带上预定义类别文件。这个文件放在工具目录的data/predefined_classes.txt一行一个类名。哪怕里面只有一行占位也能避免空列表。注意这个预定义文件只影响启动时的初始列表不会自动同步到保存目录的 classes 文件。两边的职责要分清很多人在这里混淆以为改了一个另一个会跟着变。2.2 成因二文件里混进空行或 BOM索引整体错位第二类是自己手工编辑过文件留下的后遗症。classes 文件被读进来时通常会按行切分切分结果里如果混入空字符串元素个数和真实类别数就对不上后面按编号取名字时就会错位一格甚至直接越界。三种典型的污染源文件末尾多留了一个空行某些切分方式会切出一个空元素用表格软件导出中间夹了纯空格行用记事本另存为UTF-8 带 BOM第一行开头多出三个不可见字节读出来的第一个类别名前面挂着 BOM和实际输入的名字匹配不上。前两种用肉眼看不出来得用命令查。Linux 或 macOS 下# -A 会把行尾符号和制表符显式打印出来 cat -A classes.txt正常应该看到每行结尾是$如果出现^M$说明是 Windows 换行如果某行只有一个$那就是空行。Windows PowerShell 下# 统计行数和实际类别数对不上就是有问题 (Get-Content .\classes.txt).Count # 检查首行是否带 BOM [System.IO.File]::ReadAllBytes(.\classes.txt)[0..2]如果输出的前三个字节是239 187 191那就是带 BOM 的 UTF-8需要重存成UTF-8 无 BOM。2.3 成因三标签里的编号超出了类别总数第三类属于改过类别表但没改历史标签。YOLO 的编号是从 0 开始的假设你先定了五个类标了一批图后来觉得其中两个类没必要把 classes 精简成三个。这时候旧标签里第一列可能是 3 或 4而新类别表只有 0、1、2 三个下标按编号去取名字自然越界。这类报错有个明显特征只在打开特定几张旧图时触发新图一切正常。定位方法很简单把出问题那张图的 txt 打开看第一列的数字再数一遍 classes 文件的行数对不上就是它。处理上有两个选择。要么把删掉的类别补回类别表末尾保证编号不变要么写脚本把所有大于等于新类别数的编号批量修正。前者安全但类别表会越来越长后者干净但必须全量扫描不能只改几张。我个人倾向先补回去、把数据集完整跑通再安排一次专门的重映射两件事不要混在一次操作里做。2.4 成因四路径和权限问题伪装成索引错误第四类我放在最后但它造成的时间浪费往往最多因为它看上去像代码 bug。保存目录不存在、目录在只读介质上、路径里有中文和空格、路径长度超限、目录被云同步客户端锁定这些都可能让文件写入失败。异常在某个环节被吞掉之后上层继续往下走走到取值那一步就变成了索引越界报错信息指向的地方跟真实原因隔着好几层。排查手法很笨但有效在工具里点更改保存目录直接选一个桌面上的空文件夹重新保存一次。如果这样就好了说明原来那个路径有问题回头再逐个排查目录是否存在、是否有写权限、是否被占用。3. 分三档修应急止血、改源码、根治流程问题定位清楚之后动手就有章法了。我一般按先让手上的活能干下去再修工具最后修流程的顺序来不要一上来就改源码。3.1 应急档五分钟把 classes 文件补齐手上的标注不能停先做这四步关掉工具别让它在崩溃状态继续写盘进保存目录把已有的标签目录整体复制一份做快照命名成带日期的备份目录手动新建或清空 classes 文件一行一个类名顺序必须和原来一致末尾保留一个换行但不要额外空行用无 BOM 的 UTF-8 重新保存然后重新打开工具验证。新建之后的第一件事不是继续标而是打开一张之前已经标过的图确认框的类别名显示正确。如果显示成别的类说明顺序错了立刻回滚备份。这一步能省掉后面几小时的返工。3.2 稳妥档给保存逻辑加上防御式判断如果这个工具你要长期用、团队里多人用那光靠手工补文件迟早还会再犯。我习惯把写文件这一段改成防御式写法核心就三件事过滤空白、为空就跳过、显式指定编码和换行符。import os def save_classes(classes_file_path, classes): # 去掉首尾空白剔除空串和纯空格行 cleaned [] for c in classes: if c is None: continue name c.strip() if name and name not in cleaned: cleaned.append(name) if not cleaned: # 空列表直接跳过写盘不要让它走到索引取值那一步 print([warn] 类别列表为空跳过写 classes 文件) return False parent os.path.dirname(os.path.abspath(classes_file_path)) if parent and not os.path.isdir(parent): os.makedirs(parent, exist_okTrue) # newline\n 是关键避免 Windows 上写出 CRLF 再被读回来时多出空行 with open(classes_file_path, w, encodingutf-8, newline\n) as f: f.write(\n.join(cleaned) \n) return True读取侧同样要加容错重点是encodingutf-8-sig。这个编码会自动吃掉文件开头的 BOM是解决第一行类别名怎么都对不上最省事的办法比让所有人手动另存文件靠谱得多。def load_classes(classes_file_path): if not os.path.isfile(classes_file_path): return [] result [] with open(classes_file_path, r, encodingutf-8-sig) as f: for line in f: name line.strip() if name and name not in result: result.append(name) return result注意改源码之前先把原文件复制一份改名留着。改坏了能十分钟还原比对着报错猜半天强。3.3 根治档用脚本批量对账让类别表有唯一来源手工维护类别表最大的问题是不知道什么时候就不一致了。我的做法是写一个小脚本做全量对账每次开工前跑一遍输出当前标签里实际出现过的编号分布跟类别表行数一比一目了然。import os from collections import Counter label_dir labels skip_names {classes.txt} counter Counter() bad_lines [] max_id -1 for name in os.listdir(label_dir): if not name.endswith(.txt) or name in skip_names: continue path os.path.join(label_dir, name) with open(path, r, encodingutf-8-sig) as f: for lineno, line in enumerate(f, 1): parts line.split() if not parts: continue # YOLO 一行至少 5 个字段类别编号 4 个坐标 if len(parts) 5: bad_lines.append((name, lineno, line.strip())) continue try: cid int(float(parts[0])) except ValueError: bad_lines.append((name, lineno, line.strip())) continue counter[cid] 1 max_id max(max_id, cid) print(出现的类别编号分布:, dict(sorted(counter.items()))) print(最大编号:, max_id, - classes 文件至少需要, max_id 1, 行) print(异常行数量:, len(bad_lines)) for row in bad_lines[:10]: print( 异常行:, row)这个脚本跑出来的信息量比报错弹窗大得多哪个编号出现得最多、有没有孤立的高编号、有没有残缺行全在里面。我遇到过一种情况编号分布里只有 0 和 2没有 1说明中间那个类被删过但历史标签没重映射这种情况不用脚本扫一遍根本发现不了。如果是从 VOC 格式往 YOLO 迁还有一步是把 XML 里出现过的类别名汇总成类别表。这一步的顺序决定编号脚本里按字母序还是按首次出现序都行但定下来之后就不能改。import os import xml.etree.ElementTree as ET xml_dir Annotations ordered [] for fn in sorted(os.listdir(xml_dir)): if not fn.endswith(.xml): continue root ET.parse(os.path.join(xml_dir, fn)).getroot() for obj in root.findall(object): node obj.find(name) if node is None or node.text is None: continue name node.text.strip() if name and name not in ordered: ordered.append(name) with open(classes.txt, w, encodingutf-8, newline\n) as f: f.write(\n.join(ordered) \n) print(共汇总类别, len(ordered), 个:, ordered)4. 一套能长期复用的目录与启动参数规范工具本身的问题修完之后还得让流程本身不容易出错。我现在的标注工程基本都按下面这套结构摆多人协作也没出过类别错乱。4.1 目录结构类别表放在标签目录的同级dataset/ images/ # 原图只读不放任何生成物 labels/ # YOLO txt 标签 classes.txt # 类别表跟标签同级跟标签一起备份 backup/ 20240115_labels/ predefined_classes.txt # 启动用的初始类别可以跟上面内容一致关键的三个约定类别表和标签放一起这样切目录、拷数据集时不会漏原图目录只读任何工具都不能往里面写东西备份目录用日期命名随时能回滚到某个时间点。4.2 启动参数一次写对减少手工切换与其每次在界面上点来点去不如把常用参数固化成一个启动脚本。下面这条是 YOLO 模式的常用组合python labelImg.py ./images ./predefined_classes.txt \ --save_dir ./labels \ --format yolo \ --autosave--save_dir指定标签输出目录避免默认往图片目录里写--format yolo省掉每次手动切格式这一步能规避掉相当一部分classes 存不出来的误报--autosave让翻页时自动保存减少忘记按 CtrlS 导致的漏标。参数名在不同分支上略有差异以--help输出为准别硬抄。参数作用什么时候必须加位置参数一图片目录总是位置参数二预定义类别文件希望启动就带类别时--save_dir标签输出目录图片和标签要分开存时--format输出格式用 YOLO 时必须显式指定--autosave翻页自动保存大批量标注时4.3 环境与版本三个容易忽视的前提第一个是资源文件。部分发行包需要先在本目录执行一次资源编译否则界面能起但一些图标和默认配置缺失表现得很诡异pyrcc5 -o libs/resources.py resources.qrc第二个是依赖版本。图形界面库版本太老在某些新版解释器上会直接崩装之前先确认版本号在 5.15 以上。同一个环境里不要同时装两个不同的图形界面绑定库冲突起来报错信息会离谱到看不出根源。第三个是编码。工具读写的文本文件默认跟随系统区域设置在有中文类名的场景下务必保证所有相关文件统一用无 BOM 的 UTF-8。这条在团队协作里最容易出问题一个人用 Windows 记事本改过另一个人用命令行读类别名就开始对不上。5. 常见问题速查与踩坑实录把上面这些串起来日常遇到的现象基本能在这张表里找到对应项。5.1 现象与处理对照表现象最可能的原因处理方式一保存就报 IndexError弹窗后框还在类别列表为空写盘时越界补预定义类别文件或手写 classes 文件只有某几张图报错新图正常旧标签编号超出当前类别数对账脚本扫一遍重映射或补回缺失类别保存后标签里全是 0类别名不对classes 文件顺序被改过回滚备份恢复原始顺序类别名首字符莫名其妙文件带 BOM用utf-8-sig读取或重存无 BOM界面显示保存成功但文件没变保存目录只读或被占用换一个桌面空目录验证明明标了 YOLO 却找不到 classes格式开关停在 PascalVOC切到 YOLO 重新保存重启后类别列表清空预定义文件没放在指定位置检查路径或改用启动参数指定5.2 几条没人写进文档的经验类别顺序是契约不是列表。YOLO 标签只存整数整数指向哪一行完全取决于类别表的顺序。所以类别表一旦确定追加只能往末尾加中间绝对不能插入或删除。这条规则我在每个项目开始时都会写进说明里因为它是那种改了当时没事、三天后炸一片的坑。不要用表格软件编辑类别文件。看着方便但它会自动处理编码、行尾和空白字符导出来的文件经常带着看不见的脏东西。用纯文本编辑器把显示空白字符打开问题一眼可见。崩了先备份再修。报错之后最危险的操作是在崩溃状态下反复按保存某些实现可能在写了一半的时候中断留下半截文件。正确顺序永远是关工具、拷备份、再动手。一次只改一个变量。排查时如果同时改了格式、目录、类别文件、源码最后好了也不知道是哪一步修好的下次遇到还得从头来。我的习惯是每次改动前记一行备注改完立刻验证一张旧图加一张新图。团队协作时把类别表纳入版本管理。它就是一个几行的文本文件但重要性等同于数据集定义。谁改了顺序、什么时候改的、为什么改都得能查到。少了这一层事后对账的成本高得离谱。5.3 开工前的一条体检命令最后补一个我每次开标注前都会跑的检查几秒钟就能确认环境是干净的# 统计类别表行数、检查行尾、确认没有空行 wc -l labels/classes.txt grep -c ^$ labels/classes.txt cat -A labels/classes.txt第一条输出行数第二条输出空行数量正常应该是 0命令返回非零就说明没有空行第三条把不可见字符打出来。三条一起看BOM、空行、换行符三类污染一次全暴露。养成开标注前跑一遍的习惯能省掉后面大量莫名其妙的排查。我个人在实际项目里的体会是IndexError: list index out of range这个报错本身一点都不难修难的是它出现得太晚——往往等你标完一大批图、准备导出训练时才冒出来前面所有关于类别顺序的假设都得重新验证一遍。所以与其研究怎么修不如把类别表当成数据集的一部分来管理定好顺序、纳入备份、开工前体检、每次只追加。做到这四点这个报错基本就不会再找上门了。