Label Studio + label-studio-ml 自动标注技术指南

Label Studio + label-studio-ml 自动标注技术指南

本文档详细阐述 Label Studio 安装配置、检测模板创建、label-studio-ml 自动标注后端搭建的完整流程。
示例使用动物目标检测类别(猫、狗、鸟),可替换为任意自定义类别。


一、环境准备

1.1 创建 Conda 环境

conda create -n labelstudio python=3.10 -y
conda activate labelstudio

1.2 安装依赖

# Label Studio 标注平台
pip install label-studio -i https://pypi.tuna.tsinghua.edu.cn/simple

# ML Backend 框架
pip install label-studio-ml -i https://pypi.tuna.tsinghua.edu.cn/simple

# YOLOv8(用于目标检测模型)
pip install ultralytics -i https://pypi.tuna.tsinghua.edu.cn/simple

# Windows 环境下的 WSGI 服务器(替代 Linux 专用的 gunicorn)
pip install waitress -i https://pypi.tuna.tsinghua.edu.cn/simple

1.3 依赖清单(requirements.txt)

label-studio>=1.8.0
label-studio-ml>=1.0.0
ultralytics>=8.0.0
waitress>=3.0.0
numpy
Pillow
requests

二、启动 Label Studio

2.1 基本启动

conda activate labelstudio
label-studio start -p 8080

浏览器访问 http://localhost:8080,首次访问需注册管理员账号。

2.2 自定义数据存储目录(推荐)

默认数据存储在 C 盘用户目录下,建议迁移到 D 盘:

# Windows
label-studio start --data-dir "D:\label-studio-data" -p 8080

# 或创建快捷启动脚本 start_label_studio.bat
@echo off
echo Starting Label Studio...
label-studio start --data-dir "D:\label-studio-data" -p 8080
pause

2.3 启用本地文件访问

在数据目录下创建 .env 文件:

LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT=D:/label-studio-data/media

三、创建检测项目与模板

3.1 新建项目

  1. 登录 Label Studio → 点击 Create Project
  2. 填写项目名称(如 "动物目标检测")
  3. 在 Labeling Setup 中选择 Custom Template

3.2 检测模板(XML)

将以下模板粘贴到 Label Studio 的模板编辑器中:

<View>
  <Image name="image" value="$image" zoom="true" crossOrigin="anonymous"/>

  <Header value="目标检测标注 - 框选目标并选择类别"/>

  <RectangleLabels name="bbox_labels" toName="image" strokeWidth="3">
    <Label value="猫" background="#FF4444" shortcut="1"/>
    <Label value="狗" background="#44AAFF" shortcut="2"/>
    <Label value="鸟" background="#44FF44" shortcut="3"/>
    <Label value="鱼" background="#FFAA00" shortcut="4"/>
    <Label value="马" background="#AA44FF" shortcut="5"/>
  </RectangleLabels>
</View>

模板要素说明:

元素 作用
<Image> 声明图像字段,zoom 允许缩放
<RectangleLabels> 矩形框标注容器,toName 绑定到图像
<Label> 单个类别,value 为显示名称,shortcut 为键盘快捷键

3.3 导入图片

  1. 点击 Import → 选择本地图片文件(支持 JPG/PNG)
  2. 或使用 CSV/JSON 批量导入:
[
  {"image": "https://example.com/cat.jpg"},
  {"image": "https://example.com/dog.jpg"}
]

3.4 标注操作

  1. 点击图片进入标注界面
  2. 在图像上拖拽绘制矩形框
  3. 弹出类别选择器,选择对应类别(或按快捷键 1-5)
  4. 点击 Submit 提交标注
  5. 使用 → 键切换到下一张图片

四、配置 ML Backend 自动标注

4.1 项目结构

ml_backend/
├── server.py              # ML Backend 核心代码
├── run_server.py          # 启动脚本
├── model_weights/         # 模型权重目录
│   └── yolo_animals.pt    # YOLOv8 模型
└── server.log             # 运行日志

4.2 server.py - ML Backend 核心代码

"""
Label Studio ML Backend - 目标检测自动标注服务
支持 YOLOv8 检测模型
"""
import os
import io
import logging
from pathlib import Path

import numpy as np
from PIL import Image
from label_studio_ml.model import LabelStudioMLBase
from label_studio_ml.utils import get_image_size

logger = logging.getLogger(__name__)

MODEL_DIR = os.getenv("MODEL_DIR", os.path.join(os.path.dirname(__file__), "model_weights"))

# 自动标注配置
AUTO_ANNOTATION_CONFIG = {
    "detection": {
        "enabled": True,  # 设为 False 使用 mock 预测
        "model_path": os.path.join(MODEL_DIR, "yolo_animals.pt"),
        "confidence_threshold": 0.35,
        "iou_threshold": 0.5,
    },
}


class AnimalDetectionModel(LabelStudioMLBase):
    """目标检测自动标注 (YOLOv8)"""

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.model_dir = MODEL_DIR
        self.model = None
        self.config = AUTO_ANNOTATION_CONFIG["detection"]
        logger.info(f"[Detection] config: {self.config}")
        self._load_model()

    def _load_model(self):
        if not self.config["enabled"]:
            logger.info("[Detection] YOLOv8 未启用,使用 mock 预测")
            return
        logger.info("[Detection] 启用YOLOv8进行预标注")
        model_path = self.config["model_path"]
        if os.path.exists(model_path):
            try:
                from ultralytics import YOLO
                self.model = YOLO(model_path)
                logger.info(f"[Detection] YOLO model loaded from {model_path}")
            except Exception as e:
                logger.warning(f"[Detection] Failed to load YOLO model: {e}")
        else:
            logger.warning(f"[Detection] Model not found: {model_path}, using mock predictions")

    def predict(self, tasks, context=None, **kwargs):
        logger.info(f"[Detection][predict] 收到 {len(tasks)} 个任务")
        predictions = []
        for task in tasks:
            result = self._predict_single(task)
            predictions.append({
                "result": result,
                "model_version": "1.0.0",
            })
        return predictions

    def _predict_single(self, task):
        image_url = task["data"]["image"]

        # 获取图像尺寸
        img_width, img_height = 640, 640
        if image_url.startswith("/data/"):
            local_path = self._resolve_labelstudio_path(image_url)
            if local_path:
                try:
                    with Image.open(local_path) as img:
                        img_width, img_height = img.size
                except Exception:
                    pass
        else:
            try:
                img_width, img_height = get_image_size(image_url)
            except Exception:
                pass

        if self.model is not None:
            return self._predict_with_model(image_url, img_width, img_height)
        else:
            return self._mock_prediction(img_width, img_height)

    def _predict_with_model(self, image_url, img_width, img_height):
        img = self._read_image(image_url)
        results = self.model(
            img,
            conf=self.config["confidence_threshold"],
            iou=self.config["iou_threshold"],
        )

        annotations = []
        for r in results:
            boxes = r.boxes
            if boxes is not None:
                for box in boxes:
                    x1, y1, x2, y2 = box.xyxy[0].tolist()
                    conf = float(box.conf[0])
                    cls = int(box.cls[0])
                    label = self._class_to_label(cls)

                    annotations.append({
                        "from_name": "bbox_labels",
                        "to_name": "image",
                        "type": "rectanglelabels",
                        "value": {
                            "x": x1 / img_width * 100,
                            "y": y1 / img_height * 100,
                            "width": (x2 - x1) / img_width * 100,
                            "height": (y2 - y1) / img_height * 100,
                            "rectanglelabels": [label],
                        },
                        "score": conf,
                    })
        return annotations

    def _read_image(self, image_url):
        if image_url.startswith("/data/"):
            local_path = self._resolve_labelstudio_path(image_url)
            if local_path:
                return Image.open(local_path)
            raise FileNotFoundError(f"无法解析 Label Studio 路径: {image_url}")
        import requests
        response = requests.get(image_url)
        return Image.open(io.BytesIO(response.content))

    def _resolve_labelstudio_path(self, url_path):
        possible_dirs = [
            "D:/label-studio-data/media",
            os.path.expanduser("~/.label-studio/media"),
            os.path.join(os.environ.get("LOCALAPPDATA", ""), "label-studio", "label-studio", "media"),
        ]
        relative = url_path.replace("/data/", "", 1)
        filename = os.path.basename(url_path)
        for ls_data_dir in possible_dirs:
            if not ls_data_dir or not os.path.exists(ls_data_dir):
                continue
            local_path = os.path.join(ls_data_dir, relative)
            if os.path.exists(local_path):
                return local_path
            for root, dirs, files in os.walk(ls_data_dir):
                if filename in files:
                    return os.path.join(root, filename)
        return None

    def _mock_prediction(self, img_width, img_height):
        """模型未加载时返回模拟预测结果"""
        cx, cy = img_width * 0.5, img_height * 0.5
        bw, bh = img_width * 0.3, img_height * 0.3
        return [{
            "from_name": "bbox_labels",
            "to_name": "image",
            "type": "rectanglelabels",
            "value": {
                "x": (cx - bw / 2) / img_width * 100,
                "y": (cy - bh / 2) / img_height * 100,
                "width": bw / img_width * 100,
                "height": bh / img_height * 100,
                "rectanglelabels": ["猫"],
            },
            "score": 0.75,
        }]

    def _class_to_label(self, cls_id):
        # 与训练时的 classes 顺序一致
        labels = ["猫", "狗", "鸟", "鱼", "马"]
        return labels[cls_id] if cls_id < len(labels) else f"class_{cls_id}"

    def fit(self, event, data, **kwargs):
        pass

4.3 run_server.py - 启动脚本

"""
ML Backend 启动脚本(跨平台)
- Windows: 使用 waitress
- Linux: 使用 gunicorn
"""
import sys
import os
import socket
import subprocess
import logging

sys.path.insert(0, os.path.dirname(__file__))


def kill_port(port):
    """检测端口是否被占用,如果是则杀掉占用进程"""
    if sys.platform == "win32":
        result = subprocess.run(["netstat", "-ano"], capture_output=True, text=True)
        for line in result.stdout.splitlines():
            if f":{port}" in line and "LISTENING" in line:
                pid = line.strip().split()[-1]
                print(f"端口 {port} 被进程 {pid} 占用,正在杀掉...")
                subprocess.run(["taskkill", "/PID", pid, "/F"], capture_output=True)
    else:
        result = subprocess.run(["lsof", "-ti", f":{port}"], capture_output=True, text=True)
        for pid in result.stdout.strip().split("\n"):
            if pid:
                print(f"端口 {port} 被进程 {pid} 占用,正在杀掉...")
                subprocess.run(["kill", "-9", pid], capture_output=True)


LOG_FILE = os.path.join(os.path.dirname(__file__), "server.log")
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.StreamHandler(sys.stdout),
        logging.FileHandler(LOG_FILE, encoding="utf-8"),
    ],
)

from server import AnimalDetectionModel


def run_waitress(host="0.0.0.0", port=9090):
    from waitress import serve
    from label_studio_ml.api import init_app
    model = AnimalDetectionModel
    app = init_app(model_class=model)
    print(f"Starting ML backend on http://{host}:{port} (waitress)")
    serve(app, host=host, port=port)


def run_gunicorn(host="0.0.0.0", port=9090):
    import subprocess
    print(f"Starting ML backend on http://{host}:{port} (gunicorn)")
    subprocess.run([
        sys.executable, "-m", "gunicorn",
        "--bind", f"{host}:{port}",
        "_wsgi:model",
    ])


if __name__ == "__main__":
    host = "0.0.0.0"
    port = 9090
    kill_port(port)
    if sys.platform == "win32":
        run_waitress(host, port)
    else:
        run_gunicorn(host, port)

4.4 启动 ML Backend

conda activate labelstudio
cd ml_backend
python run_server.py

启动成功输出:

Starting ML backend on http://0.0.0.0:9090 (waitress)
2026-09-15 10:00:00 [INFO] Serving on http://0.0.0.0:9090

五、在 Label Studio 中连接 ML Backend

5.1 配置 ML Backend 地址

  1. 进入项目 → 点击右上角 Settings
  2. 选择 Machine Learning 选项卡
  3. 点击 Add Model
  4. 填写:
    • Name: YOLOv8 动物检测
    • URL: http://localhost:9090
  5. 勾选 Use for predictions
  6. 点击 Validate and Save

5.2 触发自动标注

  1. 返回标注界面,点击任意一张图片
  2. 等待 1-2 秒,模型会自动生成预测框
  3. 预测框以半透明虚线显示,与手动标注区分
  4. 检查预测结果:
    • 正确 → 直接接受(或微调位置后提交)
    • 错误 → 删除错误框,手动重新标注
  5. 点击 Submit 提交最终标注

5.3 自动标注工作流

上传图片
   │
   ▼
ML Backend 接收预测请求
   │
   ├── 模型已加载 → YOLOv8 推理 → 返回检测框
   │
   └── 模型未加载 → 返回 mock 预测框(用于流程测试)
   │
   ▼
标注员审查/修正预测结果
   │
   ▼
提交标注 → 数据可用于下一轮模型训练

六、YOLOv8 模型训练

6.1 从 Label Studio 导出数据

  1. 进入项目 → 点击 Export
  2. 选择 YOLO with Images
  3. 下载并解压,目录结构:
result/
├── images/          # 图像文件
├── labels/          # .txt 标注文件(class cx cy w h)
├── classes.txt      # 类别列表
└── notes.json

classes.txt 内容示例:

猫
狗
鸟
鱼
马

labels 文件格式(每行一个检测框):

0 0.5 0.4 0.3 0.2    # class_id center_x center_y width height(归一化0-1)
1 0.2 0.6 0.15 0.25

6.2 创建 dataset.yaml

# data/annotations/detection/dataset.yaml
path: D:/project/data/annotations/detection
train: images
val: images

nc: 5

# 类别名称(顺序必须与 classes.txt 一致)
names:
  0: 猫
  1: 狗
  2: 鸟
  3: 鱼
  4: 马

注意:names 的顺序必须与 Label Studio 导出的 classes.txt 完全一致,否则标签会错乱。

6.3 训练脚本

# train_yolo.py
from ultralytics import YOLO
import shutil
from pathlib import Path

DATA_YAML = "D:/project/data/annotations/detection/dataset.yaml"
PRETRAINED = "yolov8n.pt"
BEST_WEIGHT = "D:/project/ml_backend/model_weights/yolo_animals.pt"

def main():
    print("开始训练 YOLOv8...")
    model = YOLO(PRETRAINED)
    results = model.train(
        data=DATA_YAML,
        epochs=50,
        imgsz=640,
        batch=8,
        lr0=0.01,
        device="cpu",
        project="D:/project/runs/detect",
        name="animals_train",
        exist_ok=True,
    )

    best_pt = Path("D:/project/runs/detect/animals_train/weights/best.pt")
    if best_pt.exists():
        dest = Path(BEST_WEIGHT)
        dest.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy(best_pt, dest)
        print(f"最佳权重已复制到: {dest}")

    print("训练完成!")

if __name__ == "__main__":
    main()

6.4 执行训练

conda activate labelstudio
python train_yolo.py

6.5 切换为训练后模型

训练完成后,模型权重自动复制到 ml_backend/model_weights/yolo_animals.pt。

在 server.py 中更新类别映射(如果类别有变化):

def _class_to_label(self, cls_id):
    labels = ["猫", "狗", "鸟", "鱼", "马"]  # 与 classes.txt 一致
    return labels[cls_id] if cls_id < len(labels) else f"class_{cls_id}"

重启 ML Backend,新模型即生效。


七、完整工作流总结

┌─────────────────────────────────────────────────────┐
│                    完整工作流                         │
├─────────────────────────────────────────────────────┤
│                                                     │
│  第一轮:手动标注                                     │
│  ┌──────────┐    ┌───────────┐    ┌──────────┐      │
│  │ 上传图片  │───▶│ 手动标注   │───▶│ 导出 YOLO │      │
│  └──────────┘    └───────────┘    └──────────┘      │
│                                        │            │
│                                        ▼            │
│                                  ┌──────────┐       │
│                                  │ 训练模型  │       │
│                                  └──────────┘       │
│                                        │            │
│                                        ▼            │
│  后续轮次:半自动标注                                  │
│  ┌──────────┐    ┌───────────┐    ┌──────────┐      │
│  │ 上传图片  │───▶│ 模型预测   │───▶│ 人工修正  │      │
│  └──────────┘    └───────────┘    └──────────┘      │
│                      ▲                   │          │
│                      │                   ▼          │
│                ┌───────────┐       ┌──────────┐     │
│                │ ML Backend │       │ 导出数据  │     │
│                └───────────┘       └──────────┘     │
│                      ▲                   │          │
│                      │                   ▼          │
│                ┌───────────┐       ┌──────────┐     │
│                │ 模型权重   │◀──────│ 迭代训练  │     │
│                └───────────┘       └──────────┘     │
│                                                     │
└─────────────────────────────────────────────────────┘

八、常见问题

Q1: ML Backend 启动报 ModuleNotFoundError

# 确保在正确的 conda 环境中
conda activate labelstudio
pip install label-studio-ml waitress

Q2: Windows 上 gunicorn 报错 No module named 'fcntl'

gunicorn 仅支持 Linux。Windows 下使用 waitress(已在 run_server.py 中自动切换)。

Q3: Label Studio 无法加载本地图片

  1. 检查 .env 文件中 LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT 路径是否正确
  2. 确保使用 --data-dir 启动时指向正确目录
  3. ML Backend 中 _resolve_labelstudio_path 的 possible_dirs 需包含实际存储路径

Q4: 预测结果类别显示为 class_0 而非具体名称

_class_to_label 的映射顺序必须与训练时 classes.txt 的顺序一致。从 Label Studio 导出的 classes.txt 查看正确顺序。

Q5: 端口 9090 被占用

run_server.py 已内置端口检测和清理功能,会自动杀掉占用进程后重启。

Q6: 模型预测框位置偏移

  • YOLO 输出坐标是像素值,需转换为 Label Studio 要求的百分比坐标
  • 转换公式:x_pct = x_pixel / img_width * 100
  • 确保 img_width 和 img_height 是原始图像尺寸,不是缩放后的

九、关键配置速查

配置项 文件 说明
enabled server.py 控制是否使用模型预测(False 时用 mock)
confidence_threshold server.py 置信度阈值,低于此值的预测会被过滤
iou_threshold server.py NMS 的 IoU 阈值,用于去除重叠框
_class_to_label server.py 模型 class ID → 显示标签的映射
dataset.yaml data/ YOLO 训练数据集配置
classes.txt 导出目录 Label Studio 自动生成的类别列表
--data-dir 启动命令 Label Studio 数据存储目录
posted @ 2026-09-15 09:23  hou永胜  阅读(52)  评论(0)    收藏  举报