# 5分钟搞定!用Python+Tkinter自制QWEN-VL图文标注工具(附完整代码)
最近在折腾多模态大模型微调,发现一个挺普遍的问题:数据标注。特别是像QWEN-VL这类支持视觉输入的大模型,它的训练数据格式比较特殊,需要把图片路径、用户指令、模型回复都打包成一个结构化的JSON。网上虽然有些现成的标注平台,但要么太重,要么不够灵活,要么就是得联网。对于中小团队或者个人开发者来说,有时候就想快速搞个本地工具,能自己定义流程,还能直接对接后续的训练脚本。
如果你也遇到过类似情况,手动编辑JSON文件效率低下,或者不想引入复杂的Web框架,那今天这个分享可能正合你意。我花了一些时间,基于Python的标准GUI库Tkinter,写了一个轻量级的图文对话标注工具。核心目标就一个:**让标注QWEN-VL格式的数据变得像填表格一样简单**。整个过程从打开图片、输入多轮对话,到生成符合规范的JSON文件,都在一个桌面窗口里完成。代码加起来不到200行,依赖库都是Python自带的或者非常常见的,真正做到了开箱即用。
这篇文章就是为你准备的,无论你是算法工程师、数据标注负责人,还是对AI应用开发感兴趣的开发者。我会手把手带你走通整个开发流程,从环境准备、界面布局设计,到核心功能实现和数据存储逻辑,最后还会提供一个完整的、可直接运行的代码包。你会发现,用最基础的Tkinter,也能做出实用又高效的工具。
## 1. 为什么选择Tkinter与本地化方案
在开始敲代码之前,我们得先想清楚为什么选这条路。市面上数据标注的方案很多,从商业化的Label Studio、CVAT,到开源的自建平台,选择不少。但对于QWEN-VL这类特定格式的微调数据准备,一个轻量、可定制、离线的工具往往更有吸引力。
**首先,Tkinter是Python的标准GUI库**,这意味着你不需要安装任何额外的包(除了PIL处理图片),避免了环境依赖的麻烦。对于团队协作,一句`pip install Pillow`就能让所有成员的标注环境就绪,大大降低了部署成本。相比之下,一些基于Web的标注工具需要部署后端服务、数据库,甚至前端框架,对于快速验证和小规模数据生产来说,显得有些“杀鸡用牛刀”。
**其次,完全掌控数据流和格式**。QWEN-VL的微调数据格式有它自己的约定,比如`<img>`标签包裹图片路径,`conversations`列表里交替存放`user`和`assistant`的对话。用通用标注工具,你可能需要花费大量时间配置模板、解析输出,甚至还要写后处理脚本。而自研工具可以直接将标注逻辑固化到界面操作中,确保产出的每一个JSON文件都严丝合缝地符合模型要求,省去了格式校验和转换的步骤。
> 提示:自研工具的核心优势在于“量身定制”。你可以根据项目需求,轻松增加字段(比如标注难度、标签类别)、修改保存逻辑(如直接上传到云端存储),甚至集成简单的质量检查规则。
最后,**本地化运行保证了数据隐私和标注速度**。所有的图片和标注数据都在本地磁盘流转,无需担心敏感数据上传到第三方服务器的风险。同时,没有了网络请求的延迟,打开大图、保存标注的响应速度极快,提升了标注人员的操作体验。
为了更直观地对比不同方案的优劣,我整理了一个简单的特性对照表:
| 特性维度 | 自研Tkinter工具 | 通用Web标注平台 (如Label Studio) | 手动编辑JSON |
| :--- | :--- | :--- | :--- |
| **部署复杂度** | 极低,仅需Python环境 | 中等,需部署服务与数据库 | 无 |
| **定制灵活性** | **极高**,代码完全可控 | 中等,依赖平台插件与配置 | 低,纯手工操作 |
| **数据格式契合度** | **完美匹配**,输出即目标格式 | 需配置模板,可能需后处理 | 依赖人工遵守格式,易出错 |
| **数据隐私** | **本地运行,绝对安全** | 依赖服务器环境与策略 | 本地操作 |
| **开发/学习成本** | 低至中等(需Python基础) | 低(使用现成平台) | 无 |
| **适合场景** | 特定格式、快速启动、小团队、数据敏感 | 多任务、多格式、大型团队协作 | 极少量数据、临时调整 |
从表格可以看出,当你的任务明确(标注QWEN-VL数据)、追求快速启动和格式精准时,自研一个轻量工具是性价比很高的选择。接下来,我们就进入实战环节。
## 2. 开发环境与核心依赖搭建
工欲善其事,必先利其器。这个项目的环境准备简单到令人发指,你只需要一个能运行Python 3的环境即可。我个人推荐使用Python 3.7及以上版本,兼容性更好。下面我们一步步来。
首先,检查你的Python环境。打开终端(Windows上是CMD或PowerShell,Mac/Linux上是Terminal),输入:
```bash
python --version
```
或者
```bash
python3 --version
```
确认版本号符合预期。
接下来,安装唯一一个非标准库的依赖——Pillow。它是Python图像处理库PIL的一个友好分支,我们将用它来加载和显示图片。安装命令同样简单:
```bash
pip install Pillow
```
如果你使用的是Anaconda环境,也可以用:
```bash
conda install pillow
```
至此,开发环境就准备好了。整个项目我们将创建两个主要的Python脚本:
1. `label.py`: 主标注工具,包含图形界面和核心交互逻辑。
2. `merge.py`: 数据聚合脚本,用于将标注生成的多个零散JSON文件合并成一个大的训练集文件。
此外,我们还需要一个`saves`文件夹,用于存放标注过程中生成的图片和JSON文件。这个文件夹会在程序运行时自动创建。你可以先手动创建一个项目目录,比如叫做`qwen_vl_label_tool`,然后把后续的代码文件都放在里面。
为了确保工具能正确运行,建议你的图片素材也放在一个容易访问的目录下。工具本身会通过文件对话框让你选择图片,所以位置不限。
## 3. 标注工具核心界面设计与实现
现在,我们来构建工具的心脏——图形用户界面。使用Tkinter,我们可以用非常直观的代码来定义窗口、按钮、输入框和布局。我们的界面需要满足几个核心功能:
* 显示被标注的图片。
* 输入与图片相关的第一轮用户指令和助手回复。
* 支持动态添加多轮纯文本对话。
* 提供保存功能,将对话和图片信息打包成JSON。
让我们从创建主窗口开始。打开你的代码编辑器,新建一个文件,命名为`label.py`。
### 3.1 创建主窗口与基本布局
Tkinter程序的起点是创建一个`Tk()`根窗口对象。我们可以设置窗口的标题和初始大小,让它看起来更友好。
```python
import tkinter as tk
from tkinter import filedialog
from PIL import Image, ImageTk
import json
import random
import string
import os
import shutil
# 创建主窗口
root = tk.Tk()
root.title("QWEN-VL 图文对话标注工具") # 给窗口起个名字
root.geometry("1100x700") # 设置一个合适的初始宽高
# 这里将存放动态创建的对话输入框
dialogue_entries = []
# 全局变量,记录当前打开的图片路径
image_path = ""
```
接下来,我们规划一下界面布局。整体采用左右结构:左侧区域用于显示图片,右侧区域用于所有输入控件。我们将使用`Frame`(框架)来划分这些区域。
```python
# 创建左侧图片显示框架
image_frame = tk.Frame(root, relief="solid", borderwidth=1)
image_frame.pack(side="left", fill="both", expand=True, padx=10, pady=10)
# 创建右侧控制面板框架
control_frame = tk.Frame(root)
control_frame.pack(side="right", fill="both", expand=True, padx=10, pady=10)
# 在图片框架内,先放置一个占位标签,等图片加载后再替换
image_label = tk.Label(image_frame, text="请点击下方按钮打开图片", bg="#f0f0f0")
image_label.pack(expand=True)
```
### 3.2 实现图片加载与显示功能
用户需要能打开本地图片文件。我们创建一个“打开图片”按钮,并将其绑定到一个函数上。这个函数会调用系统的文件选择对话框,然后用Pillow库加载图片,并调整大小以适应显示区域,最后更新到`image_label`上。
```python
def load_and_display_image():
global image_path, image_label
# 弹出文件选择对话框,限制为图片格式
file_path = filedialog.askopenfilename(
filetypes=[("Image files", "*.png *.jpg *.jpeg *.bmp *.gif")]
)
if not file_path: # 用户取消了选择
return
image_path = file_path
try:
img = Image.open(file_path)
# 计算缩放比例,让图片高度适应400像素,宽度按比例缩放
display_height = 500
ratio = display_height / img.height
new_width = int(img.width * ratio)
img_resized = img.resize((new_width, display_height), Image.Resampling.LANCZOS)
# 将PIL图像转换为Tkinter可用的PhotoImage对象
photo = ImageTk.PhotoImage(img_resized)
# 更新标签的图像
image_label.config(image=photo, text="")
image_label.image = photo # 保持引用,防止被垃圾回收
except Exception as e:
image_label.config(text=f"图片加载失败: {str(e)}")
# 将“打开图片”按钮放在控制面板的顶部
open_btn = tk.Button(control_frame, text="📂 打开图片", command=load_and_display_image, font=("Arial", 11))
open_btn.pack(pady=(0, 15))
```
### 3.3 构建对话输入区域
QWEN-VL的数据格式要求对话以列表形式存在。我们的工具需要处理两种对话:
1. **与图片强相关的首轮对话**:用户指令中包含图片引用(`<img>path/to/image.jpg</img>`),助手给出针对图片的回复。
2. **后续的纯文本多轮对话**:用户和助手可以就图片内容进行更深入的交流。
首先,我们为第一轮对话创建两个输入框。
```python
def create_labeled_input(parent, label_text, width=60):
"""创建一个带标签的输入框组"""
frame = tk.Frame(parent)
frame.pack(fill="x", pady=5)
label = tk.Label(frame, text=label_text, anchor="w", font=("Arial", 10, "bold"))
label.pack(side="top", fill="x")
entry = tk.Entry(frame, width=width, font=("Arial", 10))
entry.pack(side="top", fill="x", pady=(2,0))
return entry
# 在控制面板创建第一轮对话输入框
tk.Label(control_frame, text="【第一轮对话 (与图片相关)】", font=("Arial", 11, "bold"), anchor="w").pack(fill="x", pady=(10,5))
user_input_with_img = create_labeled_input(control_frame, "用户指令 (User):")
assistant_first_reply = create_labeled_input(control_frame, "助手回复 (Assistant):")
```
然后,我们需要一个机制来动态添加后续的对话轮次。这通过一个“添加对话轮次”按钮和一个用于存储后续输入框的列表来实现。
```python
# 用于存放后续动态添加的对话输入框
extra_dialogue_frames = []
def add_extra_dialogue():
"""动态添加一轮新的纯文本对话输入框"""
# 创建一个新的框架来容纳这一轮对话
frame_index = len(extra_dialogue_frames) + 1
frame = tk.Frame(control_frame, relief="groove", borderwidth=1, padx=5, pady=5)
frame.pack(fill="x", pady=5)
tk.Label(frame, text=f"对话轮次 #{frame_index}", font=("Arial", 9, "italic")).pack(anchor="w")
user_entry = create_labeled_input(frame, "用户:")
assistant_entry = create_labeled_input(frame, "助手:")
extra_dialogue_frames.append((frame, user_entry, assistant_entry))
# 创建“添加对话轮次”按钮
add_dialogue_btn = tk.Button(control_frame, text="➕ 添加一轮文本对话", command=add_extra_dialogue, font=("Arial", 10))
add_dialogue_btn.pack(pady=10)
```
### 3.4 实现数据保存与JSON生成
这是工具最核心的部分。当用户点击“保存”按钮时,我们需要:
1. 生成一个唯一的ID(用于JSON文件的`id`字段和图片文件名)。
2. 将图片复制到`saves`文件夹下,并以该ID命名。
3. 构建一个符合QWEN-VL格式的对话列表。
4. 将对话列表和ID写入一个JSON文件,也保存在`saves`文件夹下。
```python
def save_to_json():
global image_path
if not image_path:
tk.messagebox.showwarning("警告", "请先打开一张图片!")
return
# 1. 生成唯一ID (10位随机字符串)
random_id = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10))
save_dir = "saves"
os.makedirs(save_dir, exist_ok=True) # 确保保存目录存在
# 2. 保存图片
img_ext = os.path.splitext(image_path)[1] # 获取原图片后缀,如 .jpg
new_image_filename = f"{random_id}{img_ext}"
new_image_path = os.path.join(save_dir, new_image_filename)
shutil.copy2(image_path, new_image_path) # 复制图片到saves目录
# 3. 构建对话列表
conversations = []
# 第一轮对话:包含图片引用
# 构建用户指令字符串,将图片路径用<img>标签包裹
user_message_with_img = f"Picture 1: <img>{new_image_filename}</img>\n{user_input_with_img.get()}"
conversations.append({"from": "user", "value": user_message_with_img})
conversations.append({"from": "assistant", "value": assistant_first_reply.get()})
# 后续的纯文本对话
for frame, user_entry, assistant_entry in extra_dialogue_frames:
user_text = user_entry.get().strip()
assistant_text = assistant_entry.get().strip()
if user_text and assistant_text: # 只保存非空的对话轮次
conversations.append({"from": "user", "value": user_text})
conversations.append({"from": "assistant", "value": assistant_text})
# 4. 构建完整的数据结构并保存为JSON
data_to_save = {
"id": random_id,
"conversations": conversations
}
json_filename = f"{random_id}.json"
json_path = os.path.join(save_dir, json_filename)
with open(json_path, 'w', encoding='utf-8') as f:
json.dump(data_to_save, f, ensure_ascii=False, indent=2)
# 5. 提示用户保存成功,并询问是否继续标注下一张
response = tk.messagebox.askyesno("保存成功", f"标注数据已保存至:\n{json_path}\n\n是否清空当前内容,开始标注下一张图片?")
if response:
# 清空所有输入框和图片
user_input_with_img.delete(0, tk.END)
assistant_first_reply.delete(0, tk.END)
for frame, u_entry, a_entry in extra_dialogue_frames:
u_entry.delete(0, tk.END)
a_entry.delete(0, tk.END)
frame.destroy() # 移除动态添加的框架
extra_dialogue_frames.clear()
image_label.config(image='', text="请点击下方按钮打开图片")
image_path = ""
# 创建保存按钮
save_btn = tk.Button(control_frame, text="💾 保存标注结果", command=save_to_json, bg="#4CAF50", fg="white", font=("Arial", 12, "bold"), padx=20, pady=10)
save_btn.pack(pady=20)
# 最后,启动Tkinter的主事件循环
root.mainloop()
```
将以上所有代码块按顺序组合起来,你的`label.py`就完成了。运行它,一个功能完整的本地标注工具就出现在了眼前。
## 4. 数据聚合与格式最终处理
使用上面的工具,每标注一张图片,就会在`saves`文件夹下生成一个`{id}.json`和一个`{id}.jpg`文件。但QWEN-VL模型微调通常需要一个包含所有样本的、统一的JSON文件。这就需要我们进行数据聚合。
新建一个文件`merge.py`,它的任务很简单:遍历`saves`文件夹下的所有`.json`文件,读取它们的内容,组合成一个大的列表,并统一更新其中的图片路径(如果需要的话)。
```python
import os
import json
import re
# 配置项
input_dir = 'saves/' # 标注文件存放目录
output_file = 'merged_qwen_vl_data.json' # 合并后的输出文件名
# 如果你的图片在训练时需要绝对路径或特定前缀,在这里修改
# 例如,如果你把`saves`里的图片移动到了`/home/user/train_data/images/`,则前缀应为该路径
image_path_prefix = '' # 默认为空,即使用相对路径(文件名)
def merge_json_files():
merged_data = []
# 用于匹配JSON中<img>标签内的图片文件名
img_pattern = re.compile(r'<img>(.*?)</img>')
# 遍历输入目录下的所有JSON文件
for filename in os.listdir(input_dir):
if not filename.endswith('.json'):
continue
file_path = os.path.join(input_dir, filename)
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# 可选:更新图片路径前缀
# 如果配置了前缀,则替换所有<img>标签内的路径
if image_path_prefix:
def update_path(obj):
if isinstance(obj, dict):
for k, v in obj.items():
if isinstance(v, str):
obj[k] = img_pattern.sub(f'<img>{image_path_prefix}\\1</img>', v)
elif isinstance(v, (dict, list)):
update_path(v)
elif isinstance(obj, list):
for i, item in enumerate(obj):
if isinstance(item, str):
obj[i] = img_pattern.sub(f'<img>{image_path_prefix}\\1</img>', item)
elif isinstance(item, (dict, list)):
update_path(item)
update_path(data)
merged_data.append(data)
print(f"已合并: {filename}")
except json.JSONDecodeError as e:
print(f"错误:读取文件 {filename} 失败,格式有误。跳过。错误信息: {e}")
except Exception as e:
print(f"处理文件 {filename} 时发生未知错误: {e}")
# 将合并后的数据写入新文件
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"输出文件: {os.path.abspath(output_file)}")
if __name__ == '__main__':
merge_json_files()
```
这个脚本非常灵活。如果你在标注完成后,将`saves`文件夹内的所有图片文件移动到了另一个专门的目录(例如训练数据集目录),你只需要修改`merge.py`中的`image_path_prefix`变量,将其设置为新目录的路径(如`/datasets/qwen_vl/images/`),再运行脚本,它就会自动更新所有JSON文件中的图片引用路径。
## 5. 高级功能扩展与实战技巧
基础工具已经能跑了,但要让它在实际项目中更顺手,我们还可以做一些增强。这里分享几个我根据实际使用经验添加的功能和技巧。
**功能扩展一:批量图片预加载与导航**
标注大量图片时,一张张点“打开”太慢。可以增加一个“批量导入”功能,将某个文件夹下的所有图片路径读入列表,然后在工具内添加“上一张”、“下一张”的导航按钮。核心思路是维护一个图片列表和当前索引。
```python
# 在label.py的全局变量部分增加
image_list = []
current_image_index = -1
def batch_load_images():
global image_list, current_image_index
folder_path = filedialog.askdirectory(title="选择包含图片的文件夹")
if not folder_path:
return
# 收集所有支持的图片文件
extensions = ('.png', '.jpg', '.jpeg', '.bmp', '.gif')
image_list = [os.path.join(folder_path, f) for f in os.listdir(folder_path)
if f.lower().endswith(extensions)]
if not image_list:
tk.messagebox.showinfo("提示", "该文件夹内未找到图片文件。")
return
current_image_index = 0
load_image_by_index(current_image_index) # 加载第一张图片
def load_image_by_index(index):
global image_path, current_image_index
if 0 <= index < len(image_list):
image_path = image_list[index]
# ... 调用之前写的load_and_display_image类似的逻辑加载图片 ...
# 同时可以在界面上更新状态,如“图片 1/50”
```
**功能扩展二:标注进度保存与恢复**
有时标注到一半需要中断。可以增加一个“临时保存”功能,将当前所有输入框的内容(包括图片路径)保存到一个临时文件中。下次启动工具时,可以读取这个文件恢复状态。这可以用`json`或`pickle`来实现,关键是要把`dialogue_entries`列表里的所有文本内容都序列化保存。
**实战技巧:确保标注质量**
* **双人校验**:对于重要的标注任务,可以修改工具,使其生成两份文件。一份是原始标注文件,另一份是给校验员用的“校验文件”。校验员打开工具时,自动加载图片和原始对话,只能进行“通过”或“驳回并填写意见”的操作,简化流程。
* **快捷键支持**:Tkinter支持绑定键盘事件。可以为常用操作添加快捷键,比如`Ctrl+S`保存、`Ctrl+N`下一张、`Ctrl+Shift+A`添加对话轮次,能极大提升标注效率。
```python
root.bind('<Control-s>', lambda event: save_to_json())
root.bind('<Control-n>', lambda event: load_next_image())
```
* **集成简单的质量检查**:在`save_to_json`函数中,保存前可以加入一些检查逻辑,比如判断用户指令是否为空、助手回复是否过短、图片是否已加载等,并给出明确的提示,防止无效数据入库。
工具的价值在于解决实际问题。当你用这个基础版本跑通流程后,很可能会发现一些适合自己团队的特殊需求。这时,你就可以凭借对代码的完全掌控,轻松地添加新功能或调整交互逻辑,让它真正成为团队数据流水线上得心应手的一环。