笔画排序算法规格书 v1.0
配套《白板解说视频站-复刻计划.md》的 §4。这是整个项目唯一没有现成答案、必须自己啃的部分,也是决定成片观感的核心。 结论已定案,执行时不要再重新选型,按本文实现即可。
#0. 定案摘要
主路线:连通域切分 → 线/块分类 → 贪心就近排序(复合打分)→ 线走骨架 DFS、块走同心圈涂抹 → 按点数分配时长。
一句话理由:我们的输入是"粗黑描边 + 平涂色块"的位图,矢量化路线(vtracer/potrace)描的是填充轮廓不是中心线,一条粗线会被画成两条平行边,观感是"描字"不是"画画";骨架化能直接拿到单像素宽的笔尖轨迹,这才是手绘该有的东西。
#1. 先看对手的水平(实测,不是推断)
我们逆向了 SpeedPainter 的 outline.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=5 个
next = 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 + (点数占比) × (总时长 - N×0.4 - (N-1)×0.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 多做的三件事
- 就近连续贪心排序:它只按 y 排、x 完全无序(实测 47–56%),意味着画完左上角下一笔可能跳到右上角,手在瞬移。我们保证每笔起点都在上一笔终点附近。
- 线-块配对强制先勾线后上色:它不区分线和块,会出现"先涂了色块、后画它的轮廓"的颠倒观感。
- 岔路口方向连续性启发式:它走的是填充轮廓路径,没有"运笔方向"概念;我们在真实笔画分叉点用 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)