使用 Quick, Draw! 数据集训练 YOLO11 模型

记录一次从 Quick, Draw! 全量数据下载、核验、转换 YOLO 数据集,到训练 YOLO11 检测模型的完整流程。

这篇文章记录我如何使用 Google Quick, Draw! 数据集训练一个 YOLO11 模型。目标是先用公开的手绘数据训练一个基础模型,让模型具备识别手绘线条、简笔画、图形轮廓的能力,后续再用真实纸面拍摄数据进行微调。

一、目标

我想做的是:识别人在纸上绘制的不同线条或图形。

例如:

  • 直线
  • 曲线
  • 折线
  • 波浪线
  • 箭头
  • 圆形
  • 方形
  • 三角形
  • 简笔画图案
  • 不规则手绘线条

一开始我考虑直接找现成的数据集,但发现完全匹配“纸上手绘线条识别”的数据集并不多。Quick, Draw! 虽然不是专门的线条检测数据集,但它有大量人类随手画出来的矢量笔画,非常适合用来训练一个手绘风格的基础模型。

这次的方案是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Quick, Draw! NDJSON 数据
解析矢量笔画
渲染成图片
根据笔画坐标自动生成 YOLO 检测框
训练 YOLO11 检测模型
后续用真实纸面照片微调

二、重要说明

Quick, Draw! 原始数据并不是 YOLO 检测数据集。

它的数据形式更接近这样:

1
类别名 + 笔画坐标 + 是否被识别

例如每个样本里会有一组 drawing,里面保存了手绘笔画的 x/y 坐标。

所以这次我们需要自己做一步转换:

1
2
笔画坐标 → 图片
笔画坐标范围 → 目标检测框

也就是说,这次生成的 YOLO 标签框不是人工标注的,而是通过笔画的最小外接矩形自动生成的。

这个方法适合先训练 baseline,但最终如果要用于真实纸面照片,还是需要收集真实图片并人工标注一部分数据来微调。

三、项目目录结构

我把整个项目整理成下面的结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
quickdraw-yolo11/
  scripts/
    01_download_quickdraw_all.py
    02_verify_and_fix_quickdraw.py
    03_convert_quickdraw_to_yolo.py
    04_check_yolo_dataset.py
    05_preview_yolo_labels.py
    06_train_yolo11.py
    07_predict.py

  data/
    categories.txt
    quickdraw_ndjson/
      airplane.ndjson
      alarm clock.ndjson
      ...
    quickdraw_failed.txt

  datasets/
    quickdraw_all/
      images/
        train/
        val/
      labels/
        train/
        val/
      quickdraw_all.yaml
      preview/

  test_images/
    your_test.jpg

  runs/

新建项目:

1
2
mkdir quickdraw-yolo11
cd quickdraw-yolo11

新建目录:

1
2
3
4
mkdir scripts
mkdir data
mkdir datasets
mkdir test_images

四、安装环境

建议使用 Python 3.10 或 Python 3.11。

创建虚拟环境:

1
python -m venv .venv

Windows 激活:

1
.venv\Scripts\activate

Linux / macOS 激活:

1
source .venv/bin/activate

安装依赖:

1
pip install ultralytics pillow tqdm pyyaml requests opencv-python

检查 YOLO 环境:

1
yolo checks

如果有 NVIDIA GPU,可以检查 PyTorch 是否识别到 GPU:

1
python -c "import torch; print(torch.cuda.is_available())"

如果输出:

1
True

说明可以使用 GPU 训练。

五、下载 Quick, Draw! 全部类别

第一个脚本用于下载 Quick, Draw! 的全部类别。

保存为:

1
scripts/01_download_quickdraw_all.py

代码:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import os
import time
import urllib.parse
from pathlib import Path

import requests
from tqdm import tqdm


CATEGORY_URL = "https://raw.githubusercontent.com/googlecreativelab/quickdraw-dataset/master/categories.txt"
BASE_URL = "https://storage.googleapis.com/quickdraw_dataset/full/simplified"

DATA_DIR = Path("data")
OUT_DIR = DATA_DIR / "quickdraw_ndjson"
CATEGORY_FILE = DATA_DIR / "categories.txt"

MAX_RETRIES = 3
TIMEOUT = 60


def fetch_categories():
    DATA_DIR.mkdir(parents=True, exist_ok=True)

    print("Fetching categories.txt...")
    r = requests.get(CATEGORY_URL, timeout=TIMEOUT)
    r.raise_for_status()

    CATEGORY_FILE.write_text(r.text, encoding="utf-8")

    categories = [line.strip() for line in r.text.splitlines() if line.strip()]
    return categories


def download_file(category_name: str):
    filename = f"{category_name}.ndjson"
    encoded_filename = urllib.parse.quote(filename)
    url = f"{BASE_URL}/{encoded_filename}"

    out_path = OUT_DIR / filename
    tmp_path = OUT_DIR / f"{filename}.part"

    if out_path.exists() and out_path.stat().st_size > 0:
        print(f"Skip existing: {out_path}")
        return True

    for attempt in range(1, MAX_RETRIES + 1):
        print(f"\nDownloading [{attempt}/{MAX_RETRIES}]: {category_name}")
        print(url)

        try:
            if tmp_path.exists():
                tmp_path.unlink()

            with requests.get(url, stream=True, timeout=TIMEOUT) as r:
                if r.status_code != 200:
                    print(f"HTTP {r.status_code}: {category_name}")
                    time.sleep(2)
                    continue

                total = int(r.headers.get("content-length", 0))

                with tmp_path.open("wb") as f, tqdm(
                    total=total,
                    unit="B",
                    unit_scale=True,
                    desc=filename,
                ) as pbar:
                    for chunk in r.iter_content(chunk_size=1024 * 1024):
                        if chunk:
                            f.write(chunk)
                            pbar.update(len(chunk))

            os.replace(tmp_path, out_path)
            return True

        except Exception as e:
            print(f"Error downloading {category_name}: {e}")
            if tmp_path.exists():
                try:
                    tmp_path.unlink()
                except Exception:
                    pass
            time.sleep(2)

    return False


def main():
    OUT_DIR.mkdir(parents=True, exist_ok=True)

    categories = fetch_categories()
    print(f"Total categories: {len(categories)}")

    failed = []

    for category_name in categories:
        ok = download_file(category_name)
        if not ok:
            failed.append(category_name)

    print("\nDownload summary:")
    print(f"Success: {len(categories) - len(failed)}")
    print(f"Failed:  {len(failed)}")

    if failed:
        failed_file = DATA_DIR / "quickdraw_failed.txt"
        failed_file.write_text("\n".join(failed), encoding="utf-8")

        print(f"\nFailed list saved to: {failed_file}")
        for name in failed:
            print(f"- {name}")
    else:
        print("\nAll categories downloaded.")


if __name__ == "__main__":
    main()

运行:

1
python scripts/01_download_quickdraw_all.py

下载完成后,数据会放在:

1
data/quickdraw_ndjson/

六、检查 Quick, Draw! 原始下载完整度并补下载

下载 Quick, Draw! 全部类别时,可能会因为网络波动导致某些类别没有下载成功,或者留下 .part 临时文件。

因此在转换 YOLO 数据集之前,我需要先检查原始 NDJSON 文件是否完整。

这一步检查的是:

1
data/quickdraw_ndjson/*.ndjson

它和后面的 YOLO 数据集检查不一样:

1
2
原始下载完整度检查:检查 NDJSON 是否缺失或异常
YOLO 数据集完整性检查:检查图片、label、bbox、类别 ID 是否正常

保存脚本为:

1
scripts/02_verify_and_fix_quickdraw.py

代码:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
import os
import time
import urllib.parse
from pathlib import Path

import requests
from tqdm import tqdm


CATEGORY_URL = "https://raw.githubusercontent.com/googlecreativelab/quickdraw-dataset/master/categories.txt"
BASE_URL = "https://storage.googleapis.com/quickdraw_dataset/full/simplified"

OUT_DIR = Path("data/quickdraw_ndjson")
CATEGORY_FILE = Path("data/categories.txt")

MIN_VALID_SIZE_BYTES = 1024 * 1024  # 小于 1MB 基本可以认为异常
MAX_RETRIES = 3
TIMEOUT = 60


def fetch_categories():
    """
    优先使用本地 data/categories.txt。
    如果没有,则从官方仓库下载。
    """
    CATEGORY_FILE.parent.mkdir(parents=True, exist_ok=True)

    if CATEGORY_FILE.exists() and CATEGORY_FILE.stat().st_size > 0:
        text = CATEGORY_FILE.read_text(encoding="utf-8")
    else:
        print("Fetching categories.txt from official repo...")
        r = requests.get(CATEGORY_URL, timeout=TIMEOUT)
        r.raise_for_status()
        text = r.text
        CATEGORY_FILE.write_text(text, encoding="utf-8")

    categories = [line.strip() for line in text.splitlines() if line.strip()]
    return categories


def is_valid_ndjson(path: Path):
    """
    基础核验:
    - 文件存在
    - 文件大小不是 0
    - 文件不是过小
    - 第一行看起来像 JSON 行
    - 最后一行不是明显截断的空文件
    """
    if not path.exists():
        return False, "missing"

    size = path.stat().st_size
    if size == 0:
        return False, "empty"

    if size < MIN_VALID_SIZE_BYTES:
        return False, f"too small: {size} bytes"

    try:
        with path.open("rb") as f:
            first_line = f.readline().strip()

        if not first_line.startswith(b"{") or not first_line.endswith(b"}"):
            return False, "first line does not look like ndjson"

    except Exception as e:
        return False, f"read error: {e}"

    return True, "ok"


def cleanup_part_files():
    """
    清理上次中断残留的 .part 文件。
    """
    if not OUT_DIR.exists():
        return

    part_files = list(OUT_DIR.glob("*.part"))
    if not part_files:
        return

    print(f"Found {len(part_files)} .part files, removing...")
    for path in part_files:
        try:
            path.unlink()
            print(f"Removed: {path}")
        except Exception as e:
            print(f"Failed to remove {path}: {e}")


def download_file(category_name: str):
    """
    下载单个类别。
    """
    filename = f"{category_name}.ndjson"
    encoded_filename = urllib.parse.quote(filename)
    url = f"{BASE_URL}/{encoded_filename}"

    out_path = OUT_DIR / filename
    tmp_path = OUT_DIR / f"{filename}.part"

    for attempt in range(1, MAX_RETRIES + 1):
        print(f"\nDownloading [{attempt}/{MAX_RETRIES}]: {category_name}")
        print(url)

        try:
            if tmp_path.exists():
                tmp_path.unlink()

            with requests.get(url, stream=True, timeout=TIMEOUT) as r:
                if r.status_code != 200:
                    print(f"HTTP {r.status_code}: {category_name}")
                    time.sleep(2)
                    continue

                total = int(r.headers.get("content-length", 0))

                with tmp_path.open("wb") as f, tqdm(
                    total=total,
                    unit="B",
                    unit_scale=True,
                    desc=filename,
                ) as pbar:
                    for chunk in r.iter_content(chunk_size=1024 * 1024):
                        if chunk:
                            f.write(chunk)
                            pbar.update(len(chunk))

            ok, reason = is_valid_ndjson(tmp_path)
            if not ok:
                print(f"Downloaded file invalid: {category_name}, reason: {reason}")
                if tmp_path.exists():
                    tmp_path.unlink()
                time.sleep(2)
                continue

            os.replace(tmp_path, out_path)
            print(f"Fixed: {out_path}")
            return True

        except Exception as e:
            print(f"Error downloading {category_name}: {e}")
            if tmp_path.exists():
                try:
                    tmp_path.unlink()
                except Exception:
                    pass
            time.sleep(2)

    return False


def main():
    OUT_DIR.mkdir(parents=True, exist_ok=True)

    categories = fetch_categories()
    print(f"Expected categories: {len(categories)}")

    cleanup_part_files()

    invalid = []
    valid_count = 0

    print("\nChecking local files...")

    for category_name in categories:
        path = OUT_DIR / f"{category_name}.ndjson"
        ok, reason = is_valid_ndjson(path)

        if ok:
            valid_count += 1
        else:
            invalid.append((category_name, reason))

    print("\nCheck summary:")
    print(f"Valid files:   {valid_count}")
    print(f"Invalid/miss:  {len(invalid)}")

    if invalid:
        print("\nNeed to fix:")
        for name, reason in invalid:
            print(f"- {name}: {reason}")

    if not invalid:
        print("\nAll files look good.")
        return

    failed = []

    print("\nStart fixing missing/invalid files...")

    for category_name, reason in invalid:
        path = OUT_DIR / f"{category_name}.ndjson"

        # 避免保留坏文件影响重下
        if path.exists():
            try:
                path.unlink()
            except Exception as e:
                print(f"Could not remove invalid file {path}: {e}")
                failed.append(category_name)
                continue

        ok = download_file(category_name)
        if not ok:
            failed.append(category_name)

    print("\nFinal summary:")
    print(f"Fixed:  {len(invalid) - len(failed)}")
    print(f"Failed: {len(failed)}")

    if failed:
        failed_file = Path("data/quickdraw_failed.txt")
        failed_file.write_text("\n".join(failed), encoding="utf-8")

        print("\nStill failed:")
        for name in failed:
            print(f"- {name}")

        print(f"\nFailed list saved to: {failed_file}")
    else:
        print("\nAll missing/invalid files have been fixed.")


if __name__ == "__main__":
    main()

运行:

1
python scripts/02_verify_and_fix_quickdraw.py

如果全部正常,会看到:

1
All files look good.

如果有缺失或异常文件,脚本会列出类似:

1
2
3
Need to fix:
- cat: missing
- alarm clock: too small: 12345 bytes

然后它会自动重新下载这些类别。

如果仍然失败,会写入:

1
data/quickdraw_failed.txt

之后可以再次运行同一个脚本继续补下载。

这个脚本主要检查以下问题:

检查项说明
missing类别文件不存在
empty文件大小为 0
too small文件小于设定阈值,默认小于 1MB
first line does not look like ndjson文件首行不像 JSON 行
read error文件无法读取
.part 文件上次下载中断留下的临时文件

需要注意的是,这个检查是基础完整度检查。它不会逐行解析整个 NDJSON 文件,否则全部类别检查会比较慢。一般情况下,配合文件大小、首行格式和 .part 清理,已经足够发现大多数下载失败问题。

七、转换成 YOLO 数据集

第三个脚本负责把 Quick, Draw! 的 NDJSON 数据转换成 YOLO 检测数据集。

保存为:

1
scripts/03_convert_quickdraw_to_yolo.py

主要逻辑:

1
2
3
4
5
6
7
读取 categories.txt
读取每个类别的 ndjson
解析 drawing 笔画坐标
渲染为 640x640 图片
计算笔画最小外接框
保存 YOLO 标签
生成 quickdraw_all.yaml

代码:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
import json
import random
from pathlib import Path

import yaml
from PIL import Image, ImageDraw, ImageFilter, ImageEnhance
from tqdm import tqdm


DATA_DIR = Path("data")
CATEGORY_FILE = DATA_DIR / "categories.txt"
SRC_DIR = DATA_DIR / "quickdraw_ndjson"

OUT_DIR = Path("datasets") / "quickdraw_all"

IMG_SIZE = 640

# 全部类别时建议先从 200 或 500 开始测试流程。
# 200 张/类:约 6.9 万张图
# 500 张/类:约 17 万张图
# 1000 张/类:约 34.5 万张图
# 3000 张/类:约 103.5 万张图
SAMPLES_PER_CLASS = 1000

VAL_RATIO = 0.15
SEED = 42

ONLY_RECOGNIZED = True
ENABLE_AUGMENT = True

random.seed(SEED)


def load_classes():
    if not CATEGORY_FILE.exists():
        raise FileNotFoundError(
            f"Missing {CATEGORY_FILE}. Please run download script first."
        )

    text = CATEGORY_FILE.read_text(encoding="utf-8")
    classes = [line.strip() for line in text.splitlines() if line.strip()]

    if not classes:
        raise RuntimeError("categories.txt is empty.")

    return classes


def ensure_dirs():
    for split in ["train", "val"]:
        (OUT_DIR / "images" / split).mkdir(parents=True, exist_ok=True)
        (OUT_DIR / "labels" / split).mkdir(parents=True, exist_ok=True)


def get_bbox_from_drawing(drawing):
    xs = []
    ys = []

    for stroke in drawing:
        if len(stroke) < 2:
            continue

        stroke_xs = stroke[0]
        stroke_ys = stroke[1]

        xs.extend(stroke_xs)
        ys.extend(stroke_ys)

    if not xs or not ys:
        return None

    x1 = max(0, min(xs))
    y1 = max(0, min(ys))
    x2 = min(255, max(xs))
    y2 = min(255, max(ys))

    if x2 <= x1 or y2 <= y1:
        return None

    pad = 8
    x1 = max(0, x1 - pad)
    y1 = max(0, y1 - pad)
    x2 = min(255, x2 + pad)
    y2 = min(255, y2 + pad)

    if x2 <= x1 or y2 <= y1:
        return None

    return x1, y1, x2, y2


def render_drawing(drawing, img_size=640):
    if ENABLE_AUGMENT:
        bg = random.choice([
            (255, 255, 255),
            (250, 250, 250),
            (245, 245, 240),
            (248, 246, 238),
        ])
    else:
        bg = (255, 255, 255)

    img = Image.new("RGB", (img_size, img_size), bg)
    draw = ImageDraw.Draw(img)

    scale = img_size / 256.0

    if ENABLE_AUGMENT:
        width = random.choice([3, 4, 5, 6, 7, 8])
    else:
        width = 5

    ink = random.choice([
        (0, 0, 0),
        (20, 20, 20),
        (40, 40, 40),
    ]) if ENABLE_AUGMENT else (0, 0, 0)

    for stroke in drawing:
        if len(stroke) < 2:
            continue

        xs = stroke[0]
        ys = stroke[1]

        points = [
            (int(x * scale), int(y * scale))
            for x, y in zip(xs, ys)
        ]

        if len(points) >= 2:
            draw.line(points, fill=ink, width=width, joint="curve")
        elif len(points) == 1:
            x, y = points[0]
            r = width
            draw.ellipse((x - r, y - r, x + r, y + r), fill=ink)

    if ENABLE_AUGMENT:
        # 这里使用不会改变几何位置的增强,因此 bbox 不需要重新计算。
        if random.random() < 0.25:
            img = img.filter(ImageFilter.GaussianBlur(radius=random.uniform(0.2, 0.8)))

        if random.random() < 0.35:
            enhancer = ImageEnhance.Brightness(img)
            img = enhancer.enhance(random.uniform(0.85, 1.15))

        if random.random() < 0.35:
            enhancer = ImageEnhance.Contrast(img)
            img = enhancer.enhance(random.uniform(0.85, 1.25))

    return img


def bbox_to_yolo(bbox, img_size=640):
    x1, y1, x2, y2 = bbox

    scale = img_size / 256.0

    x1 *= scale
    y1 *= scale
    x2 *= scale
    y2 *= scale

    xc = ((x1 + x2) / 2) / img_size
    yc = ((y1 + y2) / 2) / img_size
    w = (x2 - x1) / img_size
    h = (y2 - y1) / img_size

    xc = min(max(xc, 0.0), 1.0)
    yc = min(max(yc, 0.0), 1.0)
    w = min(max(w, 0.0), 1.0)
    h = min(max(h, 0.0), 1.0)

    if w <= 0 or h <= 0:
        return None

    return xc, yc, w, h


def read_samples(src_path: Path, max_samples: int):
    samples = []

    with src_path.open("r", encoding="utf-8") as f:
        for line in f:
            if len(samples) >= max_samples:
                break

            try:
                item = json.loads(line)
            except Exception:
                continue

            if ONLY_RECOGNIZED and not item.get("recognized", False):
                continue

            drawing = item.get("drawing")
            if not drawing:
                continue

            samples.append(item)

    return samples


def convert_one_class(class_id: int, class_name: str):
    src_path = SRC_DIR / f"{class_name}.ndjson"

    if not src_path.exists():
        print(f"Missing file, skip: {src_path}")
        return 0

    samples = read_samples(src_path, SAMPLES_PER_CLASS)

    if not samples:
        print(f"No usable samples, skip: {class_name}")
        return 0

    converted = 0

    for idx, item in enumerate(tqdm(samples, desc=class_name)):
        drawing = item["drawing"]

        bbox = get_bbox_from_drawing(drawing)
        if bbox is None:
            continue

        yolo_box = bbox_to_yolo(bbox, IMG_SIZE)
        if yolo_box is None:
            continue

        split = "val" if random.random() < VAL_RATIO else "train"

        safe_name = class_name.replace(" ", "_").replace("/", "_")
        image_name = f"{safe_name}_{idx:07d}.jpg"
        label_name = f"{safe_name}_{idx:07d}.txt"

        image_path = OUT_DIR / "images" / split / image_name
        label_path = OUT_DIR / "labels" / split / label_name

        img = render_drawing(drawing, IMG_SIZE)
        img.save(image_path, quality=95)

        xc, yc, w, h = yolo_box

        with label_path.open("w", encoding="utf-8") as f:
            f.write(f"{class_id} {xc:.6f} {yc:.6f} {w:.6f} {h:.6f}\n")

        converted += 1

    return converted


def write_yaml(classes):
    yaml_path = OUT_DIR / "quickdraw_all.yaml"

    data = {
        "path": str(OUT_DIR.resolve()),
        "train": "images/train",
        "val": "images/val",
        "names": {i: name for i, name in enumerate(classes)},
    }

    with yaml_path.open("w", encoding="utf-8") as f:
        yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)

    print(f"YAML saved to: {yaml_path}")


def main():
    classes = load_classes()
    print(f"Classes: {len(classes)}")
    print(f"Samples per class: {SAMPLES_PER_CLASS}")
    print(f"Output: {OUT_DIR}")

    ensure_dirs()

    total = 0

    for class_id, class_name in enumerate(classes):
        count = convert_one_class(class_id, class_name)
        total += count

    write_yaml(classes)

    print("\nConvert summary:")
    print(f"Total images: {total}")
    print(f"Dataset dir:  {OUT_DIR}")
    print(f"YAML:         {OUT_DIR / 'quickdraw_all.yaml'}")


if __name__ == "__main__":
    main()

运行:

1
python scripts/03_convert_quickdraw_to_yolo.py

生成的数据集结构:

1
2
3
4
5
6
7
8
datasets/quickdraw_all/
  images/
    train/
    val/
  labels/
    train/
    val/
  quickdraw_all.yaml

八、检查转换后的 YOLO 数据集完整性

在正式训练 YOLO11 之前,除了要检查 Quick, Draw! 原始 NDJSON 是否下载完整,还需要检查转换后的 YOLO 数据集是否正常。

这一步非常重要,因为 YOLO 训练时如果 label 有问题,可能会出现:

1
2
3
4
5
6
7
8
9
图片和标签数量不一致
label 文件找不到对应图片
图片文件打不开
类别 ID 超出范围
坐标不是 0~1
bbox 宽高为 0
bbox 极小
bbox 超出图像边界
类别分布严重不均衡

所以我额外准备了一个完整性检查脚本,用来扫描转换后的数据集。

保存为:

1
scripts/04_check_yolo_dataset.py

代码:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
from pathlib import Path
from collections import Counter, defaultdict
import math
import yaml
from PIL import Image


DATASET_DIR = Path("datasets") / "quickdraw_all"
YAML_PATH = DATASET_DIR / "quickdraw_all.yaml"

IMAGE_EXTS = [".jpg", ".jpeg", ".png", ".bmp", ".webp"]

# 极端框阈值,可按需要调整
MIN_WH = 0.002       # 小于 0.002 约等于 640 图上的 1.28 像素
WARN_WH = 0.01      # 小于 0.01 约等于 640 图上的 6.4 像素
WARN_RATIO = 20.0   # 宽高比超过 20 认为是极细长框
WARN_AREA_SMALL = 0.0001
WARN_AREA_LARGE = 0.95


def load_yaml():
    if not YAML_PATH.exists():
        print(f"[ERROR] 找不到 YAML: {YAML_PATH}")
        return None

    with YAML_PATH.open("r", encoding="utf-8") as f:
        data = yaml.safe_load(f)

    names = data.get("names", {})
    if isinstance(names, list):
        names = {i: name for i, name in enumerate(names)}

    nc = data.get("nc", len(names))

    print(f"[INFO] YAML: {YAML_PATH}")
    print(f"[INFO] nc: {nc}")
    print(f"[INFO] names: {len(names)} classes")

    return {
        "data": data,
        "names": names,
        "nc": nc,
    }


def find_image_for_label(label_path: Path, split: str):
    image_stem = label_path.stem
    image_dir = DATASET_DIR / "images" / split

    for ext in IMAGE_EXTS:
        p = image_dir / f"{image_stem}{ext}"
        if p.exists():
            return p

    return None


def check_one_label_file(label_path: Path, split: str, nc: int):
    errors = []
    warnings = []
    stats = []

    image_path = find_image_for_label(label_path, split)
    if image_path is None:
        errors.append(("missing_image", "找不到对应图片"))
        img_w = img_h = None
    else:
        try:
            with Image.open(image_path) as img:
                img_w, img_h = img.size
        except Exception as e:
            errors.append(("bad_image", f"图片无法打开: {e}"))
            img_w = img_h = None

    try:
        text = label_path.read_text(encoding="utf-8").strip()
    except Exception as e:
        errors.append(("read_label_failed", f"label 无法读取: {e}"))
        return errors, warnings, stats

    if not text:
        warnings.append(("empty_label", "label 文件为空"))
        return errors, warnings, stats

    lines = text.splitlines()

    for line_no, line in enumerate(lines, 1):
        raw_line = line
        parts = line.strip().split()

        if len(parts) != 5:
            errors.append(("bad_columns", f"第 {line_no} 行列数不是 5: {raw_line}"))
            continue

        try:
            cls_raw, x_raw, y_raw, w_raw, h_raw = parts
            cls_float = float(cls_raw)
            x = float(x_raw)
            y = float(y_raw)
            w = float(w_raw)
            h = float(h_raw)
        except Exception:
            errors.append(("not_number", f"第 {line_no} 行存在非数字: {raw_line}"))
            continue

        vals = [x, y, w, h]

        if not math.isfinite(cls_float):
            errors.append(("class_nan_inf", f"第 {line_no} 行 class 是 NaN/Inf: {raw_line}"))
            continue

        if int(cls_float) != cls_float:
            errors.append(("class_not_int", f"第 {line_no} 行 class 不是整数: {raw_line}"))
            continue

        cls = int(cls_float)

        if cls < 0 or cls >= nc:
            errors.append(("class_out_of_range", f"第 {line_no} 行 class 超出范围 0~{nc - 1}: {raw_line}"))
            continue

        if not all(math.isfinite(v) for v in vals):
            errors.append(("coord_nan_inf", f"第 {line_no} 行坐标存在 NaN/Inf: {raw_line}"))
            continue

        if not all(0.0 <= v <= 1.0 for v in vals):
            errors.append(("coord_out_of_0_1", f"第 {line_no} 行坐标不在 0~1: {raw_line}"))
            continue

        if w <= 0 or h <= 0:
            errors.append(("zero_or_negative_wh", f"第 {line_no} 行 w/h <= 0: {raw_line}"))
            continue

        x1 = x - w / 2
        y1 = y - h / 2
        x2 = x + w / 2
        y2 = y + h / 2

        if x1 < 0 or y1 < 0 or x2 > 1 or y2 > 1:
            warnings.append(("box_cross_border", f"第 {line_no} 行 bbox 超出图像边界: {raw_line}"))

        area = w * h
        ratio = max(w / h, h / w)

        if w < MIN_WH or h < MIN_WH:
            errors.append(("too_tiny_wh", f"第 {line_no} 行 w/h 极小: w={w:.8f}, h={h:.8f}, {raw_line}"))

        if w < WARN_WH or h < WARN_WH:
            warnings.append(("small_wh", f"第 {line_no} 行 w/h 较小: w={w:.8f}, h={h:.8f}, {raw_line}"))

        if ratio > WARN_RATIO:
            warnings.append(("extreme_ratio", f"第 {line_no} 行宽高比极端: ratio={ratio:.2f}, {raw_line}"))

        if area < WARN_AREA_SMALL:
            warnings.append(("small_area", f"第 {line_no} 行面积过小: area={area:.8f}, {raw_line}"))

        if area > WARN_AREA_LARGE:
            warnings.append(("large_area", f"第 {line_no} 行面积过大: area={area:.8f}, {raw_line}"))

        if img_w is not None and img_h is not None:
            pixel_w = w * img_w
            pixel_h = h * img_h
        else:
            pixel_w = None
            pixel_h = None

        stats.append({
            "cls": cls,
            "x": x,
            "y": y,
            "w": w,
            "h": h,
            "area": area,
            "ratio": ratio,
            "pixel_w": pixel_w,
            "pixel_h": pixel_h,
            "label_path": label_path,
            "image_path": image_path,
            "line_no": line_no,
            "line": raw_line,
        })

    return errors, warnings, stats


def scan_split(split: str, nc: int):
    label_dir = DATASET_DIR / "labels" / split
    image_dir = DATASET_DIR / "images" / split

    print(f"\n========== Checking split: {split} ==========")
    print(f"[INFO] labels: {label_dir}")
    print(f"[INFO] images: {image_dir}")

    if not label_dir.exists():
        print(f"[ERROR] label 目录不存在: {label_dir}")
        return None

    if not image_dir.exists():
        print(f"[ERROR] image 目录不存在: {image_dir}")
        return None

    label_files = sorted(label_dir.rglob("*.txt"))
    image_files = []
    for ext in IMAGE_EXTS:
        image_files.extend(image_dir.rglob(f"*{ext}"))

    print(f"[INFO] label files: {len(label_files)}")
    print(f"[INFO] image files: {len(image_files)}")

    error_counter = Counter()
    warning_counter = Counter()
    class_counter = Counter()

    bad_examples = defaultdict(list)
    warn_examples = defaultdict(list)

    all_stats = []

    for label_path in label_files:
        errors, warnings, stats = check_one_label_file(label_path, split, nc)

        for code, msg in errors:
            error_counter[code] += 1
            if len(bad_examples[code]) < 20:
                bad_examples[code].append((label_path, msg))

        for code, msg in warnings:
            warning_counter[code] += 1
            if len(warn_examples[code]) < 20:
                warn_examples[code].append((label_path, msg))

        for item in stats:
            class_counter[item["cls"]] += 1
            all_stats.append(item)

    # 检查是否存在图片没有 label
    label_stems = {p.stem for p in label_files}
    image_stems = {p.stem for p in image_files}

    images_without_labels = sorted(image_stems - label_stems)
    labels_without_images = sorted(label_stems - image_stems)

    if images_without_labels:
        warning_counter["image_without_label"] += len(images_without_labels)
        warn_examples["image_without_label"] = images_without_labels[:20]

    if labels_without_images:
        error_counter["label_without_image"] += len(labels_without_images)
        bad_examples["label_without_image"] = labels_without_images[:20]

    print("\n[SUMMARY]")
    print(f"valid boxes parsed: {len(all_stats)}")
    print(f"errors: {sum(error_counter.values())}")
    print(f"warnings: {sum(warning_counter.values())}")

    print("\n[ERROR COUNTS]")
    if error_counter:
        for k, v in error_counter.most_common():
            print(f"{k}: {v}")
    else:
        print("No hard errors found.")

    print("\n[WARNING COUNTS]")
    if warning_counter:
        for k, v in warning_counter.most_common():
            print(f"{k}: {v}")
    else:
        print("No warnings found.")

    if all_stats:
        widths = [x["w"] for x in all_stats]
        heights = [x["h"] for x in all_stats]
        areas = [x["area"] for x in all_stats]
        ratios = [x["ratio"] for x in all_stats]

        print("\n[BOX DISTRIBUTION]")
        print(f"w min/avg/max: {min(widths):.8f} / {sum(widths) / len(widths):.8f} / {max(widths):.8f}")
        print(f"h min/avg/max: {min(heights):.8f} / {sum(heights) / len(heights):.8f} / {max(heights):.8f}")
        print(f"area min/avg/max: {min(areas):.8f} / {sum(areas) / len(areas):.8f} / {max(areas):.8f}")
        print(f"ratio min/avg/max: {min(ratios):.2f} / {sum(ratios) / len(ratios):.2f} / {max(ratios):.2f}")

        print("\n[SMALLEST BOXES]")
        for item in sorted(all_stats, key=lambda x: x["area"])[:20]:
            print(
                f"area={item['area']:.8f}, "
                f"w={item['w']:.8f}, h={item['h']:.8f}, "
                f"ratio={item['ratio']:.2f}, "
                f"cls={item['cls']}, "
                f"file={item['label_path']}"
            )

        print("\n[MOST EXTREME RATIOS]")
        for item in sorted(all_stats, key=lambda x: x["ratio"], reverse=True)[:20]:
            print(
                f"ratio={item['ratio']:.2f}, "
                f"w={item['w']:.8f}, h={item['h']:.8f}, "
                f"area={item['area']:.8f}, "
                f"cls={item['cls']}, "
                f"file={item['label_path']}"
            )

    print("\n[CLASS DISTRIBUTION]")
    print(f"classes with labels: {len(class_counter)}")
    if class_counter:
        counts = list(class_counter.values())
        print(f"per-class min/avg/max: {min(counts)} / {sum(counts) / len(counts):.2f} / {max(counts)}")

        print("\nLeast common classes:")
        for cls, n in class_counter.most_common()[-20:]:
            print(f"class {cls}: {n}")

        print("\nMost common classes:")
        for cls, n in class_counter.most_common(20):
            print(f"class {cls}: {n}")

    print("\n[ERROR EXAMPLES]")
    if bad_examples:
        for code, examples in bad_examples.items():
            print(f"\n--- {code} ---")
            for ex in examples[:20]:
                print(ex)
    else:
        print("No error examples.")

    print("\n[WARNING EXAMPLES]")
    if warn_examples:
        for code, examples in warn_examples.items():
            print(f"\n--- {code} ---")
            for ex in examples[:20]:
                print(ex)
    else:
        print("No warning examples.")

    return {
        "split": split,
        "errors": error_counter,
        "warnings": warning_counter,
        "class_counter": class_counter,
        "stats": all_stats,
    }


def main():
    if not DATASET_DIR.exists():
        print(f"[ERROR] 数据集目录不存在: {DATASET_DIR}")
        return

    yaml_info = load_yaml()
    if yaml_info is None:
        return

    nc = int(yaml_info["nc"])

    results = []
    for split in ["train", "val"]:
        result = scan_split(split, nc)
        if result is not None:
            results.append(result)

    print("\n========== FINAL SUMMARY ==========")

    total_errors = Counter()
    total_warnings = Counter()
    total_classes = Counter()
    total_boxes = 0

    for r in results:
        total_errors.update(r["errors"])
        total_warnings.update(r["warnings"])
        total_classes.update(r["class_counter"])
        total_boxes += len(r["stats"])

    print(f"total boxes: {total_boxes}")
    print(f"total classes with labels: {len(total_classes)}")
    print(f"total errors: {sum(total_errors.values())}")
    print(f"total warnings: {sum(total_warnings.values())}")

    if total_errors:
        print("\nFinal error counts:")
        for k, v in total_errors.most_common():
            print(f"{k}: {v}")
    else:
        print("\nNo hard label errors found.")

    if total_warnings:
        print("\nFinal warning counts:")
        for k, v in total_warnings.most_common():
            print(f"{k}: {v}")
    else:
        print("\nNo label warnings found.")

    print("\n[DONE]")


if __name__ == "__main__":
    main()

运行:

1
python scripts/04_check_yolo_dataset.py

如果一切正常,最后应该能看到类似:

1
2
No hard label errors found.
[DONE]

如果出现 warning,不一定代表数据不能用。例如 Quick, Draw! 有些图形本身就是细长的线条,所以可能出现:

1
2
3
extreme_ratio
small_wh
small_area

这些需要结合样本查看,不一定要全部删除。

但如果出现下面这些 hard error,就需要优先处理:

1
2
3
4
5
6
7
8
missing_image
bad_image
bad_columns
not_number
class_out_of_range
coord_out_of_0_1
zero_or_negative_wh
label_without_image

我的训练流程里,这一步放在可视化预览之前:

1
2
3
python scripts/03_convert_quickdraw_to_yolo.py
python scripts/04_check_yolo_dataset.py
python scripts/05_preview_yolo_labels.py

九、抽样可视化检查 YOLO 标签框

脚本检查完之后,还要人工看一批图片,确认框的位置是否符合预期。

保存为:

1
scripts/05_preview_yolo_labels.py

这个脚本会从训练集或验证集随机抽样,把 YOLO 框画出来,保存到:

1
datasets/quickdraw_all/preview/

代码:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
import random
from pathlib import Path

import cv2
import yaml


DATASET_DIR = Path("datasets") / "quickdraw_all"
YAML_PATH = DATASET_DIR / "quickdraw_all.yaml"

SPLIT = "val"
NUM_PREVIEW = 50

OUT_DIR = DATASET_DIR / "preview"


def load_names():
    with YAML_PATH.open("r", encoding="utf-8") as f:
        data = yaml.safe_load(f)

    names = data["names"]

    if isinstance(names, dict):
        names = {int(k): v for k, v in names.items()}
    else:
        names = {i: name for i, name in enumerate(names)}

    return names


def draw_one(image_path: Path, label_path: Path, names):
    img = cv2.imread(str(image_path))
    if img is None:
        return None

    h, w = img.shape[:2]

    if label_path.exists():
        lines = label_path.read_text(encoding="utf-8").splitlines()

        for line in lines:
            parts = line.strip().split()
            if len(parts) != 5:
                continue

            cls_id = int(float(parts[0]))
            xc, yc, bw, bh = map(float, parts[1:])

            x1 = int((xc - bw / 2) * w)
            y1 = int((yc - bh / 2) * h)
            x2 = int((xc + bw / 2) * w)
            y2 = int((yc + bh / 2) * h)

            x1 = max(0, min(w - 1, x1))
            y1 = max(0, min(h - 1, y1))
            x2 = max(0, min(w - 1, x2))
            y2 = max(0, min(h - 1, y2))

            label = names.get(cls_id, str(cls_id))

            cv2.rectangle(img, (x1, y1), (x2, y2), (0, 0, 255), 2)
            cv2.putText(
                img,
                label,
                (x1, max(20, y1 - 8)),
                cv2.FONT_HERSHEY_SIMPLEX,
                0.7,
                (0, 0, 255),
                2,
                cv2.LINE_AA,
            )

    return img


def main():
    names = load_names()

    image_dir = DATASET_DIR / "images" / SPLIT
    label_dir = DATASET_DIR / "labels" / SPLIT

    OUT_DIR.mkdir(parents=True, exist_ok=True)

    images = list(image_dir.glob("*.jpg")) + list(image_dir.glob("*.png"))
    if not images:
        raise RuntimeError(f"No images found in {image_dir}")

    sample_images = random.sample(images, min(NUM_PREVIEW, len(images)))

    for i, image_path in enumerate(sample_images):
        label_path = label_dir / f"{image_path.stem}.txt"

        img = draw_one(image_path, label_path, names)
        if img is None:
            continue

        out_path = OUT_DIR / f"preview_{i:03d}_{image_path.name}"
        cv2.imwrite(str(out_path), img)

    print(f"Preview images saved to: {OUT_DIR}")


if __name__ == "__main__":
    main()

运行:

1
python scripts/05_preview_yolo_labels.py

检查输出目录:

1
datasets/quickdraw_all/preview/

确认框没有明显偏移、类别没有错乱,再进入训练。

十、训练 YOLO11

保存为:

1
scripts/06_train_yolo11.py

代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from ultralytics import YOLO


MODEL = "yolo11n.pt"
DATA = "datasets/quickdraw_all/quickdraw_all.yaml"

EPOCHS = 50
IMGSZ = 640
BATCH = 16
DEVICE = 0  # 没有 GPU 改成 "cpu"

PROJECT = "runs/detect"
NAME = "quickdraw_yolo11n"


def main():
    model = YOLO(MODEL)

    model.train(
        data=DATA,
        epochs=EPOCHS,
        imgsz=IMGSZ,
        batch=BATCH,
        device=DEVICE,
        project=PROJECT,
        name=NAME,
        workers=4,
        patience=10,
        cache=False,
    )


if __name__ == "__main__":
    main()

运行:

1
python scripts/06_train_yolo11.py

也可以直接用命令行训练:

1
2
3
4
5
6
7
8
9
yolo detect train \
  model=yolo11n.pt \
  data=datasets/quickdraw_all/quickdraw_all.yaml \
  epochs=50 \
  imgsz=640 \
  batch=16 \
  device=0 \
  project=runs/detect \
  name=quickdraw_yolo11n

训练完成后,最佳模型会保存在:

1
runs/detect/quickdraw_yolo11n/weights/best.pt

最后一轮模型一般在:

1
runs/detect/quickdraw_yolo11n/weights/last.pt

十一、预测测试

保存为:

1
scripts/07_predict.py

代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from ultralytics import YOLO


MODEL_PATH = "runs/detect/quickdraw_yolo11n/weights/best.pt"
SOURCE = "test_images"

IMGSZ = 640
CONF = 0.25


def main():
    model = YOLO(MODEL_PATH)

    model.predict(
        source=SOURCE,
        imgsz=IMGSZ,
        conf=CONF,
        save=True,
        project="runs/detect",
        name="quickdraw_predict",
    )


if __name__ == "__main__":
    main()

把测试图片放到:

1
test_images/

运行:

1
python scripts/07_predict.py

预测结果会输出到:

1
runs/detect/quickdraw_predict/

十二、完整执行顺序

整理一下,完整流程是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 1. 下载全部类别
python scripts/01_download_quickdraw_all.py

# 2. 核验并补下载缺失或损坏的 NDJSON 原始文件
python scripts/02_verify_and_fix_quickdraw.py

# 3. 转换成 YOLO 数据集
python scripts/03_convert_quickdraw_to_yolo.py

# 4. 检查转换后的 YOLO 数据集完整性
python scripts/04_check_yolo_dataset.py

# 5. 抽样可视化检查标签框
python scripts/05_preview_yolo_labels.py

# 6. 训练 YOLO11
python scripts/06_train_yolo11.py

# 7. 预测测试
python scripts/07_predict.py

对应脚本功能:

脚本作用
01_download_quickdraw_all.py下载全部 Quick, Draw! 类别
02_verify_and_fix_quickdraw.py检查 NDJSON 下载完整度并补下载
03_convert_quickdraw_to_yolo.py转换为 YOLO 数据集
04_check_yolo_dataset.py检查 YOLO 数据集完整性
05_preview_yolo_labels.py抽样可视化标签框
06_train_yolo11.py训练 YOLO11
07_predict.py预测测试

十三、第一次训练建议参数

第一次不要直接上超大数据量,先跑通流程。

我建议先这样设置:

1
2
3
4
SAMPLES_PER_CLASS = 200
MODEL = "yolo11n.pt"
EPOCHS = 30
BATCH = 8

这样可以快速验证:

1
2
3
4
5
6
下载是否完整
转换脚本是否正常
YOLO 数据集检查是否通过
标签框是否正确
YOLO11 是否能正常训练
预测输出是否正常

确认流程没问题后,再扩大到:

1
2
3
4
SAMPLES_PER_CLASS = 1000
MODEL = "yolo11n.pt"
EPOCHS = 50
BATCH = 16

如果显卡性能足够,再考虑:

1
2
3
4
SAMPLES_PER_CLASS = 3000
MODEL = "yolo11s.pt"
EPOCHS = 80
BATCH = 16

十四、后续调参方向

1. 调整每类样本数量

最重要的参数之一是:

1
SAMPLES_PER_CLASS = 1000

不同设置大概对应:

每类样本数总图片数量适合阶段
200约 6.9 万流程验证
500约 17 万小规模训练
1000约 34.5 万正式 baseline
3000约 103.5 万更充分训练
10000约 345 万大规模训练

我的建议:

1
2
3
先用 200 跑通流程
再用 1000 训练 baseline
最后根据效果决定是否扩大到 3000

2. 调整模型大小

YOLO11 有不同规模的模型。

一般可以按这个顺序尝试:

1
yolo11n.pt → yolo11s.pt → yolo11m.pt

建议:

模型特点适合场景
yolo11n.pt最快,最轻测试流程、低显存
yolo11s.pt精度和速度平衡推荐正式 baseline
yolo11m.pt更强,但更慢数据量更大、有较好 GPU

如果只是先验证 Quick, Draw! 方案,使用:

1
MODEL = "yolo11n.pt"

如果效果稳定,可以换成:

1
MODEL = "yolo11s.pt"

3. 调整训练轮数

训练轮数在脚本里是:

1
EPOCHS = 50

建议:

训练轮数适合阶段
10快速测试
30初步训练
50baseline
80更充分训练
100+大数据长期训练

如果训练过程中验证集指标已经不再提升,可以减少 epochs。

如果模型还在持续提升,可以增加 epochs。

4. 调整 batch size

batch 参数:

1
BATCH = 16

如果显存不够,训练时报 CUDA out of memory,可以降低:

1
BATCH = 8

甚至:

1
BATCH = 4

如果显存充足,可以提高:

1
BATCH = 32

经验上:

1
2
3
显存小:batch=4 或 8
8GB 显存:batch=8 或 16
16GB 显存:batch=16 或 32

5. 调整图片尺寸

当前图片尺寸是:

1
IMG_SIZE = 640

训练时也是:

1
IMGSZ = 640

如果想训练更快,可以改成:

1
IMGSZ = 416

如果想保留更多细节,可以改成:

1
IMGSZ = 768

但是图片越大,显存占用越高,训练越慢。

我的建议:

1
2
3
baseline 使用 640
显存不足使用 416 或 512
后期追求精度再尝试 768

6. 调整是否只使用 recognized 样本

转换脚本里有:

1
ONLY_RECOGNIZED = True

如果为 True,只使用 Quick, Draw! 中被系统识别成功的样本。

优点:

1
2
3
数据更干净
标签和图形更稳定
训练更容易收敛

缺点:

1
少了一些更难、更潦草、更真实的手绘样本

如果想让模型更鲁棒,可以后期尝试:

1
ONLY_RECOGNIZED = False

但建议 baseline 先保持:

1
ONLY_RECOGNIZED = True

7. 调整数据增强

当前转换脚本里的增强比较保守:

1
ENABLE_AUGMENT = True

增强内容包括:

1
2
3
4
5
6
不同背景颜色
不同笔迹深浅
不同笔迹粗细
轻微模糊
亮度变化
对比度变化

这些增强不会改变几何位置,所以 YOLO 框不用重新计算。

后续如果想更贴近真实纸面照片,可以加入:

1
2
3
4
5
6
纸张纹理
阴影
轻微噪声
拍照模糊
JPEG 压缩
局部光照变化

但要注意:

1
旋转、透视变换、裁剪这类增强会改变 bbox

如果加入这些增强,就必须同步变换标注框,否则标签会错。

8. 调整检查脚本阈值

YOLO 数据集检查脚本里有几个阈值:

1
2
3
4
5
MIN_WH = 0.002
WARN_WH = 0.01
WARN_RATIO = 20.0
WARN_AREA_SMALL = 0.0001
WARN_AREA_LARGE = 0.95

如果训练的是线条类目标,很多框天然会比较细长,所以 extreme_ratiosmall_wh 的 warning 可能会比较多。

如果确认这些是正常样本,可以适当放宽:

1
2
WARN_RATIO = 40.0
WARN_WH = 0.005

但是 hard error 不建议轻易忽略,例如:

1
2
3
4
coord_out_of_0_1
class_out_of_range
label_without_image
bad_image

这些问题应该先修复再训练。

9. 调整置信度阈值

预测脚本中有:

1
CONF = 0.25

如果误检很多,可以提高:

1
CONF = 0.4

如果漏检很多,可以降低:

1
CONF = 0.15

一般可以测试:

1
2
3
4
0.15
0.25
0.35
0.5

观察哪个阈值最适合自己的场景。

10. 训练结果怎么看

训练结束后重点看:

1
2
3
4
Precision
Recall
mAP50
mAP50-95

简单理解:

指标含义
Precision检出的东西有多少是真的
Recall真实目标有多少被检出来
mAP50IoU=0.5 下的平均检测性能
mAP50-95更严格的平均检测性能

如果:

1
Precision 高,Recall 低

说明模型比较保守,漏检可能比较多。

如果:

1
Recall 高,Precision 低

说明模型检得比较多,但误检可能比较多。

十五、后续微调真实纸面数据

Quick, Draw! 数据是干净白底手绘图,而真实纸面照片会有很多额外问题:

1
2
3
4
5
6
7
纸张阴影
拍摄角度
纸张纹理
笔迹粗细变化
背景杂乱
多条线同时出现
手写文字干扰

所以 Quick, Draw! 训练出来的模型更适合作为基础模型。

真正落地时,建议继续做:

1
2
3
4
1. 收集真实纸面照片
2. 使用 CVAT 或 Roboflow 标注 YOLO 框
3. 用 Quick, Draw! 训练出的 best.pt 继续微调
4. 用真实测试集评估

微调命令示例:

1
2
3
4
5
6
7
8
yolo detect train \
  model=runs/detect/quickdraw_yolo11n/weights/best.pt \
  data=datasets/paper_lines/paper_lines.yaml \
  epochs=80 \
  imgsz=640 \
  batch=8 \
  device=0 \
  name=paper_lines_finetune

真实纸面数据建议至少准备:

数据量适合阶段
300 张快速验证
1000 张初步可用
3000 张比较稳定
10000 张以上更接近生产使用

十六、我的当前结论

这套方案的意义不是直接得到一个最终可商用的纸面线条识别模型,而是先训练出一个手绘风格基础模型。

当前流程可以总结为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Quick, Draw! 全量类别
检查 NDJSON 下载完整度
自动转 YOLO 检测数据
检查 YOLO 数据集完整性
抽样可视化标签框
训练 YOLO11 baseline
用真实纸面数据继续微调

我后续主要会继续优化三件事:

1
2
3
1. 扩大每类样本数量
2. 尝试 yolo11s.pt 或 yolo11m.pt
3. 加入真实纸面照片进行微调

最终目标是让模型不仅能识别干净白底手绘图,也能识别真实纸张上拍摄出来的线条和图形。

使用 Hugo 构建
主题 StackJimmy 设计