📚 离职知识库

笔画排序算法规格书 v1.0

配套《白板解说视频站-复刻计划.md》的 §4。这是整个项目唯一没有现成答案、必须自己啃的部分,也是决定成片观感的核心。 结论已定案,执行时不要再重新选型,按本文实现即可。


#0. 定案摘要

主路线:连通域切分 → 线/块分类 → 贪心就近排序(复合打分)→ 线走骨架 DFS、块走同心圈涂抹 → 按点数分配时长。

一句话理由:我们的输入是"粗黑描边 + 平涂色块"的位图,矢量化路线(vtracer/potrace)描的是填充轮廓不是中心线,一条粗线会被画成两条平行边,观感是"描字"不是"画画";骨架化能直接拿到单像素宽的笔尖轨迹,这才是手绘该有的东西。


#1. 先看对手的水平(实测,不是推断)

我们逆向了 SpeedPainteroutline.svg,统计 <path> 文档顺序 vs 起点坐标:

样本 path 数 起点 y 递增比例 起点 x 递增比例
ai-4a2a97bcc924 19 89% 56%
ai-ad84dfbbe7d7 31 100% 47%
ai-14ebecc2da60 11 80% 50%

它的算法就是「按 path 起点 y 从上到下排序」,x 完全不参与(50% ≈ 随机),面积也不参与。 第一条 path 通常是覆盖全画面的大结构(因为它起点 y 最小)。

行业现状:VideoScribe 官方文档明写笔顺来自 SVG 图层顺序("from the bottom of the list and working up");Doodly 承认自动识别的顺序"常常不合逻辑";Adobe 社区专家直言"不能自动化,必须手工"。自动笔顺在业界基本是未解决问题。

⇒ **我们只要做得比"一次 sort by y"好,就已经领先。**门槛比想象中低得多。


#2. 选型对比与否决理由

路线 结论
纯位图遮罩淡入 ❌ 做不出笔尖运动感,只能色块淡入
矢量化 + 路径排序(vtracer→vpype linesort) ❌ 不做主路线。描的是填充轮廓,粗线变两条平行边;linesort 是纯贪心 TSP,只解决"怎么串最省笔",不知道"什么该先画"。降为层级 3 的局部工具
骨架化 + 图遍历 主路线。细化到 1px 得到真正的笔尖轨迹;sknw 建图后节点度数天然区分端点(度1)/岔路口(度≥3),岔路口正是"不机械"的控制点
Proximity clustering(arXiv 2502.20119) ⚠️ 不做独立路线(该论文用户研究仅 5 人、自评 "somewhat simplified"),但吸收为排序打分的一项权重
TRACE (CRNN) ❌ 要训练数据 + 200–500ms + GPU 才好用
汉字/日文笔顺库(makemeahanzi/KanjiVG) 🔜 只在"画面里有文字"的子场景启用,留接口,本期不做

骨架化库选 skimage.morphology.skeletonize,不用 LingDong 的 skeleton-tracing —— 后者 Python 绑定维护度低、C 扩展在部分发行版要额外调编译;skimage 纯 pip 装、零编译风险。1536² 二值图骨架化约 100–300ms,预算内。


#3. 算法四层

#层级 1:切分绘制单元

1. cv2.connectedComponentsWithStats(mask, connectivity=8)
2. 每个连通域算 area / bbox / stroke_ratio = area / bbox周长
3. 分类:stroke_ratio < 8.0 → "描边线";否则 → "色块"
4. area < 60 px² 丢弃(噪点)
5. 合并:cv2.dilate(5×5, iters=2) 后重新连通域,
   膨胀后同属一域的原始单元合并为一个"单元组"
   —— 防止一条被小色块打断的轮廓线被拆成两个不相关单元

⚠️ 实现注意:stroke_ratio 用 bbox 周长是粗略近似,斜向细长线会偏大。M1 调参时若发现误判多,改用 area / cv2.arcLength(contour, True)(真实轮廓周长)。

#层级 2:单元间排序 —— 贪心动态过程,不是一次性静态 sort

这是相对 SpeedPainter 的核心改进:静态排序(不管按 y 还是按面积)必然产生"画完这类再跳去画那类"的瞬移感。

score(u) = 0.45 * norm_area(u)              # 面积:先大后小
         + 0.25 * (1 - norm_top_y(u))       # 位置:偏上优先
         + 0.30 * (1 - norm_dist_to_last(u))# 就近:离上一笔越近越优先 ← 关键项
         + 0.00 * norm_zorder(u)            # 图层信息(暂无,权重 0)

# 贪心流程
first = 面积最大的单元            # 呼应实测"首个 path 复杂度最高"
while 还有剩余:
    candidates = 按到上一笔质心的距离排序的前 K=5next = candidates 中 score 最大者

特例规则(覆盖通用打分)

  • bbox 覆盖 > 70% 画面的"背景色块" → 强制第 0 笔
  • 线-块配对:色块膨胀 2px 后与某描边线单元 IoU > 0.3 ⇒ 判定为该色块的轮廓线,强制线排在块之前(先勾线后上色)

#层级 3.1:描边线 → 骨架 DFS

skel = skeletonize(unit.mask)
graph = sknw.build_sknw(skel, multi=True)
起点 = 离"上一笔收笔点"最近的端点(度=1);无端点(闭环)则就近切开
DFS,岔路口打分:
score = 1.0 * cos_sim(当前运笔方向, 候选边起始方向)   # 方向连续性,主导项
      + 0.6 * (-1 if 该边已画过 else 0)              # 未画过的优先,但不绝对禁止重走
      + 0.2 * min(边长, 200) / 200                   # 先长后短,仅作 tie-break

方向向量取边起始的第 5 个点(edge.pts[min(5, len-1)] - edge.pts[0])。允许重复走边——骨架图是欧拉路径问题,度>2 的节点必须重走才能全覆盖。设 max_steps = 边数 × 3 防死循环。

#层级 3.2:色块 → 同心圈涂抹(不走骨架

色块骨架化只剩一根中轴线,沿它画根本填不满,会出现"画了根线却凭空多出一片颜色"。改用收缩轮廓法

while 还有像素:
    取最外层轮廓 (cv2.findContours, RETR_EXTERNAL)
    向内腐蚀一个笔刷半径 (disk(12))
各层轮廓按外→内顺序,层内用之字形连接
面积 < 400 px² 的小色块跳过分层,直接一笔带过/淡入

渲染时这一段把手换成 hand_marker_360.png(粗头马克笔)。

#层级 4:时间分配

raw_duration(u) = 0.4 + (点数占比) × (总时长 -0.4 - (N-10.15)
笔画之间插入 0.15s 抬笔停顿
整体缩放命中总时长,但 0.4s 是不可压缩下限(优先压长笔画的富余)
每笔首尾各 3 帧 ease-in-out,模拟起笔/收笔的物理感

#4. 参数表(调大/调小的后果)

参数 默认 调大 调小
STROKE_RATIO_THRESH 8.0 细长块被当色块(该涂色的变成描线) 粗线被当色块(走涂色逻辑显呆板)
MIN_UNIT_AREA 60 px² 丢细节(高光、小装饰) 留噪点,多余小笔画拖节奏
合并膨胀核 5×5 iter=2 过度合并(两朵不相干的云被绑成一组) 合并不足,一笔被拆多段
W_AREA 0.45 越"先画大的" 先画细节,画面无重点
W_TOP 0.25 越接近纯 y 排序(越像对手) 上下顺序随意
W_ANCHOR_DIST 0.30 >0.4 过度贪恋局部,大结构线被切碎穿插进细节 =0 退化成静态排序
CANDIDATE_POOL_K 5 ≥12 退化为全局排序,规整但生硬 ≤3 局部反复横跳,杂乱
W_DIR 1.0 >2 该转弯不转,生硬 岔路随意转向,像抽搐
W_VISITED 0.6 尽量不重走,可能覆盖不全 重复多,笔画拖沓
brush_radius_px 12 涂色快但露白 细腻但慢、点数暴增
MIN_STROKE_DURATION 0.4s 大结构被压缩过快 短笔画一闪而过
PAUSE_DURATION 0.15s 拖沓 <0.05s 重现连续扫描的机械感

单元数 <15 时 CANDIDATE_POOL_K 调到 3,>80 时调到 8。


#5. 代码骨架

"""whiteboard_stroke_order.py —— 笔画排序主流程"""
import cv2, numpy as np, networkx as nx, sknw
from skimage.morphology import skeletonize
from dataclasses import dataclass, field

@dataclass
class Unit:
    id: int; mask: np.ndarray; bbox: tuple; area: int
    centroid: np.ndarray; kind: str            # "stroke" | "block"
    sub_branches: list = field(default_factory=list)

@dataclass
class Stroke:
    unit_id: int; points: np.ndarray           # (N,2) 有序坐标
    start_time: float; end_time: float
    kind: str                                  # "stroke" | "block_fill"

def compute_stroke_order(binary_mask, cfg=Config()) -> list[Stroke]:
    units = segment_units(binary_mask, cfg)            # 层级1
    ordered = order_units(units, binary_mask.shape, cfg)  # 层级2
    paths, entry = {}, np.array([binary_mask.shape[1]/2, 0])
    for u in ordered:                                   # 层级3
        paths[u.id] = (trace_stroke_unit(u, entry, cfg) if u.kind == "stroke"
                       else trace_block_unit(u, cfg))
        if len(paths[u.id]): entry = paths[u.id][-1]
    return assign_timing(ordered, paths, cfg)           # 层级4

def order_units(units, shape, cfg):
    h, w = shape[:2]; diag = np.hypot(h, w)
    max_area = max(u.area for u in units); by_id = {u.id: u for u in units}
    bg = [u for u in units if u.bbox[2]*u.bbox[3] > 0.7*h*w]
    first = max(bg or units, key=lambda u: u.area)
    ordered, remaining = [first], {u.id for u in units} - {first.id}
    while remaining:
        last = ordered[-1]
        cands = sorted(remaining,
            key=lambda i: np.linalg.norm(by_id[i].centroid - last.centroid))[:cfg.candidate_pool_k]
        nxt = max(cands, key=lambda i: score_unit(by_id[i], last, diag, max_area, h, cfg))
        ordered.append(by_id[nxt]); remaining.remove(nxt)
    return ordered

def dfs_with_direction_heuristic(graph, start, cfg):
    visited, path, cur = set(), [], start
    incoming = np.array([0.0, 1.0])
    for _ in range(graph.number_of_edges() * 3):
        edges = list(graph.edges(cur, keys=True, data=True))
        if not edges: break
        best, best_s = None, -1e9
        for u, v, k, d in edges:
            eid = (min(u, v), max(u, v), k); pts = d['pts']
            cand = normalize(pts[min(5, len(pts)-1)] - pts[0])
            s = (cfg.w_dir * float(np.dot(incoming, cand))
                 + cfg.w_visited * (-1.0 if eid in visited else 0.0)
                 + cfg.w_length * min(d.get('weight', 0), cfg.long_edge_cap) / cfg.long_edge_cap)
            if s > best_s: best_s, best = s, (u, v, k, d)
        u, v, k, d = best; visited.add((min(u,v), max(u,v), k))
        pts = d['pts'] if u == cur else d['pts'][::-1]
        path.append(pts); incoming = normalize(pts[-1] - pts[max(0, len(pts)-6)])
        cur = v if u == cur else u
        if len(visited) >= graph.number_of_edges(): break
    return np.concatenate(path) if path else np.empty((0, 2))

segment_units / trace_block_unit / assign_timing 按第 3 节的伪代码直译即可。)


#6. 我们比 SpeedPainter 多做的三件事

  1. 就近连续贪心排序:它只按 y 排、x 完全无序(实测 47–56%),意味着画完左上角下一笔可能跳到右上角,手在瞬移。我们保证每笔起点都在上一笔终点附近。
  2. 线-块配对强制先勾线后上色:它不区分线和块,会出现"先涂了色块、后画它的轮廓"的颠倒观感。
  3. 岔路口方向连续性启发式:它走的是填充轮廓路径,没有"运笔方向"概念;我们在真实笔画分叉点用 cos 相似度选路,不会突然 90° 掉头。

#7. 降级预案(M1 卡住时按这个退)

触发条件(任一命中)

  • 单图骨架化 + DFS 超过 2.5 秒(连通域 >150 或骨架边数 >2000)
  • DFS 触及 max_steps 上限仍未覆盖全部边
  • 自动指标显示局部反复横跳段数 > 总段数 15%

L1 局部降级:该单元跳过方向启发式,用 vpype linesort(贪心最近邻)串联骨架线段。 L2 整图降级:直接复现对手逻辑 —— 连通域按"面积最大者优先,其余按 bbox 顶部 y 从上到下"。保证最坏情况不低于对标产品

降级是每图独立判定,不做全局开关。


#8. 验收标准(可自动化,不靠肉眼)

指标 算法 阈值
笔画连贯度 相邻两笔 dist(前笔终点, 后笔起点) / 对角线 的均值 < 0.15(纯 y 排序基线通常 >0.30)
反跳率 连续三笔质心 dot(c2-c1, c3-c2) < 0 的比例 < 20%
岔路平滑度 路径上相邻切线夹角 >90° 的点占比 < 5%
像素覆盖率 轨迹经过像素 与 原 mask 前景的 IoU > 95%
单图耗时 wall-clock < 3 秒(硬性)

验收流程:拿 150–200 张同风格插画跑全流程,看这 5 项的分布(要看 P95 不只看均值)。Coverage <90% 或超时的样本单独拉出来,确认是否命中降级条件、降级是否正确触发。

第一项「笔画连贯度」就是我们和对手的直接可比指标 —— 用同一批图,把我们的算法和"纯 sort by y"各跑一遍,两个数字放一起,就是最直观的效果证明。

来源:沉淀/explainer-video-逆向素材/白板视频-笔画排序算法规格书.md(整理于 2026-08-18)