CVAT 导出崩溃排查实录:一个空壳轨迹引发的连锁反应

一、遇到问题:导出任务时的神秘崩溃

在使用老版本 CVAT(Computer Vision Annotation Tool)导出标注数据时,我们遇到了一个导致导出任务直接中断的致命报错。报错信息指向了 dataset_manager 模块,具体的异常堆栈如下:

text 复制代码
Traceback (most recent call last):
  ...
  File "/home/django/cvat/apps/dataset_manager/annotation.py", line 393, in _get_objects_by_frame
    shape = obj["shapes"][-1] # optimization for old tracks
IndexError: list index out of range

此外,在网页端导出时也遇到了同样的报错:

Could not export dataset for the task 573 Error: Request failed with status code 500. "IndexError: list index out of range\n"。

错误信息明确指向 Task 573,说明问题并非随机发生,而是与特定任务的数据状态密切相关。

二、分析问题:从异常堆栈推导业务逻辑

面对这个 IndexError: list index out of range 错误,我们进行了深入的代码逻辑分析:

  1. 代码行为 :报错行 obj["shapes"][-1] 试图获取某个轨迹(Track)的最后一个形状(Shape)。
  2. 异常原因 :在 Python 中,对一个空列表执行 [-1] 索引操作会抛出越界异常。这说明在数据库中,存在至少一条 "空壳轨迹" ------即该轨迹在数据库中有记录,但其对应的形状列表(shapes)为空。
  3. 业务场景:这通常是因为标注人员在操作时,创建了轨迹但忘记画框,或者在删除某一帧的标注框时,只删除了形状而没有彻底删除轨迹本身,从而产生了这种"脏数据"。

理解了异常的本质后,下一步就是要在数据库层面把这条"空壳轨迹"找出来。

bash 复制代码
# 容器内部 Supervisor 记录的完整日志文件
django@02dd1763fd8d:~$ cat /var/log/supervisor/cvat_server-stderr.log | grep -A 30 "IndexError"
cat: /var/log/supervisor/cvat_server-stderr.log: No such file or directory
django@02dd1763fd8d:~$ cat /var/log/apache2/error.log | grep -A 30 "IndexError"
cat: /var/log/apache2/error.log: Permission denied
django@02dd1763fd8d:~$ cat /var/log/mod_wsgi/error.log | grep -A 30 "IndexError"
cat: /var/log/mod_wsgi/error.log: No such file or directory
django@02dd1763fd8d:~$
# 当前在容器内使用的是 django 普通用户,而 Apache 的日志文件属于系统级文件,普通用户没有权限读取(所以报了 Permission denied)

# 退出当前容器,以 root 身份重新进入容器
docker exec -u root -it cvat_server bash

root@02dd1763fd8d:~# cat /var/log/apache2/error.log | grep -A 30 "IndexError"
root@02dd1763fd8d:~# grep -rnw /var/log -e "IndexError"
root@02dd1763fd8d:~#
# 既然常规日志走不通,我们就用终极绝招:直接在容器内运行一段 Python 脚本,绕过 Web 服务,强制调用 CVAT 底层的导出逻辑。

# 于你的 CVAT 版本比较老,调用 CVAT 自带的命令行工具来触发导出。
python manage.py export_task 573 coco

root@02dd1763fd8d:~# python manage.py export_task 573 coco
Unknown command: 'export_task'
Type 'manage.py help' for usage.
root@02dd1763fd8d:~# ^C
root@02dd1763fd8d:~#

# 找到老版本 CVAT 真实的导出函数。从你的 grep 结果来看,最底层的真实入口是 /home/django/cvat/apps/dataset_manager/task.py 第 741 行的 export_task 函数。

root@02dd1763fd8d:~# grep -rn "def export" /home/django/cvat/apps/dataset_manager/
/home/django/cvat/apps/dataset_manager/formats/registry.py:68:def exporter(name, version, ext, display_name=None, enabled=True, dimension=DimensionType.DIM_2D):
/home/django/cvat/apps/dataset_manager/project.py:19:def export_project(project_id, dst_file, format_name,
/home/django/cvat/apps/dataset_manager/project.py:124:    def export(self, dst_file: str, exporter: Callable, host: str='', **options):
/home/django/cvat/apps/dataset_manager/views.py:46:def export(dst_format, project_id=None, task_id=None, job_id=None, server_url=None, save_images=False):
/home/django/cvat/apps/dataset_manager/views.py:107:def export_job_annotations(job_id, dst_format=None, server_url=None):
/home/django/cvat/apps/dataset_manager/views.py:110:def export_job_as_dataset(job_id, dst_format=None, server_url=None):
/home/django/cvat/apps/dataset_manager/views.py:113:def export_task_as_dataset(task_id, dst_format=None, server_url=None):
/home/django/cvat/apps/dataset_manager/views.py:116:def export_task_annotations(task_id, dst_format=None, server_url=None):
/home/django/cvat/apps/dataset_manager/views.py:119:def export_project_as_dataset(project_id, dst_format=None, server_url=None):
/home/django/cvat/apps/dataset_manager/views.py:123:def export_project_annotations(project_id, dst_format=None, server_url=None):
/home/django/cvat/apps/dataset_manager/task.py:543:    def export(self, dst_file, exporter, host='', **options):
/home/django/cvat/apps/dataset_manager/task.py:630:    def export(self, dst_file, exporter, host='', **options):
/home/django/cvat/apps/dataset_manager/task.py:691:def export_job(job_id, dst_file, format_name,
/home/django/cvat/apps/dataset_manager/task.py:741:def export_task(task_id, dst_file, format_name,
root@02dd1763fd8d:~#

# 捕获到了完整的 Python 报错堆栈(Traceback)!
python -c "
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cvat.settings.production')
django.setup()

from cvat.apps.dataset_manager.task import export_task

# 强制触发 Task 573 的 COCO 格式导出
# 如果你实际导出的是 YOLO 格式,请把下面的 'coco' 换成 'yolo'
export_task(573, '/tmp/test_export.zip', 'coco')
"

Traceback (most recent call last):
  File "<string>", line 11, in <module>
  File "/home/django/cvat/apps/dataset_manager/task.py", line 750, in export_task
    task.init_from_db()
  File "/home/django/cvat/apps/dataset_manager/task.py", line 628, in init_from_db
    self._merge_data(annotation.ir_data, start_frame, overlap)
  File "/home/django/cvat/apps/dataset_manager/task.py", line 599, in _merge_data
    annotation_manager.merge(data, start_frame, overlap)
  File "/home/django/cvat/apps/dataset_manager/annotation.py", line 158, in merge
    tracks.merge(data.tracks, start_frame, overlap)
  File "/home/django/cvat/apps/dataset_manager/annotation.py", line 214, in merge
    old_objects_by_frame = self._get_objects_by_frame(self.objects, start_frame)
  File "/home/django/cvat/apps/dataset_manager/annotation.py", line 393, in _get_objects_by_frame
    shape = obj["shapes"][-1] # optimization for old tracks
IndexError: list index out of range
root@02dd1763fd8d:~#

三、排查问题:老版本 CVAT 数据库模型的"连环坑"

为了安全起见,我们决定编写 Python 脚本在数据库层面进行排查。但在执行过程中,我们遭遇了老版本 CVAT 复杂的底层模型关联带来的挑战。

第一坑:字段名变更

初次尝试使用 shapes__isnull=True 查询时,Django 抛出 FieldError。通过排查发现,老版本 CVAT 中轨迹的形状字段名为 trackedshape,而非新版本的 shapes。

第二坑:Task 与 Job 的关联断裂

修正字段名后,尝试使用 LabeledTrack.objects.filter(task=task) 查询,再次报错。查阅字段列表后发现,老版本的 LabeledTrack 并不直接关联 Task,而是关联 Job。

第三坑:Job 与 Task 的中间层

当我们试图通过 task.job_set 或 Job.objects.filter(task_id=task.id) 获取 Job 时,依然报错。最终通过 Django 的报错提示,我们彻底摸清了老版本 CVAT 的真实数据拓扑结构:

text 复制代码
Task -> Segment -> Job -> LabeledTrack

原来 Task 和 Job 之间还隔着一个 Segment 层,这是老版本 CVAT 特有的架构设计。

四、解决问题:精准定位与数据清理

在理清了 Task -> Segment -> Job -> LabeledTrack 的关联链路后,我们编写了终极排查脚本,成功绕过了所有反向关联属性的坑:

python 复制代码
# 核心排查逻辑
segment_ids = Segment.objects.filter(task_id=task.id).values_list('id', flat=True)
job_ids = Job.objects.filter(segment_id__in=segment_ids).values_list('id', flat=True)
tracks = LabeledTrack.objects.filter(job_id__in=job_ids)
for track in tracks:
    if not track.trackedshape_set.exists():
        # 找到空壳轨迹
        print(f'轨迹 ID: {track.id} | 帧数: {track.frame}')

脚本运行后,成功在 Task 573 中精准揪出了导致崩溃的罪魁祸首:轨迹 ID 36956(位于 Frame 2003)。

在确认该轨迹确实为空壳后,我们执行了精准的删除操作:

python 复制代码
track = LabeledTrack.objects.get(id=36956)
track.delete()

删除后重新触发导出任务,Task 573 的数据顺利导出完成,问题彻底解决。

bash 复制代码
# Python 脚本,直接在 Task 573 中找出这些"空壳轨迹"并把它们删掉。
# 显式导入了 Segment 模型,完全顺应了老版本 CVAT Task -> Segment -> Job -> LabeledTrack 的真实数据库结构,避开了所有可能报错的反向关联属性。

python -c "
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cvat.settings.production')
django.setup()

from cvat.apps.engine.models import Task, Segment, Job, LabeledTrack

# 1. 获取 Task 573
task = Task.objects.get(id=573)

# 2. 通过 Segment 找到该任务下所有的 Job ID
segment_ids = Segment.objects.filter(task_id=task.id).values_list('id', flat=True)
job_ids = Job.objects.filter(segment_id__in=segment_ids).values_list('id', flat=True)

print(f'正在排查并清理 Task 573 (包含 {len(job_ids)} 个 Job)...')

# 3. 在这些 Job 中查找所有的轨迹
tracks = LabeledTrack.objects.filter(job_id__in=job_ids)
count = 0

for track in tracks:
    # 检查是否存在关联的形状 (trackedshape)
    if not track.trackedshape_set.exists():
        print(f'发现空壳轨迹,正在删除 -> 轨迹 ID: {track.id} | 帧数: {track.frame} | 标签 ID: {track.label_id}')
        track.delete()
        count += 1

print('-' * 50)
if count > 0:
    print(f'清理完成!共成功删除了 {count} 个导致崩溃的空轨迹。')
    print('现在你可以回到 CVAT 网页端,重新尝试导出了!')
else:
    print('排查完成:没有找到空轨迹。如果仍然无法导出,可能是其他数据问题。')

# 精准定位到了罪魁祸首------轨迹 ID: 36956
正在排查 Task 573 (包含 9 个 Job)...
诊断完成!发现了 1 个有问题的空轨迹:
--------------------------------------------------
轨迹 ID: 36956 | 所在帧数 (Frame): 2003 | 标签 ID: 84
--------------------------------------------------

# 直接把这个导致崩溃的"空壳轨迹"从数据库中彻底删除。

python -c "
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cvat.settings.production')
django.setup()

from cvat.apps.engine.models import LabeledTrack

# 精准定位并删除 ID 为 36956 的空轨迹
track_id_to_delete = 36956
try:
    track = LabeledTrack.objects.get(id=track_id_to_delete)
    print(f'正在删除问题轨迹 -> ID: {track.id} | 帧数: {track.frame} | 标签 ID: {track.label_id}')
    track.delete()
    print('删除成功!现在你可以回到 CVAT 网页端重新尝试导出了。')
except LabeledTrack.DoesNotExist:
    print(f'未找到 ID 为 {track_id_to_delete} 的轨迹,可能已被删除。')

正在删除问题轨迹 -> ID: 36956 | 帧数: 2003 | 标签 ID: 84
删除成功!现在你可以回到 CVAT 网页端重新尝试导出了。

五、经验总结与反思

这次排查过程不仅成功修复了导出崩溃的问题,还为我们留下了宝贵的经验:

  1. 老版本架构的复杂性 :在维护老旧系统时,不能想当然地套用新版本的数据模型。Django 的 FieldError 报错信息(Choices are: ...)是探索未知数据库结构的最佳指南针。
  2. 脏数据的产生与防御:在复杂的标注工具中,级联删除(Cascade Delete)如果不彻底,极易产生孤儿数据。在编写数据清理脚本时,应遵循"先诊断打印,后确认删除"的安全原则。
  3. 版本确认 :问题解决后,可通过 pip show cvat | grep Version 确认当前环境的具体版本,以便在未来的运维中建立版本档案。

通过这次实战,我们不仅清理了数据库中的"毒瘤",也彻底摸清了该老版本 CVAT 的底层数据流转逻辑,为后续的系统维护和升级打下了坚实基础。

相关推荐
PaperData9 小时前
1985-2025年全国区县专利申请与授权面板数据
数据库
snpgroupcn9 小时前
大数据量SAP迁移方案:系统越大,越不能硬搬
数据库·sap
Flynt10 小时前
Redis Cluster主节点挂了,为什么"高可用"还全员掉线?我把三次kill的记录翻出来了
数据库·redis·分布式
笃行35010 小时前
KingbaseES 数据加密全解:从 SSL 到全密态
数据库
Ivanqhz10 小时前
激活函数在 Transformer 中的作用及各种变体简述
java·linux·数据库·人工智能·深度学习
java1234_小锋12 小时前
【技术专题】Mysql8 数据库 - Mysql8 数据类型简介
数据库·mysql
知行EDI12 小时前
知行之桥 MaBang 端口使用指南——Create Order 订单创建篇
java·服务器·数据库
成旭先生12 小时前
【2026】企业信息模糊查询 API 实战:名称、注册号、统一社会信用代码、企业类型与法人一次查全
服务器·数据库·数据服务
JosieBook12 小时前
【数据库】MySQL 实战精通系列 · 第10篇:分库分表与分布式事务实战
数据库·分布式·mysql