5分钟搞定!用Python+Tkinter自制QWEN-VL图文标注工具(附完整代码)
从零构建:面向QWEN-VL的轻量级图文标注工具实战指南
最近在尝试为一些特定的视觉任务微调多模态大模型时,我发现了一个普遍存在的痛点:市面上通用的标注工具要么过于笨重,要么无法直接生成符合特定模型微调格式的数据。尤其是当我们选定像QWEN-VL这样优秀的开源视觉语言模型作为基座时,为其准备格式规整的(image, conversation)配对数据,成了项目启动的第一道门槛。手动整理JSON文件、核对图片路径、确保对话结构正确——这些琐碎的工作不仅耗时,还极易出错。
对于中小型团队或个人开发者而言,我们需要的不是一个功能庞杂的工业级平台,而是一个能快速上手、聚焦核心需求、并且代码完全掌握在自己手中的解决方案。今天,我想分享的就是如何利用Python的标准库Tkinter,在短时间内打造一个专属于你自己项目的QWEN-VL图文标注工具。这个工具将完全围绕QWEN-VL的微调数据格式设计,实现“打开图片-输入对话-一键保存为合规JSON”的流畅流程。更重要的是,我会带你深入代码细节,理解每一步的设计考量,并附上完整的、可直接复用的项目代码,让你能根据自身业务场景灵活调整。
1. 项目核心设计思路与准备工作
在动手写代码之前,明确我们要解决的核心问题至关重要。QWEN-VL的微调数据格式本质上是一个结构化的对话列表,其中嵌入了图片引用。一个典型的数据样本如下所示:
{
"id": "unique_sample_id",
"conversations": [
{
"from": "user",
"value": "Picture 1: <img>path/to/image.jpg</img>\n图中的狗是什么品种?"
},
{
"from": "assistant",
"value": "图中是一只拉布拉多犬。"
},
{
"from": "user",
"value": "框出图中的格子衬衫"
},
{
"from": "assistant",
"value": "<ref>格子衬衫</ref><box>(588,499),(725,789)</box>"
}
]
}
我们的工具需要自动化地生成这种格式。这引出了几个关键设计点:
- 图片管理:工具需要能加载本地图片,并在保存时,将图片自动复制到指定目录,同时生成正确的相对或绝对路径,写入JSON的
value字段。 - 对话流编辑:需要支持多轮对话的灵活输入。初始的一轮(用户描述图片,助手回答)是固定的,后续可以动态添加更多轮次的问答。
- 数据序列化:将用户在界面中输入的所有信息(图片路径、多轮对话内容)无缝地组装成上述JSON格式,并保存为文件。
- 文件命名与组织:需要一套机制来唯一标识每个标注样本,通常使用随机生成的ID,并确保图片文件名与JSON文件中的引用ID一致。
基于这些需求,我们选择Python的Tkinter库作为GUI框架。它无需安装任何第三方依赖(除了PIL用于图片处理),是标准库的一部分,非常适合快速开发轻量级桌面应用。
提示:虽然Tkinter的界面风格比较“经典”,但它的优势在于极高的可移植性和零依赖。对于内部使用的工具,功能性和开发效率远比炫酷的UI更重要。
首先,确保你的开发环境已就绪。你需要安装Python(建议3.7及以上版本)以及Pillow库(PIL的一个友好分支)。
# 使用pip安装必要的库
pip install Pillow
安装完成后,就可以开始创建我们的项目目录了。建议的目录结构如下:
qwen_vl_annotator/
├── label_tool.py # 主标注工具脚本
├── merge_tool.py # 数据合并与后处理脚本
├── saves/ # 标注数据原始输出目录(工具自动创建)
├── requirements.txt # 项目依赖说明
└── README.md # 项目说明文档
requirements.txt文件内容很简单:
Pillow>=9.0.0
2. 标注工具核心模块拆解与实现
我们将主工具label_tool.py的功能分解为几个核心模块:图形界面初始化、图片加载与显示、动态对话输入框管理、以及数据保存逻辑。让我们逐一深入。
2.1 界面骨架与图片显示区
我们首先搭建一个主窗口,并将其划分为左右两个主要区域:左侧用于显示图片,右侧用于所有的交互控件。
import tkinter as tk
from tkinter import filedialog
from PIL import Image, ImageTk
import json
import random
import string
import os
import shutil
import sys
class AnnotationTool:
def __init__(self, root):
self.root = root
self.root.title("QWEN-VL 图文标注工具")
self.root.geometry("1200x700") # 稍大的初始窗口尺寸
# 存储关键数据
self.image_path = None
self.dialogue_entries = [] # 存储动态添加的对话输入框对 (用户, 助手)
# 创建左侧图片显示框架
self.image_frame = tk.Frame(self.root, relief=tk.SUNKEN, borderwidth=2)
self.image_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=10, pady=10)
self.image_label = tk.Label(self.image_frame, text="请打开一张图片", bg='#f0f0f0')
self.image_label.pack(expand=True)
# 创建右侧控制面板框架
self.control_frame = tk.Frame(self.root)
self.control_frame.pack(side=tk.RIGHT, fill=tk.Y, padx=10, pady=10)
# 接下来将在control_frame中添加各种按钮和输入框
ImageTk模块是Pillow与Tkinter的桥梁,负责将PIL的Image对象转换为Tkinter可显示的PhotoImage对象。这里我们预留了一个image_label用于后续显示图片。
2.2 实现图片加载与自适应显示
图片加载功能需要处理文件选择,并确保不同尺寸的图片都能在固定高度的显示区内完整呈现,同时保持宽高比。
def load_image(self):
"""打开文件对话框选择图片,并加载显示"""
file_path = filedialog.askopenfilename(
title="选择图片",
filetypes=[("图片文件", "*.png *.jpg *.jpeg *.bmp *.gif")]
)
if not file_path:
return
self.image_path = file_path
try:
# 使用PIL打开图片
img = Image.open(file_path)
# 计算适应显示区域的高度(例如500像素)的宽度,保持宽高比
display_height = 500
h_percent = display_height / float(img.size[1])
display_width = int(float(img.size[0]) * h_percent)
# 调整图片尺寸
img_resized = img.resize((display_width, display_height), Image.Resampling.LANCZOS)
# 转换为Tkinter PhotoImage
self.tk_image = ImageTk.PhotoImage(img_resized)
# 更新Label显示的图片
self.image_label.config(image=self.tk_image, text='')
# 更新状态提示
self.status_label.config(text=f"已加载: {os.path.basename(file_path)}")
except Exception as e:
tk.messagebox.showerror("错误", f"无法加载图片: {e}")
这里有几个细节值得注意:
Image.Resampling.LANCZOS:这是高质量的重采样滤波器,在Pillow 9.1.0之后推荐使用,替代了旧的Image.ANTIALIAS。- 保持引用:将
self.tk_image赋值给实例变量至关重要。如果只在局部变量中保存,图片可能会被Python的垃圾回收机制销毁,导致显示为空白。 - 异常处理:使用
try...except包裹图片处理逻辑,并用messagebox向用户反馈友好错误信息,能极大提升工具健壮性。
2.3 构建动态对话输入系统
QWEN-VL的微调数据支持多轮对话。我们的工具需要支持在基础问答之外,动态添加任意轮次的后续对话。我们将创建两种输入区域:
- 固定首轮对话:用户输入(包含图片引用)和助手回答。
- 动态新增对话轮次:通过按钮触发,每次添加一对新的用户和助手输入框。
def create_input_section(self):
"""在控制面板创建输入区域"""
# 固定首轮对话 - 用户输入 (包含图片引用)
tk.Label(self.control_frame, text="【首轮】用户指令 (描述图片或提问):",
font=('Arial', 10, 'bold')).pack(anchor=tk.W, pady=(10, 5))
self.user_input_frame = tk.Frame(self.control_frame)
self.user_input_frame.pack(fill=tk.X, pady=5)
# 图片引用会自动添加,这里用户只需输入问题部分
self.user_instruction_entry = tk.Text(self.user_input_frame, height=3, width=50)
self.user_instruction_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
# 固定首轮对话 - 助手回答
tk.Label(self.control_frame, text="【首轮】助手回答:",
font=('Arial', 10, 'bold')).pack(anchor=tk.W, pady=(10, 5))
self.assistant_answer_entry = tk.Text(self.control_frame, height=3, width=50)
self.assistant_answer_entry.pack(fill=tk.X, pady=5)
# 分隔线
ttk.Separator(self.control_frame, orient='horizontal').pack(fill=tk.X, pady=20)
# 动态对话区域标题
self.dynamic_section_label = tk.Label(self.control_frame,
text="后续对话轮次 (可选):",
font=('Arial', 10, 'bold'))
self.dynamic_section_label.pack(anchor=tk.W, pady=(0, 10))
# 用于容纳所有动态添加的对话对的框架
self.dynamic_dialogues_frame = tk.Frame(self.control_frame)
self.dynamic_dialogues_frame.pack(fill=tk.X)
# “添加一轮对话”按钮
self.add_dialogue_btn = tk.Button(self.control_frame,
text="+ 添加一轮对话",
command=self.add_dialogue_pair,
bg='#e7f3ff')
self.add_dialogue_btn.pack(pady=10)
def add_dialogue_pair(self):
"""动态添加一对用户和助手输入框"""
pair_frame = tk.Frame(self.dynamic_dialogues_frame)
pair_frame.pack(fill=tk.X, pady=5)
tk.Label(pair_frame, text="用户:", width=8).pack(side=tk.LEFT)
user_entry = tk.Text(pair_frame, height=2, width=30)
user_entry.pack(side=tk.LEFT, padx=(0, 10))
tk.Label(pair_frame, text="助手:", width=8).pack(side=tk.LEFT)
assistant_entry = tk.Text(pair_frame, height=2, width=30)
assistant_entry.pack(side=tk.LEFT)
# 存储这对输入框的引用
self.dialogue_entries.append((user_entry, assistant_entry, pair_frame))
# 如果对话轮次过多,可以添加滚动条(此处略去实现)
这个设计使得标注流程非常直观:标注员先看图片,然后在对应区域填写第一轮问答。如果需要更复杂的多轮对话(例如,先问物体类别,再要求定位),只需点击按钮添加新的输入框即可。
2.4 实现数据保存与格式封装
这是工具最核心的部分,它需要将散落在各个输入框中的文本,与图片文件一起,打包成符合QWEN-VL格式的JSON文件。
def save_annotation(self):
"""收集所有输入,生成JSON文件并保存图片"""
if not self.image_path:
tk.messagebox.showwarning("警告", "请先加载一张图片。")
return
# 1. 生成唯一ID和文件名
sample_id = ''.join(random.choices(string.ascii_lowercase + string.digits, k=12))
json_filename = f"{sample_id}.json"
image_filename = f"{sample_id}{os.path.splitext(self.image_path)[1]}" # 保留原后缀
# 2. 创建保存目录(如果不存在)
save_dir = "saves"
os.makedirs(save_dir, exist_ok=True)
json_filepath = os.path.join(save_dir, json_filename)
image_filepath = os.path.join(save_dir, image_filename)
# 3. 构建对话列表
conversations = []
# 3.1 构建首轮用户指令:拼接图片引用和用户输入
user_instruction = self.user_instruction_entry.get("1.0", tk.END).strip()
# 构建QWEN-VL格式的图片引用字符串
first_user_value = f"Picture 1: <img>{image_filename}</img>\n{user_instruction}"
conversations.append({"from": "user", "value": first_user_value})
# 3.2 首轮助手回答
assistant_answer = self.assistant_answer_entry.get("1.0", tk.END).strip()
conversations.append({"from": "assistant", "value": assistant_answer})
# 3.3 添加动态轮次的对话
for user_entry, assistant_entry, _ in self.dialogue_entries:
user_text = user_entry.get("1.0", tk.END).strip()
assistant_text = assistant_entry.get("1.0", tk.END).strip()
if user_text and assistant_text: # 只保存非空对话对
conversations.append({"from": "user", "value": user_text})
conversations.append({"from": "assistant", "value": assistant_text})
elif user_text or assistant_text:
tk.messagebox.showwarning("不完整对话", "发现一轮对话中只有一方有内容,已忽略该轮次。")
# 4. 组装完整的数据结构
annotation_data = {
"id": sample_id,
"conversations": conversations
}
# 5. 写入JSON文件
try:
with open(json_filepath, 'w', encoding='utf-8') as f:
json.dump(annotation_data, f, ensure_ascii=False, indent=2)
except IOError as e:
tk.messagebox.showerror("保存失败", f"无法写入JSON文件: {e}")
return
# 6. 复制图片文件到保存目录
try:
shutil.copy2(self.image_path, image_filepath)
except IOError as e:
tk.messagebox.showerror("保存失败", f"无法复制图片文件: {e}")
return
# 7. 保存成功反馈,并重置界面准备下一张
self.status_label.config(text=f"已保存: {json_filename}", fg='green')
if tk.messagebox.askyesno("保存成功", f"标注数据已保存至{save_dir}/。\n是否开始标注下一张图片?"):
self.reset_for_next_image()
else:
self.root.quit()
def reset_for_next_image(self):
"""清空当前输入,准备标注下一张图片"""
self.image_path = None
self.image_label.config(image='', text="请打开一张图片")
self.user_instruction_entry.delete("1.0", tk.END)
self.assistant_answer_entry.delete("1.0", tk.END)
# 清除所有动态添加的对话输入框
for _, _, frame in self.dialogue_entries:
frame.destroy()
self.dialogue_entries.clear()
self.status_label.config(text="就绪", fg='black')
这个save_annotation方法涵盖了数据处理的完整链条。关键点在于严格按照QWEN-VL的格式组装conversations列表,并确保图片文件名与JSON中引用的名称完全一致。reset_for_next_image方法则提供了流畅的批量化标注体验。
3. 数据聚合与后处理脚本
使用上述工具标注一批图片后,你会在saves/目录下得到许多{id}.json和对应的{id}.jpg文件。但在进行模型微调前,我们通常需要将所有样本合并到一个大的JSONL文件或一个JSON列表中,并且可能需要更新图片路径(例如,改为服务器上的绝对路径)。这就是merge_tool.py脚本的用武之地。
import os
import json
import re
from pathlib import Path
def merge_annotations(data_dir='saves', output_file='merged_qwen_data.json', image_prefix=''):
"""
合并多个标注JSON文件,并可选地更新图片路径前缀。
参数:
data_dir: 存放单个标注JSON文件的目录。
output_file: 合并后输出的文件名。
image_prefix: 要添加到图片路径前的字符串(例如绝对路径前缀)。
"""
merged_data = []
# 用于匹配QWEN-VL格式图片引用的正则表达式
# 匹配 <img>filename.jpg</img> 这种模式
img_pattern = re.compile(r'<img>(.*?\.(?:jpg|png|jpeg|bmp|gif))</img>', re.IGNORECASE)
for json_file in Path(data_dir).glob('*.json'):
try:
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# 如果指定了图片路径前缀,则更新所有对话中的图片引用
if image_prefix:
def update_path_in_text(text):
return img_pattern.sub(f'<img>{image_prefix}\\1</img>', text)
for conv in data.get('conversations', []):
conv['value'] = update_path_in_text(conv['value'])
merged_data.append(data)
print(f"已处理: {json_file.name}")
except (json.JSONDecodeError, KeyError) as e:
print(f"跳过文件 {json_file.name},解析错误: {e}")
continue
# 将合并后的数据写入新文件
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(merged_data, f, ensure_ascii=False, indent=2)
print(f"\n合并完成!共处理 {len(merged_data)} 个样本。")
print(f"输出文件: {output_file}")
if image_prefix:
print(f"图片路径前缀已更新为: '{image_prefix}'")
if __name__ == '__main__':
# 示例用法:假设图片将被移动到 /data/training_images/ 目录下
# merge_annotations(image_prefix='/data/training_images/')
# 最简单的用法:只合并,不修改路径
merge_annotations()
这个脚本提供了灵活性。如果你在本地标注,但要在服务器上训练,只需在调用时设置image_prefix为服务器上的图片目录路径即可。正则表达式img_pattern确保了只替换<img>...</img>标签内的文件名部分,而不会影响其他文本内容。
4. 高级功能扩展与实战技巧
基础工具搭建完成后,我们可以根据实际项目需求,为其添加更多提升效率或准确性的功能。
功能扩展一:快捷键支持 为常用操作绑定键盘快捷键,能极大提升标注速度。
def bind_shortcuts(self):
"""绑定键盘快捷键"""
self.root.bind('<Control-o>', lambda event: self.load_image()) # Ctrl+O 打开图片
self.root.bind('<Control-s>', lambda event: self.save_annotation()) # Ctrl+S 保存
self.root.bind('<Control-n>', lambda event: self.reset_for_next_image()) # Ctrl+N 下一张
self.root.bind('<Control-d>', lambda event: self.add_dialogue_pair()) # Ctrl+D 添加对话
# 注意:需要将load_image等方法稍作修改以兼容事件参数
功能扩展二:预设指令模板 对于特定任务(如物体检测、属性描述),可以预设一些常用的用户指令模板,减少重复输入。
def create_template_menu(self):
"""创建预设指令模板的下拉菜单"""
templates = {
"描述场景": "请详细描述这张图片中的场景。",
"识别主体": "图片中最突出的物体是什么?",
"计数": "请数一数图片中一共有多少个[物体类别]。",
"相对位置": "描述一下[A物体]和[B物体]之间的相对位置关系。"
}
self.template_var = tk.StringVar(self.control_frame)
self.template_var.set("选择预设指令...")
template_menu = tk.OptionMenu(self.control_frame, self.template_var, *templates.keys())
template_menu.pack(pady=5)
apply_btn = tk.Button(self.control_frame, text="应用模板",
command=self.apply_template)
apply_btn.pack(pady=2)
def apply_template(self):
"""将选中的模板文本插入到用户指令输入框"""
template_key = self.template_var.get()
template_text = self.templates.get(template_key, "")
if template_text:
self.user_instruction_entry.insert(tk.END, template_text)
功能扩展三:简单的质量检查 在保存前,可以加入一些基本的验证逻辑,比如检查必填字段是否为空、对话轮次是否成对等。
def validate_inputs(self):
"""简单的输入验证"""
errors = []
if not self.image_path:
errors.append("未加载图片。")
if not self.user_instruction_entry.get("1.0", tk.END).strip():
errors.append("首轮用户指令不能为空。")
if not self.assistant_answer_entry.get("1.0", tk.END).strip():
errors.append("首轮助手回答不能为空。")
# 检查动态对话是否成对
for i, (user_entry, assistant_entry, _) in enumerate(self.dialogue_entries):
user_has_text = bool(user_entry.get("1.0", tk.END).strip())
assistant_has_text = bool(assistant_entry.get("1.0", tk.END).strip())
if user_has_text != assistant_has_text: # 一个有一个没有
errors.append(f"第{i+2}轮对话不完整(用户和助手需同时有内容或无内容)。")
return errors
实战技巧:与QWEN-VL微调流程衔接 标注工具产出的merged_qwen_data.json文件,已经非常接近QWEN-VL官方微调脚本要求的格式。通常,你只需要确保数据集中每个样本的conversations字段符合要求,并且图片路径能被训练代码正确访问。在启动训练前,建议先用几行代码快速验证一下数据格式:
import json
with open('merged_qwen_data.json', 'r', encoding='utf-8') as f:
data = json.load(f)
# 检查前3个样本
for i, sample in enumerate(data[:3]):
print(f"样本 ID: {sample.get('id')}")
for conv in sample.get('conversations', []):
print(f" {conv['from']}: {conv['value'][:100]}...") # 打印前100个字符
print("-" * 40)
这个简单的检查能帮你提前发现格式错乱、路径错误或编码问题。
整个工具的开发过程,本质上是对特定数据生产流程的抽象和自动化。代码本身并不复杂,但精准地解决了从原始图片到格式化训练数据之间的“最后一公里”问题。在实际使用中,你可能还会遇到需要支持边界框标注、图像分割掩码等更复杂的标注类型,那时可以在现有框架上,引入Canvas绘图或集成更专业的图像处理库。但无论如何,从这样一个可完全掌控的轻量级工具开始迭代,远比一开始就尝试定制一个重型平台要高效和务实得多。
更多推荐


所有评论(0)