从扩散图到分层原画
diffusion-to-painting —— 把一张 AI 生成的平面图,变成一张每个像素都有来历的分层原画。
一张 diffusion 图看起来是画,但它不是画出来的:没有图层、没有笔触、放大就糊、边缘是像素格子。 这本书记录一条把它变成可交付原画的完整流程,以及沿途每一个被实测过的结论和踩过的坑:
一张 diffusion 图
│
├─ 拆分 分割成语义部件, 从抗锯齿边解出亚像素边界, 切色阶, 轮廓矢量化
├─ 补全 被遮挡的部件补完整 (📐 设计稿, 未实现)
├─ 叠层 在 Krita 里按画师的图层顺序叠回去: 剪影矢量做底, 每个色阶自成一组
├─ 重绘 在每个色阶的形状里一笔笔画: 平涂 / 柔和过渡 / 排线 / 发丝 / 细节 / 线稿
├─ 记录 每一笔写进 strokes.jsonl, 可回放
└─ 验收 归一到同一尺度, 以源图为锚, 量边、量色阶、量放大指纹
终点是一份 8192×12288 的 .kra:307 个节点、74 个矢量层、8 万笔,写像素只有 2 次(背景铺黑)。
怎么读
- 只想跑起来 → 附录 A 命令速查,然后 第 1 章 一图流
- 想知道为什么这么做 → 从 前言 顺着读
- 要接手改代码 → 附录 B 代码索引 + 第 22 章 strokes.jsonl 规范
- 要在 Krita 里做别的事 → 第七部 Krita 无头能力
- 想少走弯路 → 第 34 章 量错对象案例集,这一章是全书最贵的部分
完整目录在左侧边栏。
状态标记
每章第一行有一个标记,这是本书的诚实性所在——没量过的东西不会混进量过的表里:
| 标记 | 含义 |
|---|---|
| ✅ 已验证 | 有探针或实测数据,数字可复现 |
| 🧪 在做 | 跑通了,但没有收敛到满意 |
| 📐 设计 | 只有接口和验收标准,没跑过 |
仓库布局
book.toml mdBook 配置
src/
README.md 封面(本文件)
SUMMARY.md 目录
00-前言 … 99-附录 正文, 按流程分八部 + 附录
assets/ 对比图、参考画特写、中间产物
code/ 配套代码: 分割 / 矢量化 / 规划笔画 / Krita 落笔 / 判据
probes/ 探针: 每个回答一个「Krita 无头下到底行不行」的问题
paint 唯一入口脚本
环境
Fedora 44 · Krita 6.0.2.1(RPM)· PyQt6 · Xvfb · Python 3.14(numpy / scipy / scikit-learn / Pillow / matplotlib)。
Krita 5.x 没有 Node.paintLine,本书代码不兼容 5.x(第 26 章有说明)。
./paint setup # 装 harness (独立 HOME, 不碰你自己的 Krita 配置)
./paint check # 31 项自检
./paint trace # 把 work/input/origin.png 临摹成 8K 分层原画, 约 8 分钟
前言:一张 diffusion 图为什么不能直接当原画用
✅ 已验证
三个缺陷
一张 diffusion 生成的插画,比如本书通篇用的这张 1024×1536 的 origin.png:
它看起来是一张画,但它不是画出来的,这在三个地方露馅:
1. 不可编辑。 它是一张平面位图。想把裙子换个颜色、把头发调亮一点、把眼睛重画——没有图层,只能重新生成。画师拿到它接不了手。
2. 放大就糊,边缘是像素格子。 1024×1536 拿去印刷差 8 倍。bicubic 放大 8 倍之后,1x 的抗锯齿边被摊成 8px 宽的过渡,1x 的像素格被放大成 8px 的阶梯。实测在同一块 1:1 眼部特写上量梯度 p99(边缘锐度):
| 梯度 p99 | |
|---|---|
| origin 自己(1x) | 124.3 |
| origin bicubic ×8 | 20.2 |
| 人类画的 2508px 插画(同样 1:1 特写) | 69.4 |
放大一次,锐度掉到 1/6。而一次早期尝试——把 origin 分层、逐位无损分解、再 bicubic 升采样写进 8K 图层——输出的 p99 是 23.5,约等于一次放大。它的作画日志里 55 条记录全是 set_pixels,一笔都没落。
3. 没有笔触,没有层次。 人类画的插画靠明确的几档色阶加少量柔和过渡构成层次;diffusion 图是连续糊。放大后一个是笔痕,一个是马赛克。
目标
把它变成可交付的分层原画:
输出的每一个像素,要么来自一条解析曲线,要么来自一笔真的落笔。没有任何一个像素是把源图放大出来的。
具体到交付物:一份 8192×12288 的 Krita .kra,图层树就是作画顺序,画师能接手改任何一层;一份 strokes.jsonl,记录了每一笔的坐标与压感,能回放。
这不是复刻——不要求逐像素对上源图。这是临摹:源图是参考,输出是照着它重新画的一张。所以本书的判据不是 PSNR,而是「边是不是曲线」「有没有放大指纹」「层次像不像人画的」(第八部)。
这本书是怎么来的
它不是从零设计出来的,是一个项目做了五版之后按流程重新组织的。前四版走的是像素复刻路线,第五版推倒重来。每一个数字都是在这台机器(Fedora 44 / Krita 6.0.2.1)上量出来的;每一个坑都是真的栽过。
所以书里有很多「试过,不行,为什么」。这些不是废话——Krita 的大量 API 不校验参数、不报错、静默失效,而「量错了对象」这类错误不会报错、只会给出方向相反的结论。哪些路走不通,和哪些路走得通一样值钱。
状态标记与阅读顺序
每章第一行有状态标记:✅ 已验证(有数据)· 🧪 在做(跑通没收敛)· 📐 设计(只有接口)。
全书按流程分八部。第一部是总览,读完就知道每一步在干什么;第二到第六部是流程本身,按顺序读;第七部是 Krita 无头能力手册,随用随查;第八部是判据,第 34 章的案例集建议先读——它教你怎么不被自己的测量骗。
1 一图流:拆 → 补 → 叠 → 画 → 验
✅ 已验证(补全那一步除外,📐)
origin.png (1024×1536)
│
┌───────────────┴───────────────┐
│ 拆分 trace_prepare.py │ 裸 python, 不需要 Krita, 约 2.5 分钟
│ 4 segment.regions → 15 部件 │
│ 5 alpha_from_image → 亚像素 α│
│ 6 cel.levels → 2–5 档色阶 │
│ 7 contour.trace → 贝塞尔 │
│ 17–21 strokes.* → 8 万笔计划 │
└───────────────┬───────────────┘
│ plan.json + strokes.jsonl
┌───────────────┴───────────────┐
│ 补全 (📐 未实现) │ 被遮挡的部件补完整
└───────────────┬───────────────┘
│
┌───────────────┴───────────────┐
│ 叠层 + 重绘 job_trace.py │ Krita 无头, 约 5 分钟
│ 12–14 建 307 节点的图层树 │
│ 27 逐笔 paintPath / paintLine│
└───────────────┬───────────────┘
│ final.kra + final.png + strokes.jsonl(日志)
┌───────────────┴───────────────┐
│ 验收 gauge.py │ 归一到 2508px, 以源图为锚
└───────────────────────────────┘
两步,中间是文件
流程被刻意切成两个进程:
| 步 | 进程 | 干什么 | 耗时 |
|---|---|---|---|
| 1 读画 | 裸 python3 | 分割、解 α、切色阶、矢量化、规划笔画 | ~2.5 分钟 |
| 2 落笔 | Krita(xvfb) | 建图层树、加矢量、逐笔画、存盘 | ~5 分钟 |
切开的理由:迭代参数时不用每次起 Krita。第一步产出 plan.json(曲线)和 strokes.jsonl(笔画计划),还有一张 1x 的 preview.png——规划器自己模拟出来的成品,不起 Krita 就能看个大概。第二步只是照着画。
./paint trace 把两步串起来,末尾自动打印判据。
分工:矢量管边,笔画管里面
整条流程的核心主张只有一句:
矢量层 负责边 解析级平滑, 分辨率无关
笔画 负责边以内的一切 平涂 / 柔和过渡 / 排线 / 发丝 / 细节 / 线稿
边为什么不能用笔画?因为笔画是光栅的,在 8K 上画一条边,边的形状取决于笔刷的形状,而不是取决于源图。矢量曲线是从源图的抗锯齿边解出来的(第 5 章),位置准到亚像素,而且 8x / 16x / 100x 都一样光滑。
里面为什么不能用矢量?因为矢量填出来是死平的一块。人画的东西有笔触、有过渡、有排线、有发丝——这些是笔画的事。
两者在 Krita 里的结合点是 clip:矢量层做组底定义形状,笔画层 setInheritAlpha(True) 裁到它。笔可以随便涂出界,边永远是那条曲线(第 13 章)。
一次跑下来的数字
以 origin.png 为例,最终成品:
| 画布 | 8192×12288(8x) |
| 部件 | 15 |
| 色阶组 | 73(每部件 2–5 档) |
| 图层节点 | 307,其中矢量层 74 |
| 贝塞尔 | 607 环 / 20,495 控制点 |
| 笔画 | 80,669 笔 / 99,755 次落笔调用 |
| 写像素 | 2 次(背景铺黑) |
| 耗时 | 读画 ~150s + 落笔 217s + 存盘 ≈ 8 分钟 |
.kra | 323 MB |
判据(全部归一到 2508px 再量,第 32 章):
| 判据 | 源图 | bicubic ×8 | 本流程 |
|---|---|---|---|
| 8px 阶梯指纹(放大痕迹) | 6.19 | 2.23 | 0.78 |
| 梯度 p99(边锐不锐) | 41.6 | 39.3 | 36.9(源图的 89%) |
| 8 级量化色数(层次) | 2900 | 2800 | 1840(63%) |
| 低频 likeness(降到 256 宽比) | — | — | 33.57 dB |
| 全图最暗像素 | (0,0,0) | (0,0,0) | (0,0,0) |
阶梯指纹 2.23 → 0.78 是主结果:放大痕迹没了。边的锐度 36.9 低于纯矢量阶段的 78.9——笔画在软化边,这是第五部末尾明确留下的账。
8K 原尺寸 1:1 眼部特写。左:bicubic ×8(能看到阶梯和糊)。中:只有矢量层(边是真曲线)。右:落完 8 万笔。
2 中间产物
✅ 已验证
流程每一步都落一个文件。看懂这些文件,就能在任何一步停下来检查,不用等整条链跑完。
目录布局
work/
input/origin.png 输入
cache/origin/ 读画 (trace_prepare.py) 的产物
plan.json 部件 / 色阶 / 轮廓控制点 / 主体剪影 ← 给 Krita 建矢量层
strokes.jsonl 一行一笔的计划 ← 给 Krita 逐笔画
parts.png 部件伪彩图
cells.png (部件, 色阶) 联合标签的伪彩图
preview_cel.png 只铺色阶的 1x 模拟
preview.png 色阶 + 笔画的 1x 模拟 ← 不起 Krita 就能看
output/trace-origin/ 落笔 (job_trace.py) 的产物, 名字固定, 重跑覆盖
final.png 8192×12288 成品
final.kra 完整分层工程文件 ← 画师接手
layers.json 每层的 path / blend / opacity / clip / label / bounds
strokes.jsonl 作画日志: 实际落下的每一笔 ← 可回放
stages/01-tree.png 只有矢量层时的样子
stages/02-strokes.png 落完笔
archive/ ./paint archive 存的带时间戳历史
两个 strokes.jsonl 不是一回事
| 文件 | 谁写的 | 内容 | 坐标 |
|---|---|---|---|
cache/origin/strokes.jsonl | 规划器 | 计划:kind / pts / R / rgb / op / taper / part / tier | 1x |
output/trace-origin/strokes.jsonl | Krita 会话 | 日志:实际调了什么 API、传了什么 | 8x |
计划是「打算画什么」,日志是「真的画了什么」。规格分别在第 23 章和第 22 章。
各阶段看什么
| 想确认 | 看哪个文件 | 怎么看 |
|---|---|---|
| 部件分对了没 | parts.png | 每个部件一种颜色,眼睛该在脸里、丝带该独立 |
| 色阶切得合不合理 | cells.png / preview_cel.png | 阴影形状是不是嵌套的、眼睛有没有被压成一块 |
| 笔画有没有白画 | preview.png vs preview_cel.png | 差别就是笔画贡献的部分 |
| 边是不是曲线 | stages/01-tree.png 的 1:1 特写 | 这一张的梯度 p99 是判「锯齿修没修掉」的依据 |
| 笔画有没有把结构盖掉 | stages/01-tree.png vs 02-strokes.png 同一块 1:1 | 见第 17 章的坏例 |
| 是不是真画的 | output/.../strokes.jsonl | ./paint replay <名> 数写像素次数,必须 ≤ 2 |
cells.png:每个 (部件, 色阶) 一种颜色。笔画的活动范围是这些格子,不是部件。
3 量对对象的三条铁律
✅ 已验证——每一条都是栽过之后写下来的
Krita 的 API 大量不校验参数、不报错、静默失效,所以这本书里的每一个结论都要靠测量。但测量本身会骗人,而且骗得比 API 更狠:它不报错,只给出一个看起来合理但方向相反的结论。 这个项目在五版里至少栽了 28 次,全部记在第 34 章。这里先立三条铁律。
一、量单层还是量合成,先想清楚
Krita 的 clip(inherit alpha)机理是合成时只写 RGB 不写 A。所以:
- 读一个 clip 层的
pixelData,alpha=0 的地方 RGB 照样被写过——只看 RGB 会得出「没裁剪」 - 读一张累积合成图,几十层的边界叠在一起——某一层脏不脏根本看不出来
- 塑造画在 Shade 层上,量 Flat 层永远是「颜色数=1」
规矩:量图层效果一律用 s.projection(group);判 clip 必须同时看 alpha;判某一层的边缘质量就只量那一层。
二、跨分辨率比是无效比较
梯度 p99 是逐像素的差分,随分辨率变。同一张 8K 成品:
| 量的时候归一到 | 梯度 p99 |
|---|---|
| 8192px 宽 | 33.2 |
| 2508px 宽 | 42.2 |
| 1024px 宽 | 56.2 |
拿 8192 的读数(33.2)去比参考画在它原生 2508px 上的读数(59.4–74.6),结论是「差一倍」;归一到 2508 再比是 42.2,差距只有一半,方向和量级都变了。
规矩:画面指标一律先归一到同一物理宽度再量。gauge.py 默认归到 2508(参考画的原生宽度)。
三、门槛以源图为锚,不以参考画为锚
参考画(两张人类画的 2508px 插画)说明「人画的长什么样」。但它是另一张画:它有成套的深色线稿,而 origin 是色トレス风格,本来就没有独立线稿。同样归一到 2508px:
| 梯度 p99 | |
|---|---|
| 参考画 | 59.4–74.6 |
| origin 自己 | 41.6 |
要求临摹品比自己的源头还锐,是一道永远达不到的门。规矩:门槛写成「源图的百分比」(现在是 85%),参考画的区间只作参照打印,不当门槛。
附:三条铁律之外的两个提醒
探针的几何要匹配实际用法。 在长平滑曲线上量出 path() 比 stroke() 干净 23–47%,据此改线稿;换到实际的短线段上结论完全反过来(stroke() K90 0.032 最好,path() 0.0437 最差)。点少时 paintPath 的 quadTo 平滑会改变形状。
一次只改一件,每件有独立判据。 为修一圈锯齿黑描边一次上了三件事(抽线稿 + 去抗锯齿 + 平滑 SDF),结果比它们要修的问题糟 9 倍(PSNR 38.53 → 24.99),而且花了六轮才分清是哪一件在坏事。
4 语义分割成部件
✅ 已验证 · 代码 code/segment.py · 入口 trace_prepare.py 顶部
目标
把 origin 切成十几个有名字的部件:头发、脸、躯干、裙子、丝带……每个部件之后会成为 .kra 里的一个组。这是全流程的第一刀,切错了后面全错。
边界屏障法,不是颜色分类
三种「按颜色分」的路子都试过,都错:
| 方法 | 错在哪 |
|---|---|
| 逐像素 HSV 规则 | 发梢的粉色判成皮肤 |
| 区域均值色 | 整头头发判成皮肤 |
| 加金色先验 | 小腿高光吞进头发 |
原因很简单:一个部件内部的颜色变化(头发从金到粉的渐变)常常比部件之间的差别还大。
segment.regions(img, thr=25, win=7, min_px=300) 走的是另一条路:先找边界(局部对比超过阈值的地方形成屏障),再把屏障围出来的连通区域各自编号。头发内部金→粉是平滑渐变,不构成屏障,天然不会被切开。产出是一张区域标签图和每个区域的 bbox / 中心 / 面积。
区域 → 部件:一张手工核对的表
regions() 切出来的是几十个无名区域。把它们归到部件靠 trace_prepare.py 顶部的 ASSIGN 表:
ASSIGN = [
("Hair_Back", [5, 26, 30, 32, 25]),
("Skin_Leg_L", [58]),
...
("Hair_Front", [2, 3, 6, 1]),
]
顺序 = 底→顶 = 遮挡顺序。 后面的部件盖住前面的,所以 Hair_Front 在最后。
这张表是对着 atlas.png(区域伪彩图 + 编号)目视核对写出来的,换一张图必须重做。没有被分配到的区域会并入空间最近的已分配部件。
本书用的 origin 分成 15 个部件:
Hair_Back Skin_Leg_L Skin_Leg_R Skin_Foot Shoe Skin_Torso Skin_Arm Skin_Face
Dress_Bodice Dress_Skirt Dress_Frill Ribbon_Waist Ribbon_Hem Ribbon_Hair Hair_Front
部件之后的两个集合
同在 trace_prepare.py 顶部:
STRANDY = {"Hair_Back", "Hair_Front"} # 哪些部件长发丝
HATCHY = {"Skin_Face", "Skin_Torso", "Skin_Arm", "Skin_Leg_L", "Skin_Leg_R"} # 哪些排线
它们决定第 18 章里 strand 和 hatch 两种笔落在哪。也是换图要改的。
已知的边界
- 分割在 1x 上做。 部件边界的亚像素位置在这一步就丢了——分割成硬标签图的那一刻,抗锯齿信息没了。第 5 章负责把它捡回来。
- 眼睛不是独立部件。 它在
Skin_Face里,靠第 6 章的色阶把它切出来。这在本书的 origin 上够用;五官更复杂的图可能需要把眼睛单独列进ASSIGN。 regions()的三个参数(thr对比阈值、win窗口、min_px最小区域)目前是手调的,没有自动选择。
5 亚像素边界:从抗锯齿边解 α
✅ 已验证 · 代码 code/contour.py 的 alpha_from_image()
丢掉的信息在哪
origin 是一张抗锯齿的图。在两个部件交界的像素上,颜色是两侧颜色的线性混合:
c = α · C_p + (1 − α) · C_q
α 就是这个像素有多大比例属于部件 p——它编码着边界的亚像素位置。第 4 章的分割把每个像素硬判给一个部件,α 在那一刻被扔了。而它正是「边平不平」的全部依据:一条从二值 mask 里提出来的边,位置误差可达 ±0.5 像素,放大 8 倍就是 ±4 像素的抖动。
解 α
把 c 投影到 C_p − C_q 这条直线上:
α = ((c − C_q) · (C_p − C_q)) / |C_p − C_q|²
两个关键细节:
C_p、C_q 不能用固有色。 有明暗时,固有色离该处的实际颜色很远,解出来的 α 会系统性偏。做法是各取本侧最近的干净像素:把 mask 腐蚀掉 band=2 层,剩下的核心区用最近邻向外推成整幅的场。所以 C_p / C_q 是随位置变的。
两侧颜色太接近时退回二值。 |C_p − C_q| < min_sep(12) 时 α 病态,硬解会把噪声放大成边界抖动。这些地方 α 直接取 0 或 1。
只在边界带(mask 膨胀 2 层 ∖ 腐蚀 2 层)里解,其余地方 α = mask。
实测
对 Hair_Front(90,473 px):
| 解出分数覆盖的像素 | 5,896(6.5% 的部件像素是亚像素边) |
| 边界带 α 分布 p10 / p50 / p90 | 0.05 / 0.30 / 0.89 |
| 面积:硬 mask → 分数 | 90,473 → 88,135(−2.58%) |
面积缩小是对的——硬 mask 把任何标签为 p 的像素都算作整个像素,哪怕它只有 30% 属于 p。
它值多少
第 7 章的自检里,同一套矢量化,喂二值 mask 和喂 α 场的差别:
| 输入 | K90(8x) | aliased% | 形状偏差 |
|---|---|---|---|
| 二值 mask(最佳参数) | 0.0448 | 2.8 | 0.0715 |
| α 场 | 0.0422 | 2.6 | 0.0485 |
形状准确度好 1.5 倍。这是 origin 里唯一残存的亚像素信息,不用就没了。
也用于色阶边界
同一个函数加 domain= 参数,可以在部件内部解色阶之间的 α:这时「另一侧」是同部件里更浅或更深的那档,而不是隔壁部件。搞混的话色阶边会去跟部件边对齐,位置整体偏掉。第 6 章的 cel.vectorize() 就是这么调的。
6 色阶分层
✅ 已验证 · 代码 code/cel.py(分档复用 segment.tiers / segment.pick_flat)
层次是这么来的
人类画的插画靠明确的几档色阶构成层次,不是连续渐变。实测参考画和 bicubic 放大的 origin 在同一块 1:1 特写上量 8 级量化色数,前者更少却更有层次。所以每个部件要切成几档:固有色、阴影 1、阴影 2、高光……
分档
cel.levels(rgb, mask, k=None):
segment.tiers()在部件内做 KMeans,按亮度排序segment.pick_flat()把面积最大的那档定为Flat(不是最亮的——白裙的本色就是最亮档)- 比 Flat 暗的档叫
Shadow1 / Shadow2 …,比它亮的叫Light1 / Light2 …
档数 k 由 pick_k() 按部件的亮度跨度(3–97 分位)自适应,2–5 档。
嵌套,不是并列
这一条是关键。每一档的 mask 不是「只属于这档的像素」,而是「这档及所有比它更深的像素」:
Flat = 整个部件
Shadow1 = 所有比 Flat 暗的像素 (包含 Shadow2)
Shadow2 = 所有比 Shadow1 暗的像素 (包含 Shadow3)
叠层时上层盖下层,每一档只管「比上一档再深一点的那块」。
为什么不并列?并列切法的各档各自独立矢量化,曲线不会正好贴合,档与档之间会漏缝。 嵌套则永远是上层盖下层,没有缝。这也正是画师的图层叠法。
清理
KMeans 分出来的 mask 有碎点和针孔,矢量化之后是几百个 2px 的小环。clean_mask() 做闭运算 + 开运算,去掉小于 min_px=24 的连通域,填掉小于 24 的洞。
矢量化
cel.vectorize() 把每一档变成曲线(第 7 章)。Flat 用对整幅解的 α(另一侧是别的部件),其余档用 domain=部件 限定在部件内解(另一侧是同部件的更浅档)。Flat 还向外扩 expand=0.6 像素盖住接缝(第 14 章)。
实测
origin 的 15 个部件:
| 部件 | 档 | 环 | 控制点 |
|---|---|---|---|
| Hair_Front | 5 | 124 | 4254 |
| Dress_Frill | 5 | 126 | 4148 |
| Hair_Back | 5 | 61 | 2831 |
| Dress_Skirt | 5 | 43 | 1848 |
| … | |||
| 合计 | 594 | 20,495 |
耗时 26.7s(裸 python)。
档数不是越多越好
试过给脸加档:
| Skin_Face | k=4 | k=8 | k=16 |
|---|---|---|---|
| 只铺色阶的 PSNR | 18.89 | 20.08 | 20.80 |
4 倍的档数只换来 1.9 dB。色阶的作用是给结构(眼睛在哪、阴影形状是什么),连续变化的部分交给笔画。这也是为什么第 17 章的规划器起点是「色阶铺完的画面」。
只铺色阶的 1x 模拟。眼睛被压成色块是预期的——这是「铺平涂」阶段,画师的平涂稿也长这样。
7 轮廓矢量化
✅ 已验证 · 代码 code/contour.py · 自检 python3 code/contour.py
为什么必须是曲线
旧路线的边界一路是「1x 硬标签图 → SDF → bicubic 放大 8 倍」。SDF 的零等值线本身就建在 1x 的台阶上,放大只是把台阶放大。高斯平滑 SDF 能把 K90(边缘曲率,锯齿的直接度量)从 0.31 压到 0.05,但那是把台阶抹圆,不是换成曲线——形状信息在 1x 那一步就丢了。
矢量曲线是分辨率无关的,8x / 16x / 100x 都一样光滑。而且 Krita 的矢量层渲染是解析级的:同一条曲线,矢量层与解析真值的边界带偏差 0.083,paintPolygon 0.498,select_mask 0.331(第 27 章)。
四步
contour.trace(mask, smooth=0.8, eps=0.30, expand=0.0):
- 标量场
level_field():内正外负,零点落在真实边界上。传 α 场时,边界带用α − 0.5,远处用 SDF 提供全局结构。单位统一成像素,这样expand的偏移在两个区域对得上。 - 等值线:高斯平滑
smooth=0.8之后,取−expand等值线。用 matplotlib 的contour(在解析圆的 SDF 上实测半径 mean 19.9988 / std 0.0015,真值 20)。 - 简化
lineart.rdp(eps=0.30):太小把台阶原样保留成折点,太大啃掉真的尖角。 - 闭合 Catmull-Rom → 三次贝塞尔
_catmull_closed()。开口版首尾会有折角,闭合环要绕回去取邻居。
产出 SVG <path>,fill-rule="evenodd" 让洞自动挖掉。
+0.5 —— 一个半像素的坐标约定
matplotlib 的 contour 返回数组下标空间的坐标:下标 (0,0) 是第一格的中心。而 PIL / Krita / SVG 里像素 (0,0) 覆盖 [0,1)×[0,1),中心在 (0.5, 0.5)。差这半格不报错、形状照样光滑,只是整条曲线系统性偏移半个像素。
实测不加 +0.5 时:控制点到解析曲线 mean 0.442 / max 0.783 px,降回 1x 的形状偏差 0.302——而光栅化本身的地板只有 0.007。一个纯粹的坐标约定错误,差点被读成「矢量化不如 coverage()」。
自检:v4 与 v5 同题对比
一个解析形状 → 1x 二值 mask → 两条路各自重建 8x → 与 8x 解析真值比:
| 路子 | K90(8x) | aliased% | 形状偏差 | 面积 |
|---|---|---|---|---|
| 解析抗锯齿(锚点) | 0.0642 | 3.6 | — | — |
v4 coverage(smooth=0.8) 最佳 | 0.1475 | 30.0 | 0.1521 | +0.43% |
v5 二值 mask(smooth=1.5 eps=0.30) | 0.0448 | 2.8 | 0.0715 | +0.01% |
v5 α 场(smooth=0.8 eps=0.30,默认) | 0.0422 | 2.6 | 0.0485 | +0.01% |
三项同时赢:边更平(K90 好 3.5 倍)、更抗锯齿(好 11 倍)、形状还更准(好 3.1 倍)。v5 的 K90 甚至低于解析锚点——曲线本来就比像素化的真值光滑。
判据是配对的,缺一不可
- K90(8x 覆盖率的 level-set 曲率)→ 边平不平
- 降回 1x 与 1x 真值比 → 形状有没有被抹坏
只看 K90 会奖励「抹圆一切」。而且 1x 真值必须由 8x 真值降采样得到,不能另画一张——另画的那张与 8x 那张之间有约 1/8 像素的系统偏移,锚点读出 0.1245 而不是 0。
一个失败的判据
最初用的是 probe_fill 的判据——「8x 真值抗锯齿带里的 |覆盖率−真值|」。在这里它是坏的:那条带只有 1px 宽,而从 1x 二值 mask 恢复不出 8x 的亚像素位置,结果 v4 四条、v5 十二条全部读出 0.47±0.02,毫无区分度。它量的是「边在不在原位」,而这里要问的是「边是曲线还是台阶」——两个不同的性质。probe_fill 里它能用,是因为那里矢量和真值来自同一条解析曲线,天然对齐。
在真实图上
origin 的 15 个部件:5,043 个控制点(只有 Flat)/ 20,495 个(含色阶),5.5s / 26.7s。K90 0.056–0.11,全部好于 v4 的 0.15。
expand:各部件独立矢量化,边界曲线不会正好贴合。躯干那块实测 expand=0 时 0.935% 的主体像素漏出底色,expand=0.6 压到 0.41%。剪影外鼓出来的那圈由第 14 章的组 clip 切掉。
8K 1:1 眼部,只有矢量层。边是曲线,一个台阶都没有。这一张归一到 2508px 后梯度 p99 = 78.9,高于参考画区间。
8 为什么要补全
📐 设计 —— 这一部没有实现,没有实测数据。写的是问题、接口和验收标准。
问题
拆分出来的部件是被遮挡过的。Skin_Torso 被手臂和裙子挡住,它的 mask 是「露出来的那部分」,不是躯干的完整形状。Hair_Back 被 Hair_Front 和脸挡住。
在叠层时这会露出来:
- 图层顺序改不动。 画师想把手臂移开一点,底下的躯干是空的。
- 接缝依赖遮挡关系。 现在靠
expand=0.6让上层压住下层,如果下层根本没画到那里,扩张也没用。 - 局部重绘没有底。 后续 AI 要重画手臂,它挪动之后露出的躯干区域没有内容。
一张真正可编辑的分层原画,每一层在被遮挡的地方也该是画完整的——这正是画师的做法:先把躯干整个画完,再在上面画手臂。
补什么、不补什么
补:部件被其他部件遮挡的区域,在遮挡边界向内一个合理深度(建议 ≤ 部件等效半径的 40%)。
不补:
- 部件被画框裁掉的部分(画布外没有真值,也不需要)
- 背景(它已经是铺满整幅的,见第 12 章)
- 遮挡太深的区域(躯干被裙子挡住的下半身——补出来的东西没有任何约束,是编造)
在流程里的位置
拆分 (4–7) → 补全 (8–11) → 叠层 (12–16)
│
└─ 输入: 每个部件的 mask / α / 色阶 / 周边上下文
输出: 补完整的部件 RGBA, 以及重新解出的 α 边界
补全的输出要重新走一遍第 5 章和第 7 章——新的边界要解 α、要矢量化。所以补全模块的产出格式和拆分的中间产物一致,下游不需要知道有没有补过。
这一部的状态
本书完成时 ./paint trace 直接从拆分跳到叠层,跳过补全。所有实测数字都是在没有补全的前提下量的。补全实现之后,第 15 章的判据不应变差;如果变差了,是补全引入的。
9 补全的接口与验收
📐 设计
输入
对每个需要补全的部件 p:
| 项 | 来源 | 说明 |
|---|---|---|
mask_p | 第 4 章 | 部件现在可见的区域(1x 二值) |
alpha_p | 第 5 章 | 亚像素覆盖率 |
tiers_p | 第 6 章 | 色阶列表 [(role, rgb, mask)],告诉补全模块这个部件有哪几种颜色 |
occluders | ASSIGN 顺序 | 压在 p 上面的部件集合——被它们挡住的地方才需要补 |
context | origin | mask 周边一圈的原图像素(建议 32px),给生成模型当条件 |
budget | 配置 | 最大补全深度(像素),默认 = 0.4 × 部件等效半径 |
被遮挡区 = dilate(mask_p, budget) ∩ (∪ mask_occluders)。
输出
| 项 | 格式 | 说明 |
|---|---|---|
rgba_p | (H, W, 4) uint8 | 补完整的部件,alpha 在补全区为 255 |
mask_p' | 1x 二值 | 新的完整 mask = mask_p ∪ 补全区 |
alpha_p' | float | 对 mask_p' 重新解的 α(第 5 章) |
report | json | 补了多少像素、哪些像素被判为不可补(budget 外)、耗时 |
验收标准
每条都要能自动量,不能靠看:
1. 重叠区不变。 原本可见的区域(mask_p)补全前后逐像素相同:max|Δ| = 0。补全只许加不许改。
2. 边界连续。 沿可见区与补全区的交界,两侧颜色的梯度不高于部件内部同类边的中位数。量法:在交界带上取梯度 p90,与部件内部(远离交界)的梯度 p90 比,比值 ≤ 1.5。
3. 色阶一致。 补全区的像素按 tiers_p 的颜色做最近邻归档,落在已有档之外(距离最近档 > 24)的像素比例 ≤ 5%。补全不许发明新颜色。
4. 不发明结构。 补全区的高频能量(measure.py 的 σ1 带通)不高于可见区的 1.2 倍。生成模型喜欢在空白处画出东西,这一条拦它。
5. 下游判据不变差。 走完叠层之后,第 15 章的每一项不低于未补全时的值。
不做的事
- 不改可见区的任何像素
- 不改
ASSIGN顺序 - 不补背景
- 超出
budget的区域标记为不可补,而不是硬补
10 候选工具
📐 设计 —— 以下没有一个跑过。列出来是为了下一步有的选。
本机环境
- GPU:RTX 5070 Ti,16 GB 显存
- ComfyUI 已在
Krita/ComfyUI/(与本书同级目录) - 系统 Python 3.14:numpy / scipy / scikit-learn / Pillow / matplotlib;没有 torch(ComfyUI 有自己的环境)
候选
| 工具 | 类型 | 适合 | 顾虑 |
|---|---|---|---|
| ComfyUI + SD/SDXL inpainting | 生成式 | 大面积、有语义的补全(躯干、裙子) | 会发明结构;风格可能漂;要写 workflow JSON 并走 HTTP API |
| LaMa | 非生成式(FFT 卷积) | 小面积、纹理延续(头发、布料) | 不理解语义,大面积会糊;模型要单独装 |
| 自己写:最近邻 + 按流向外推 | 纯 numpy | 极小面积(expand 那种几像素的缝) | 只能延续,不能补形状 |
推荐的组合:LaMa 先补,ComfyUI 只在 LaMa 的输出通不过验收(第 9 章第 4 条)时接手。理由是 LaMa 不发明东西,验收更容易过;生成模型留给真的需要语义的地方。
ComfyUI 接口(待验)
ComfyUI 的调用方式是把 workflow JSON POST 到 http://127.0.0.1:8188/prompt,轮询 /history/<id> 拿输出。要写的东西:
- 一个 inpainting workflow 模板:加载图 + mask → inpaint → 输出
code/complete.py:按第 9 章的输入组装 context + mask,调 API,收输出,做验收- 一个探针
probes/probe_inpaint.py:拿一个人为遮挡的部件(把Skin_Arm从躯干上抠掉)跑一遍,量第 9 章的五条验收,记录耗时和显存
先做探针,再做模块。 这是本书的规矩:没量过的东西不进流程。
风格约束
补全区必须和可见区同一张画。对生成模型来说这意味着:
- 条件里给足 context(周边 32px 不够的话加到 64px)
- prompt 不描述内容,只描述风格(“flat anime shading, cel”),内容由 context 决定
- 输出按
tiers_p的颜色做归档校正(第 9 章第 3 条),把漂掉的颜色拉回来
11 补全后的一致性检查
📐 设计
补全的输出要在进入叠层之前过一遍检查。检查的东西和第 9 章的验收一一对应,这里写怎么量。
检查清单
# 伪代码, 每一项返回 (pass: bool, value: float)
def check_completion(orig_rgb, mask_p, rgba_p, mask_p2, tiers_p, occluders):
filled = mask_p2 & ~mask_p # 补全区
# 1 重叠区不变
d = np.abs(rgba_p[mask_p, :3] - orig_rgb[mask_p]).max()
yield "重叠区不变", d == 0, d
# 2 边界连续: 交界带梯度 vs 部件内部梯度
seam = dilate(mask_p, 2) & dilate(filled, 2)
inner = erode(mask_p, 6)
g = gradient_mag(rgba_p[..., :3])
ratio = p90(g[seam]) / p90(g[inner])
yield "边界连续", ratio <= 1.5, ratio
# 3 色阶一致: 补全区像素到最近档的距离
cols = np.array([rgb for _, rgb, _ in tiers_p])
dist = nearest_color_distance(rgba_p[filled, :3], cols)
off = (dist > 24).mean()
yield "色阶一致", off <= 0.05, off
# 4 不发明结构: 高频能量
e_fill = bandpass_energy(rgba_p, filled, sigma=1)
e_seen = bandpass_energy(rgba_p, mask_p, sigma=1)
yield "不发明结构", e_fill <= 1.2 * e_seen, e_fill / e_seen
第 5 条(下游判据不变差)在叠层之后由 gauge.py 量,不在这里。
失败了怎么办
| 失败项 | 处理 |
|---|---|
| 重叠区不变 | 直接用原图覆盖回去(这一条永远能修) |
| 边界连续 | 在交界带上做归一化卷积羽化(第 18 章的 _blur(mask=) 就是这个),宽度 2–3px |
| 色阶一致 | 把超出的像素拉到最近档的颜色 |
| 不发明结构 | 换工具(LaMa → ComfyUI 或反过来),或缩小 budget |
重新解边界
补全区的外边界是新的,α 要重解:对 mask_p' 跑 contour.alpha_from_image()。注意补全区的外侧是遮挡它的部件,clean() 取的「另一侧干净像素」会是遮挡物的颜色——这是对的,叠层时它确实压在上面。
记录
每个部件的检查结果进 plan.json 的 parts[i].completion 字段:
"completion": {"filled_px": 4120, "skipped_px": 880, "tool": "lama",
"checks": {"重叠区不变": [true, 0], "边界连续": [true, 1.12], ...}}
没补的部件这个字段为 null。这样第 15 章的判据变差时能追到是哪个部件、哪一条。
12 图层树 = 作画顺序
✅ 已验证 · 代码 code/job_trace.py
树
01_BG/
BG_Base 铺黑 ← 全片唯一一次 set_pixels
BG_Glow 背景辉光, 铺满整幅画布, 最底层
04_Color/
Subject_Silhouette 矢量: 主体剪影 ← 组底, 定义整体 alpha
<Part>/ ×15, 组, inheritAlpha → 裁到剪影
<Part>_Flat/ 组
Shape 矢量: 固有色轮廓 (向外扩 0.6px)
Soft paint, clip → 只在 Shape 里 airbrush 柔和过渡
Marks paint, clip → 只在 Shape 里 排线 / 细节 / 发丝
<Part>_Shadow1/ 组, clip 到部件 (下方累积 alpha)
Shape / Soft / Marks
<Part>_Shadow2/ … 每部件 2–5 档
06_Line/
Line 线稿 (anime 风格才有内容)
09_Post/ passthrough
Post_Grade soft_light, opacity 12
对 origin:15 个部件 → 73 个色阶组 → 307 个节点,其中矢量层 74 个。
顺序就是画师的顺序
从下往上读这棵树,就是一个画师的作画顺序:
- 铺背景,整幅铺满(不抠人物——人物在上面盖住)
- 定剪影
- 逐个部件:先铺固有色,再叠阴影 1、阴影 2、高光……每一档是一个形状
- 在每个形状里面画:柔和过渡、排线、发丝
- 最后描线稿、调色
ASSIGN 表的顺序决定部件组的顺序(底→顶 = 遮挡顺序),cel.levels() 的顺序决定色阶组的顺序(Flat 在最底,深阴影在浅阴影上面)。
为什么每个色阶自成一组
这是本书第五版最重要的一条架构决定,来自一次失败:
第一版把笔画层直接挂在部件组下,只 clip 到部件。结果一笔可以横跨眼睛和皮肤——Skin_Face 里眼睛是一个深色的色阶,皮肤是浅色的,一支从皮肤起笔的粗笔涂过眼睛,把矢量层刚立好的结构整片盖掉。8K 成品的梯度 p99 从矢量阶段的 64.0 掉到 15.8,眼睛变成一团圆斑。
左:只有矢量层。右:笔画只按部件 clip 之后。眼睛的形状没了。
修法:每个色阶自成一组,Shape(矢量)做组底,Soft / Marks clip 到它。笔画的活动范围是色阶形状,不是部件轮廓。 规划器那边同步改成按 (部件, 色阶) 联合标签规划(第 17 章)。改完 p99 回到 33.2。
代价是节点数从 110 涨到 307,建树时间从 72s 涨到 209s。值。
两级 clip
- 色阶组(
Shadow1/起)setInheritAlpha(True)→ 裁到部件组内下方的累积 alpha = 部件 - 部件组
setInheritAlpha(True)→ 裁到04_Color内下方的累积 alpha = 主体剪影
所以一个色阶里的笔画被裁两次:先裁到本档形状,再(通过部件组)裁到剪影。规则的实测依据在第 13 章。
建树的成本
| 节点 | 矢量层 | 控制点 | 耗时 | |
|---|---|---|---|---|
| 第一版(笔画只 clip 到部件) | ~110 | 31 | 20,495 | 72s |
| 现在(每色阶一组) | 307 | 74 | 20,495 | 209s |
矢量层是大头:addShapesFromSvg 一个 5000 控制点的 path 约 4s。每个矢量层之后要 pump(6, 0.05) 等它就绪。
13 三条 clip 规则
✅ 已验证 · 探针 probes/probe_clipping.py · probe_clipvector.py · probe_vecstack.py
clip 在 Krita 里叫 inherit alpha(node.setInheritAlpha(True))。它是本书整个叠层架构的支点,三条规则全部实测。
一、裁到「同组内下方各层的累积 alpha」
Group "Skin"
├─ Skin_Light (inherit alpha) ← 裁到组内下方各层的累积 alpha
├─ Skin_Shadow (inherit alpha)
└─ Skin_Flat ← 组内最底, 定义整个组的形状
实测(组内 base 红块 x∈[100,200)、top 绿条 x∈[20,380)):
| 条件 | 绿条可见跨度 |
|---|---|
| 未开 inherit | (20, 379) |
| 开 inherit | (100, 199) ← 精确裁到 base |
| base 再加一块蓝 x∈[280,340) | (100, 339) ← 累积,不是紧邻层 |
group 设 passThroughMode(True) | (20, 379) ← 裁剪失效 |
所以:Flat 必须在组内最底;group 不能 passthrough。
二、矢量层能做底,group 也能被 clip
这两条是第五版新验的,架构成立与否全看它们。
笔画层 clip 到矢量层(probe_clipvector.py,8192×12288,39 笔故意画出界):
| 状态 | 边界带偏差 | 溢出 | 内部均色 |
|---|---|---|---|
| ① 只有矢量层 | 0.0828 | 0.0% | (62,110,161) 矢量色 |
| ② 笔画层未 clip | 0.4804 | 100% | (227,122,64) 笔色 |
| ③ 笔画层 clip 到矢量层 | 0.0828 | 0.0% | (227,122,64) 笔色 |
| ④ 对照:clip 到二值光栅层 | 0.3791 | 0.0% | (227,122,64) 笔色 |
③ 与 ① 完全相同——inherit alpha 认矢量层做底,而且把矢量的分数 alpha 原样传给了上面的笔画层。比 ④ 好 4.6 倍,差别全在组底是曲线还是像素格。
group 本身可以 setInheritAlpha(probe_vecstack.py,部件组里是「比剪影大一圈的矢量 + 涂出界的笔画」):
| 状态 | 组的不透明像素 | 均色 |
|---|---|---|
| ① 只有剪影矢量 | 7,147,891 | 剪影色 |
| ② 部件组未 clip | 10,270,050 | 笔色 |
③ 部件组 setInheritAlpha(True) | 7,147,891 | 笔色 |
③ 与 ① 一模一样:裁的是整组的合成 alpha,只裁 alpha 不换内容。
三、判据必须同时看 alpha
机理是 KisLayer::disableAlphaChannel():合成时只写 R/G/B、不写 A。alpha=0 的地方 RGB 仍然是被写过的。同一次测量里,不看 alpha 的 RGB 跨度是 (64, 255)(tile 对齐),可见跨度只有 (100, 199)。只看 RGB 会得出「没裁剪」。
这条规则有一次历史:v1.0.0 把 base/top 建在根节点下,根下有一个全不透明的 Background,「下方累积 alpha」处处是 255,于是什么也没裁——那是正确行为,却被记成了「clipping 用不了」。复现实验:同样构造放根节点下,跨度确实还是 (20, 379)。
附:transparency mask
另一种裁法,挂在目标图层下才生效(挂到 rootNode 下是孤儿节点):
mask = doc.createTransparencyMask("M")
target_layer.addChildNode(mask, None)
mask.setSelection(sel)
实测挂上后绿条跨度 (20,379) → (120,199),精确。它比 inherit alpha 更适合交给画师:mask 本身可编辑。本书没用它,因为矢量层做底已经够了。
14 接缝
✅ 已验证
部件各自独立矢量化,边界曲线不会正好贴合。接缝处会漏出底色——底是纯黑,所以是一串黑点或黑线。这一章讲三条解法,以及一条走错的路。
走错的路:partition of unity
很自然会想到给相邻部件按覆盖率分配 alpha:alpha_i = cov_i / Σcov,接缝处两边各 0.5,加起来正好 1。
Krita 是 alpha-over 合成,不是加权和。 两层各 0.5 叠出来会留下 (1−0.5)×(1−0.5) = 25% 的背景。纯黑底上就是一串黑点。
正确做法和画师铺色一样:每个部件自己不透明,并向邻居底下多铺几像素,靠遮挡顺序让上面的盖住下面的。
一、Flat 向外扩 0.6px
contour.trace(..., expand=0.6):取 −0.6 等值线而不是 0,等于把 Flat 的轮廓向外推 0.6 个 1x 像素(8x 上约 5px)。
躯干那一块(部件交界最密)实测:
| expand | 漏底(主体像素) | 溢出(剪影外) |
|---|---|---|
| 0.0 | 0.935% | 0.077% |
| 0.3 | 0.595% | 0.177% |
| 0.6 | 0.410% | 0.260% |
| 1.0 | 0.240% | 0.347% |
漏底压了一半多,代价是剪影外鼓出一圈。
二、部件组 clip 到剪影
鼓出来的那圈由第二条解法切掉:04_Color 组底放一条主体剪影矢量,每个部件组 setInheritAlpha(True)。部件可以随便涂出界,剪影仍然由那一条曲线精确定义。依据是第 13 章第二条。
只有色阶组的 Flat 用 expand,其余色阶不扩——它们的邻居是同部件的另一档,嵌套结构(第 6 章)保证上层盖下层,本来就没缝。
三、背景铺满整幅
背景辉光原来只铺「主体之外且有辉光」的区域,误差驱动的种子在平滑渐变上留空隙,而且剪影周围一圈没铺到——剪影和部件轮廓差一点点就漏黑边。
改成铺满整幅画布、放最底层:主体那一块被上面的部件组盖住,天然不会露。主体挡住的那块辉光从外面外推出来(_blur(bg, 24, mask=~subject) 归一化卷积)。改完低频 likeness 从 21.7 dB 升到 33.6 dB,全图最暗像素回到 (0,0,0)。
这也是画师的顺序:先把背景整个铺完,再在上面画人。
细长条会被平滑吃掉
一个相关的坑,来自旧路线但现在仍适用:高斯平滑 SDF 时,1–2px 宽的细条会整条变成负值 → 覆盖率 0 → 什么都不画 → 黑底透出来。实测 (x=201, y=283):origin (254,247,225),画成 (4,2,1)。
逐部件的面积判据抓不到——丢几根细丝对 9 万像素的部件不到 0.5%,视觉上却是一串黑洞。修法是让平滑只许改形状不许吃内部:sdf = where(sdf ≥ 1, max(smoothed, sdf), smoothed)(code/upsample.py)。contour.trace() 的 smooth=0.8 比旧的 1.2 小,也是为了这个。
15 叠回原画效果的验收
✅ 已验证 · 工具 code/gauge.py(第 32 章)
叠完之后,怎么知道它像不像原画?三层判据,从硬到软。
一、硬判据:是不是真画的
看 output/.../strokes.jsonl:
| 门槛 | 现在 | |
|---|---|---|
写像素(set_pixels / ground)次数 | ≤ 2(只有背景铺黑) | 2 |
| 落笔次数 | > 0 | 80,669 |
旧路线这两个数是 55 / 0。这一条不过,后面的都不用看。
二、画面判据:边、层次、放大指纹
全部归一到 2508px 宽再量(第 3 章第二条),以源图为锚(第三条):
| 判据 | 门槛 | 源图 | 现在 |
|---|---|---|---|
| 梯度 p99 | ≥ 源图的 85% | 41.6 | 36.9(89%) |
| 8 级量化色数 | ≥ 源图的 55% | 2900 | 1840(63%) |
| 8px 阶梯指纹 | < 源图 | 6.19 | 0.78 |
阶梯指纹是「8px 周期的方向抖动」——1x 像素格放大 8 倍留下的签名。bicubic ×8 是 2.23,本流程 0.78。这是叠层这一部的主结果:放大痕迹没了。
三、像不像:低频 likeness
两边都降到 256px 宽再算 PSNR。临摹只该在这个尺度上像——逐像素对上是复刻的判据,不是临摹的。
| dB | |
|---|---|
| 门槛 | ≥ 30 |
| 现在 | 33.57 |
外加一条:全图最暗像素 (0,0,0)。origin 的背景是纯黑;早期版本铺的是 (6,5,8),黑发灰,肉眼能看出来。
两处固定的 1:1 特写
数字之外,每次出图必看两块(8x 坐标):
眼部: (3900, 1400) – (4700, 2000)
发丝边缘: (1500, 2000) – (2300, 2600)
把它们和参考画的 1:1 特写并排放。判成败的一句话:看不出「一边是放大的、一边是画的」就算过。
左:参考画(人类,2508px)。右:本流程 8K 成品,同为 1:1。差距还在——参考画有收尖的发丝和线稿,我们没有。这是第五部的事。
判据变差时怎么追
| 症状 | 先看 |
|---|---|
| 写像素 > 2 | job_trace.py 里谁调了 set_pixels |
| 阶梯指纹上升 | 有没有哪一步又走了升采样(upsample.upsample_image 不该出现在流程里) |
p99 掉,但 stages/01-tree.png 的 p99 没掉 | 笔画在软化边——第 17 章的两个坑 |
p99 掉,01-tree.png 也掉 | 矢量化参数(smooth / eps)或 α 解错 |
| 低频 likeness 掉 | 背景(有没有铺满、有没有外推)、或整体色偏 |
| 最暗像素不是 (0,0,0) | 背景平涂的颜色取错(整条扫线一个颜色) |
16 .kra 交付规范
✅ 已验证(layers.json 那一节 🧪)
三件套
s.final() 落在 work/output/<作品名>/,名字固定,重跑覆盖:
| 文件 | 给谁 | 内容 |
|---|---|---|
final.kra | 画师 | 完整 layer stack;clip / blend / opacity / color label / 矢量层 往返无损(实测) |
final.png | 预览 | 合成结果 |
strokes.jsonl | 回放 / 审计 | 每一笔(第 22 章) |
layers.json | 后续 AI | 每层的 path / blend / opacity / clip / label / bounds / uid |
stages/NN-*.png | 过程 | 每个 s.stage() 一张 |
命名文法
顶层组 NN_Name 01_BG 04_Color 06_Line 09_Post
部件组 Part Hair_Front Skin_Face Dress_Frill
色阶组 Part_Role Hair_Front_Flat Hair_Front_Shadow1
叶子 Shape / Soft / Marks (在色阶组内)
Role ∈ { Flat, Shadow1, Shadow2, …, Light1, Light2, … }。部件名全局唯一 ⇒ 色阶组名全局唯一。
color label 是第二索引
findChildNodes(colorLabelIndex=N, recursive=True) 是 Krita 唯一自带的「按语义批量取层」入口,在 docker 里就是一条彩色条——一个字段同时喂画师和程序。
| label | 语义 |
|---|---|
| 2 | Lineart |
| 3 | Flat |
| 4 | Shadow |
| 5 | Light |
| 8 | 辅助(BG / Post) |
画师接手时怎么改
| 想做 | 动哪里 |
|---|---|
| 换一个部件的固有色 | 改该部件 _Flat/Shape 矢量的填充色 |
| 调阴影形状 | 改 _ShadowN/Shape 的路径(矢量,可拖节点) |
| 重画某一块的质感 | 清掉 Soft / Marks,在上面画——clip 仍然生效 |
| 隐藏所有笔画看纯色阶 | 按 label 批量隐藏 Soft / Marks |
| 移动一个部件 | 整组移动;被遮挡区是空的(第 8 章) |
体积
.kra | |
|---|---|
| 旧路线(103 层光栅,8K) | 181 MB |
| 只有矢量层(15 层,8K) | 13.8 MB |
| 现在(307 节点,8 万笔) | 323 MB |
矢量层几乎不占空间;体积全在笔画层的 tile。存盘 4–5s。
layers.json:🧪 一个已知的不一致
layers.json 由 Session.manifest() 生成,它遍历的是 s.index——而 s.index 只由 s.build(spec) 填。第五版的 job_trace.py 直接用 doc.createGroupLayer / createVectorLayer 建树,没走 build(),于是 manifest() 拿到空索引,layers.json 没有被重新生成。现在目录里那份是旧路线留下的(103 层,paintlayer,与 .kra 里的 307 节点不符)。
修法有两条:让 job_trace.py 建完树后自己遍历 doc.rootNode() 填 s.index;或让 manifest() 不依赖 index,直接递归 childNodes()。后者更对。没做,所以标 🧪。
还能往里塞什么
三样实测建得出、不改画面、.kra 往返不丢的(第 30 章):
| 东西 | 给画师的价值 |
|---|---|
doc.createSelectionMask(name) + mask.setSelection(sel) | 每个部件一个现成选区,点一下就能只改那个部件 |
doc.createFileLayer(name, 源图路径, "None") | 源 diffusion 图作为对照层躺在最上面,随时开关 |
doc.createCloneLayer(name, source) | 重复部件只存一份;改源,克隆跟着变 |
调色板进不去:Palette / Swatch 在内存里加得进去,但 save() 返回 False
不写盘,而且没有 Document 级的 palette API。图层样式也进不去(第 31 章)。
往返保真的依据
probes/probe_vector.py 与自检第 196–200 项:.kra 存了再开,层数、inheritAlpha、blend / opacity / label、4 个矢量层及其 toSvg() 全部保真。
17 规划器总论
✅ 已验证(软化边缘那一条是留下的账) · 代码 code/strokes.py · 调用 trace_prepare.py 第 2 步
起点不是黑画布
规划器要回答「在哪里、用什么颜色、多大的笔、画一笔」。它的起点是色阶铺完之后的画面(第 6 章的 preview_cel.png),不是空白。它只补差额:哪里和源图差得够多,才在哪里起笔。于是笔数花在看得见的地方,而不是花在重画一遍已经对了的色块上。
模拟画布
strokes.Sim 在 1x 上模拟每一笔落下去的样子:圆头软笔核(hardness=0.42),沿折线按 0.35·R 的间隔盖章,alpha = kernel × opacity × mask,alpha-over 合成。它只需要够准到能判断「还差多少」,不是渲染器——真正的落笔是 Krita 的 brush engine 干的,形状不会完全一样。
叠加符合 1−(1−a)^N(probe_glaze.py 实测 b) Airbrush Soft opacity 0.35 叠 1/2/3/5/8 笔:83/139/177/219/244,理论 89/147/184/225/246),所以模拟和 Krita 的差别主要在笔的形状,不在叠色。
坐标一律 1x
规划器工作在 1x(1024×1536),产出的 pts 和 R(半径)都是 1x 单位;job_trace.py 统一乘 SCALE=8。R=1 的笔在 8x 上是直径 16px,与参考画 1:1 下的发丝宽度同量级。
活动范围是色阶格,不是部件
trace_prepare.py 把 (部件, 色阶) 编成联合标签 cells = pid × 100 + tier(cells.png)。每种笔在一个格子里规划:生长时不出格(_grow() 的 region 约束),落笔时 alpha 乘格子的 mask。
这和第 12 章的「每色阶一组」是同一件事的两端:Krita 那边 clip 到色阶形状,规划器这边不跨格取色、不跨格落笔。只做一边不够——只在 Krita 裁,笔的颜色仍然是跨格取的(见下一节)。
两个软化边缘的坑
跨区域取色。 规划器给笔上色用的是「模糊过的参考图」。不加 mask 的高斯模糊会把区域外的颜色渗进来:一支落在阴影区里的笔,颜色混进了亮部;每条色阶边两侧都被各自往对方拉,边就软了。修法是归一化卷积 _blur(a, s, mask):gaussian(a·m) / gaussian(m),颜色只在 mask 内取。
无差别铺一层。 给部件的大色阶区也做了一遍平涂(wash,opacity 0.30),结果归一后的梯度 p99 从 48.4 掉到 32.3、色数 2303 → 1206。平涂是不看误差地铺满,把色阶边一起盖软。平涂只用在背景那种纯渐变上。
留下的账
纯矢量阶段归一到 2508px 的梯度 p99 是 78.9,比源图(41.6)还锐;落完笔是 36.9。笔画在软化边。上面两个坑修掉之后还剩这么多,主要是 soft(airbrush,约 3.9 万笔)和 detail(贪心细节笔,约 3.9 万笔)叠在色阶边上。参考画落在 59.4–74.6,正好在两者之间——我们软过头了。
下一步的方向在第 20 章:不同风格给这两种笔不同的预算。
笔画的顺序
每个部件、每个色阶,按这个顺序规划(后画的盖前画的):
soft(大) → soft(小) → hatch → detail(粗→细) → strand
背景单独:wash × 3 个角度 → soft。线稿最后,落在 06_Line,不 clip。
18 六种笔
✅ 已验证(line 🧪) · 代码 code/strokes.py
每种笔对应参考画里肉眼可见的一类痕迹。表里的 preset 和笔数是 paint 风格(第 20 章)的实测值。
| 笔 | 干什么 | preset | 收尖 | 笔数(paint) |
|---|---|---|---|---|
wash | 平涂铺满,不看误差 | b) Airbrush Soft | 否 | ~12k(只有背景) |
soft | 把色阶之间的台阶揉开 | b) Airbrush Soft | 是 | ~27k |
hatch | 短平行排线 | c) Pencil 1 Sketch (mypaint) | 是 | ~190 |
strand | 沿形体流向的收尖长笔 | c) Pencil 1 Sketch (mypaint) | 是 | ~2.7k |
detail | 多尺度贪心补差 | c) Pencil-3 Large 4B | 否 | ~39k |
line | 色トレス线稿(从轮廓曲线生成) | c) Pencil 1 Sketch (mypaint) | 否 | 0(anime 风格约 1.2k) |
wash 平涂
按固定间距把整块区域来回扫满,三个角度各一遍。用在纯渐变上——误差驱动的笔(soft / detail)在平滑渐变上会留空隙:误差一降到阈值以下就不再起笔,笔与笔之间没铺到的地方露出底色。背景辉光就是这么变成一片黑斑的。
一条扫线要拆成短段逐段取色(seglen=2.2·R)。整条一个颜色的话,一笔从画面中心的亮处扫到角落的暗处只带一个色——背景变成一片交叉错色扫痕,最暗的角落被抬亮((0,0,0) → (6,0,1))。
soft 柔和过渡
大号 airbrush,低透明度(0.22 / 0.18),两个尺度。种子按 R 大小的格子取(每格一个,误差 > T 才起笔),沿等色线生长(亮度梯度的法线),Hertzmann 的收笔判据:画布已经比这支笔更接近参考就停。
笔径跟着色阶格的大小走:R = min(13, max(0.30·等效半径, 2))。固定半径会让小色块被一笔糊掉——一支直径 200px 的笔盖在 300px 宽的阴影上,那块阴影就没了。
hatch 排线
短平行线。方向由整块区域的主梯度定,不是逐点定——逐点定会变成一团乱麻;参考画腮红上那片斜线是一致方向的。主方向用双角平均(atan2(mean sin 2θ, mean cos 2θ)/2)避免 +d / −d 互相抵消。只在 HATCHY 部件的非 Flat 色阶上。
strand 发丝
沿形体流向的收尖长笔,只在 STRANDY 部件。颜色顺着局部明暗推开 contrast=14(发丝要比它盖住的地方更亮或更暗,否则看不见)。
这是「8K 比参考画多出来的 3.3 倍细节」的主要去处:1x 的 origin 里没有这么细的丝,但流向是它给的。点列要狠简化到 ≤ 9 个点——收尖只能逐段 paintLine,34 段和 8 段撑出的是同一条曲线,调用次数差 4 倍。
detail 细节
Hertzmann 1998 的多尺度贪心:从粗到细(R = 6 → 3 → 1.5),每级把参考图按笔刷尺度模糊,在误差 > T 的格子里起笔,沿等色线生长。
opacity 0.6 而不是 1.0:结构已经由矢量色阶层给定了,这一层只该补质感。不透明的粗笔会把色阶结构整片盖掉(第一版就是这样,五官被抹平)。最细停在 R=1.5——继续加细只把 PSNR 从 27.8 抬到 28.0,却要多花两倍的笔。
line 线稿 🧪
从已有的轮廓曲线生成色トレス线稿:沿贝塞尔走,取内侧的颜色压暗(darken=0.72),分段落笔。取内侧而不是线上:线正压在两侧颜色的过渡带上,取到的是混合色,压暗后发灰。
不去源图上找暗脊——那条路(lines(),局部 max_filter − lum > 18)在色トレス图上是失败的:抓到的 18% 主体像素其实是明暗过渡,v3 实测强行描线掉 10 dB。
不逐段收尖:一条连续的轮廓线被切成很多段,每段两头都收尖会画成一串虚线。段与段共用端点,不收尖也接得上;手绘感靠半径的轻微抖动给。
只在 anime 风格开启。跑过两轮,线出来了但还不好看(太粗、太重的一版把荷叶边画成一串香肠)。
落盘前简化点列
点数 = API 调用次数。 生长出来的点列按 step=R 密排,R=1 时逐像素。_mk() 落盘前用 RDP(eps = max(0.35·R, 0.4))简化。理由在第 19 章。
19 预算是内存不是时间
✅ 已验证 · 探针 probes/probe_stroke_budget.py · probe_throughput_tree.py
先量出来的数
单层空白 8192×12288 文档(probe_stroke_budget.py):
| 操作 | 吞吐 |
|---|---|
paintLine(长约 300px) | 450–850 笔/秒,size 8/32/120/400 无差别 |
paintPath(8 点曲线) | 310–390 笔/秒 |
setForeGroundColor + paintLine,不 pump | 605 笔/秒 |
set_color()(带 pump(1))+ paintLine | 71 笔/秒——8.5 倍的税 |
成本与笔刷大小无关:大笔和小笔一样贵。换色换笔径不需要 pump()(连画 24 个色块不跑 event loop,读回来正好 24 种),Session.brush() 就是这条快路径。
真实图层树上的数
上面的数字骗了我一次。真实作业有 307 个带 clip 的节点,第一次跑:
20000 笔 187 笔/秒
60000 笔 121
100000 笔 99 ← 还在掉, Krita RSS 已经 18.6 GB
probe_throughput_tree.py 在同构的树上分开量:
| 画法 | 笔/秒 | 每 12000 笔的 RSS 增量 |
|---|---|---|
单层 · stroke() 逐段 paintLine | 140 | +2.8 GB |
clip 树 · stroke() 逐段 paintLine | 130 | +3.9 GB |
clip 树 · path() 一次 paintPath | 297 | +2.8 GB |
同上 + Document.setBatchmode(True) | 291 | +2.3 GB |
三个结论:
- 图层树只贵 7%(140 → 130),不是主因
- 调用次数是主因:
paintPath一次调用比逐段paintLine快 2.3 倍 - 每次落笔调用约吃 233 KB 撤销栈,
setBatchmode无效,Python API 里没有清撤销栈的接口(只有Krita.setBatchmode/Document.setBatchmode,都不管撤销)
所以预算是内存
第一版规划了 241,162 笔。每笔平均 3.5 段,stroke() 逐段调 → 845,060 次调用 → 60 GB。本机 46 GB,会静默 OOM(Krita 被 OOM killer 干掉,一句话都不留)。
预算:46 GB − 基础 5 GB − 余量 ≈ 11 万次调用。
三件事把调用数压下来:
| 做法 | 效果 |
|---|---|
不收尖的笔走 path()(一次 paintPath) | 调用数 = 笔数,而不是段数 |
| 落盘前 RDP 简化点列 | detail 从 3.4 段/笔降到约 1.2 |
strand 点列砍到 ≤ 9 点 | 从 34.8 段/笔降到 8 |
现在:80,669 笔 → 99,755 次调用 → 撤销栈约 23 GB,217 秒,371 笔/秒。
trace_prepare.py 落盘时会自己算调用次数和内存估计,超过 11 万次打警告。调 ART_DETAIL_T(越大越少笔)来控。
什么时候需要逐段 paintLine
只有需要收尖的笔——pressure 是逐段给的,paintPath 全程一个压力。发丝、排线、线稿(如果收尖)走 stroke(pts, pressures),其余全部 path(pts)。
离屏画布不是出路
Scratchpad 是文档之外的一块画布,本来指望它的落笔不进文档撤销栈。实测它上面
根本没有程序化落笔的 API,只有 fill* 和 loadScratchpadImage——paintEngine /
paintEvent / paintingActive 是 QWidget 自带的,别被名字骗了。省内存这条路不成立
(第 30 章)。
预算仍然只有一个办法:少调用。
旧的坑
layers.md 时代记过 undoStackLimit=3 这个 kritarc 配置。它对 paint* 的撤销栈无效——第一版跑到 18.6 GB 就是在这个配置下。kritarc 里额外的内存相关键实测会直接搞坏绘画,保持最小配置。
20 风格
paint ✅ · anime 🧪 · 油画 📐
「一笔笔画」和画风是正交的
同一套架构(矢量管边,笔画管里面),只是把预算分配到不同的笔上:
| 边 | 色阶 | 主要的笔 | 效果 | |
|---|---|---|---|---|
| 印象派 / 油画 | 笔画自己的边 | 无 | 不透明粗笔,沿梯度法线走 | 方向性涂抹 |
软调厚涂(paint) | 矢量 | 2–5 档 | airbrush + 半透明细节笔 | 水粉感 |
二次元(anime) | 矢量 | 2–5 档 | 收尖发丝 + 线稿 + 排线,几乎不用 airbrush | 赛璐璐 |
本书最早的原型(从黑画布开始、多尺度贪心、不透明笔、直接涂合成图)出来就是标准的印象派油画——顺着形体的方向性涂抹,很明显。那是错的美术风格,被推倒重来。但这条路是通的,代码路径还在 strokes.detail() 里:把 opacity 拉回 1.0、半径放大、关掉矢量色阶层就是。
paint(默认)✅
第 1 章的数字就是它。1:1 下有厚涂味,来自三件事:airbrush + 铅笔的软边半透明堆叠;detail 那约 3.9 万笔本质是 Hertzmann 的印象派算法;参考画的招牌痕迹几乎没有(发丝 2,700 笔全在头发,排线 190 笔,线稿关闭)。
anime 🧪
ART_STYLE=anime ./paint trace。改的是预算分配:
| paint | anime | |
|---|---|---|
soft | 两遍,opacity 0.22 / 0.18 | 只在大块(>12000 px)上一遍,opacity 0.10 |
detail | R = 6 / 3 / 1.5,opacity 0.6 | R = 3 / 1.5,阈值 ×2.2,opacity 0.4 |
strand | 每 55 px 一根 | 每 16 px 一根(9,108 笔) |
hatch | 间距 3 | 间距 2 |
line | 关 | 从轮廓曲线生成(1,174 笔) |
跑过三轮,状态:
| 轮 | 变化 | p99(归一 2508) | 色数 | 问题 |
|---|---|---|---|---|
| 1 | 首版 | 17.6 | 2089 | 线太粗太重(荷叶边一串香肠);soft 被单笔 taper 字段覆盖成全压强,整张糊 |
| 2 | 线细一半、回退 taper 覆盖 | 20.6 | 2389 | 仍软 |
| 3 | 关掉 airbrush | 20.4 | 2375 | 没变——soft 不是元凶 |
第三轮的发现是这一部最后一条线索:发丝和线稿用的 c) Pencil 1 Sketch (mypaint) 是全场最软的笔(过渡宽度 W=3.25,probe_taper 里最大)。它的 aliased 0.0% 是因为软,不是因为利。用最软的笔去画本该最锐的东西。 候选替代是 c) Pencil-3 Large 4B(W=1.25、圆度 0.916、收尖 0.18,都实测过)。没跑——写书时停在这里。
第三轮。发丝有了,线稿有了,但整体软。
两版并存
work/archive/trace-origin-v5.0.0-软调厚涂-*/ 存着 paint 风格的完整成品和 README;work/output/trace-origin-anime/ 是 anime 第三轮。同一套代码,两个 ART_STYLE,可以直接对比。
一条回归
第 1 轮的「整张糊」是我自己造成的:让规划器每一笔的 taper 字段覆盖 KIND 表的默认。soft 从「压感 0.1→1→0.1 的轻扫」变成全压强铺满。规划器的 taper 字段只是它自己模拟时用的形状标记,不是给 Krita 的指令——现在 job_trace.py 里 taper 由笔种决定,单笔不覆盖。
21 笔刷选型表
✅ 已验证 · 探针 probes/probe_taper.py · probe_markshape.py · probe_glaze.py
选笔要看五个独立的性质,任何一个指标都回答不了另一个的问题。这一章先给表,再讲五次选错。
五个性质
| 性质 | 探针 | 量法 | 回答什么 |
|---|---|---|---|
| 收尖比 | probe_taper | 两端 8% 的平均宽度 ÷ 中段 40% | size 跟不跟 pressure 走 |
| 过渡宽度 W | probe_taper | 边缘 10–90% 过渡的像素数 | 边利不利 |
| aliased% | probe_taper | 过渡 < 1px 的边像素比例 | 有没有硬台阶 |
| 圆度 / 长宽比 | probe_markshape | 一个点盖下去的笔痕 | 圆头还是平头 |
| 笔径系数 | probe_glaze / probe_markshape | 实际宽度 ÷ setBrushSize | 传多大才画出想要的宽 |
| 认不认 opacity | probe_glaze | opacity 0.35 画一笔的峰值 alpha | 能不能半透明 |
表
| preset | 收尖 | W | aliased% | 圆度 | 长宽比 | 系数 | opacity | 用途 |
|---|---|---|---|---|---|---|---|---|
b) Airbrush Soft | 0.68 | 3.0 | 18.8 | 0.975 | 1.01 | 0.93 | 认 | 平涂、柔和过渡 |
c) Pencil-3 Large 4B | 0.18 | 1.25 | 13.0 | 0.916 | 1.10 | 0.90 | — | 细节笔;发丝/线稿的候选 |
c) Pencil 1 Sketch (mypaint) | 0.28 | 3.25 | 0.0 | 画不出点 | — | 0.20 | — | 发丝/线稿(现用,太软) |
d) Ink pen (mypaint) | 0.70 | 1.5 | 0.0 | 画不出点 | — | — | — | 线稿候选 |
i) Wet Bristles | 0.52 | 2.75 | 0.0 | 画不出点 | — | — | — | 湿画 |
f) Bristles-5 Flat | 0.95 | 1.5 | 0.0 | 0.316 | 2.52 | 0.44 | 不认 | 平头,别当圆笔用 |
b) Basic-5 Size | 0.25 | 0.0 | 88.6 | 0.994 | 1.01 | 0.99 | — | 硬边,有台阶 |
b) Basic-1 | 0.90 | 0.0 | 94.0 | — | — | — | — | 同上 |
「画不出点」= mypaint 引擎的 preset 靠笔的移动沉积颜料,setBrushSize(120) 盖一个点什么都不留,画长线正常。
三条规律
- mypaint 引擎 aliased ≈ 0%,Krita 原生
b) Basic-*88–95%。 用 Basic 系列画线稿,边天然是硬台阶。 setPaintingOpacity收 0..1,传 255 饱和成全不透明而不是报错;f) Bristles-5 Flat完全无视它。setBrushSize的值 ≠ 画出来的宽度,差最多 4.7 倍。job_trace.py的KIND表按系数换算:size = 2·R·SCALE / 系数。
五次选错
1. 平头当圆头。 f) Bristles-5 Flat 在 probe_taper 里 aliased 0.0% / K90 0.065,全场最干净,被选为细节笔。但那量的是长直线的边,没回答「一小笔盖下去是什么形状」。它长宽比 2.52,短笔全是带锯齿的矩形,8K 成品五官被一片色块糊掉,p99 从 64.0 掉到 15.8。→ probe_markshape.py 就是为它写的。
2. 系数没换算。 规划器算出「直径 20px」,直接 setBrushSize(20),用 Pencil 1 Sketch 画出 4px——整层等于没画,不报错。
3. opacity 传 255。 旧代码里 set_preset(..., opacity=255),结果碰巧是想要的(饱和成 1.0),参数是错的。probe_glaze 之前没人量过量纲。
4. 最软的笔画最锐的线。 Pencil 1 Sketch 的 W=3.25 是候选里最大的。aliased 0.0% 读成了「干净」,其实是「软到没有台阶」。发丝和线稿因此全是糊的(第 20 章第三轮)。
5. 探针几何不匹配。 更早的一次:在长平滑曲线上量出 path() 比 stroke() 干净,换到短线段上结论反过来。探针的几何要匹配实际用法。
系数能不能从根上消掉
不能。Preset(res).toXML() / fromXML() 能程序化改笔刷,改 brush_definition 里的
diameter 也确实生效(Airbrush Soft 90 → 200,画出 184 px),但系数纹丝不动:
把 hfade / vfade 归零之后 Airbrush Soft 还是 0.91、Bristles-5 Flat 还是 0.44。
系数来自笔尖形状本身。而且对预定义笔尖的 preset 改 diameter 完全无效且不报错,
fromXML(toXML()) 对 mypaint preset 有损(14 px → 442 px)。
细节见第 30 章。这张表得留着。
选笔的顺序
- 先
probe_markshape:圆度 > 0.55 且长宽比 < 1.3,否则规划器的圆核模型不成立 - 再看 W:要锐的边选 W 小的;要柔的过渡选 W 大的
- 需要收尖就看收尖比 < 0.6
- 需要半透明就确认它认 opacity
- 记下系数
22 strokes.jsonl 规范
✅ 已验证 · 代码 code/canvas.py 的 _journal_rec() / _JOURNAL_OPS
Session(journal=True) 把落笔与写像素的方法包一层,每调一次写一行 JSON 到 <作品>/strokes.jsonl。它是「这张画是不是真画出来的」的唯一证据,也是回放的输入。
通用字段
每行都有:
| 字段 | 类型 | 含义 |
|---|---|---|
i | int | 序号,从 1 起,连续 |
t | float | 距会话开始的秒数 |
op | str | 操作名,下表之一 |
坐标一律是画布像素(8x 作业就是 8192×12288 的坐标),y 向下。layer 是节点路径(由 build() 建的树)或节点名(否则)。
落笔类(可精确回放)
op | 字段 | 说明 |
|---|---|---|
stroke | layer, pts: [[x,y],…], pressure: [p,…] | 逐段 paintLine,pressure 与 pts 等长,0..1;null = 全程 1.0 |
path | layer, pts | 一次 paintPath,quadTo 平滑,全程压力固定 |
line | layer, pts, width, segments | 分段变宽的浮点路径(线稿专用) |
dab | layer, xy: [x,y], pressure | 一个点 |
ellipse | layer, rect: [x,y,w,h], fill | |
polygon | layer, pts | 自动闭合 |
{"i": 6, "t": 221.045, "op": "stroke", "layer": "BG_Glow",
"pts": [[0.0, 0.0], [292.32, 0.0], [584.56, 0.0]], "pressure": [0.55, 1.0, 0.55]}
{"i": 26612, "t": 239.116, "op": "path", "layer": "Marks",
"pts": [[6448.0, 3976.0], [6406.88, 4000.8]]}
换装类
op | 字段 | 说明 |
|---|---|---|
set_preset | preset, size | 换 brush preset(带 pump) |
set_color | rgb: [r,g,b] | 前景色(带 pump) |
brush | rgb / size / opacity / flow / preset | 快路径,只记真正传了的字段,一条约 40 字节 |
{"i": 4, "t": 221.043, "op": "brush", "preset": "b) Airbrush Soft"}
{"i": 5, "t": 221.044, "op": "brush", "rgb": [0, 0, 0], "size": 584.95}
落笔的颜色和笔径就是它前面最近一条 brush / set_color / set_preset 设的。size 是传给 setBrushSize 的值,不是画出来的宽度(第 21 章的系数)。
写像素类(只记指纹,不记像素)
op | 字段 | 说明 |
|---|---|---|
set_pixels | layer, box: [x,y,w,h], shape, mean: [r,g,b,a], sha1 | 写了什么形状的数组、均值、前 12 位 sha1 |
ground | layer, box, rgb | 铺底色 |
erase | layer, box | 清 alpha |
filter | layer, name, params | 应用 filter |
{"i": 1, "t": 2.426, "op": "set_pixels", "layer": "BG_Base", "box": [],
"shape": [12288, 8192, 4], "mean": [0.0, 0.0, 0.0, 255.0], "sha1": "6629f2f7f361"}
box: [] 表示整幅。这几类不可精确回放——回放时跳过并报告,调用方要自己把数据部分重跑。
缓冲
逐条 flush 在十万笔量级是纯浪费;但进程挂掉时日志是唯一剩下的东西。折中:每 500 条落一次盘,s.flush() / s.stage() / s.final() 时也落盘。进程被 OOM 杀掉时最多丢 500 条。
规模
8 万笔的作业写 209,004 行(每笔约 2.6 行:换装 + 落笔)、18 MB。
一个已知的局限 🧪
layer 字段在没走 build() 的作业里是 node.name()。第五版的树里 73 个色阶组各有一个 Soft 和一个 Marks,名字不唯一,"layer": "Marks" 无法定位回具体哪一层。回放路由会失败(第 24 章)。修法是让 _node_path() 在 index 为空时递归拼全路径,没做。
23 plan.json 规范
✅ 已验证 · 产出 code/trace_prepare.py · 消费 code/job_trace.py
读画一步的产物有两个:plan.json(曲线)和 strokes.jsonl(笔画计划——注意和第 22 章的作画日志同名不同物)。坐标全部是 1x,job_trace.py 乘 scale。
plan.json
{
"w": 1024, "h": 1536, "scale": 8,
"expand": 0.6, "smooth": 0.8, "eps": 0.30,
"silhouette": [ {"pts": [[x,y], …], "closed": true}, … ],
"parts": [
{
"id": 1, "name": "Hair_Back", "px": 65416,
"bbox": [x, y, w, h],
"flat": [r, g, b],
"tiers": [
{"role": "Flat", "rgb": [r,g,b], "rings": [ {"pts": […], "closed": true}, … ]},
{"role": "Shadow1", "rgb": [r,g,b], "rings": […]},
…
]
},
…
]
}
| 字段 | 说明 |
|---|---|
w, h | 1x 尺寸 |
scale | 建议倍数(ART_SCALE),job 可覆盖 |
expand / smooth / eps | 矢量化参数,记下来方便复现 |
silhouette | 主体剪影的环,Subject_Silhouette 矢量层 |
parts[].id | 部件编号,cells = id × 100 + tier |
parts[].tiers | 底→顶,第 0 项永远是 Flat |
rings[].pts | Catmull-Rom 的控制点(不是贝塞尔控制点),job_trace 用 contour.to_path_d() 转 |
rings[].closed | 闭合环 vs 开口曲线(贴着画布边的部件) |
一个环变成 SVG:contour._catmull_closed(pts, scale) → M … C … Z。一个 tier 的所有环拼进一条 <path fill-rule="evenodd">,洞自动挖掉。
笔画计划 strokes.jsonl
一行一笔:
{"kind": "soft", "pts": [[36.5, 0.0], [73.1, 0.0], [109.6, 0.0]],
"R": 34.0, "rgb": [0, 0, 0], "op": 0.9, "taper": false, "part": "BG", "tier": 0}
| 字段 | 说明 |
|---|---|
kind | wash(记为 soft)/ soft / hatch / strand / detail / line |
pts | 1x 坐标,已 RDP 简化 |
R | 1x 半径。落笔时 size = 2·R·scale / 系数 |
rgb | 颜色 |
op | opacity,0..1 |
taper | 规划器模拟时的形状标记,job 不用它(第 20 章的回归) |
part | 部件名,或 BG / Line |
tier | 色阶序号,决定落在哪个 Soft / Marks 层 |
顺序就是落笔顺序。job_trace.py 按 (part, tier) 路由到图层,按 kind 查 KIND 表选 preset 和系数,soft 落 Soft 层,其余落 Marks。
预算信息
trace_prepare.py 落盘时打印:
落笔 API 调用 99755 次 (taper 的逐段 paintLine, 其余一次 paintPath)
预计 336 秒 / 撤销栈约 23.2 GB (实测树内 297 次/秒, 每次 233 KB)
调用数 = Σ(收尖的笔的段数) + (不收尖的笔数)。超过 11 万打警告(第 19 章)。
附带的图
同目录下 parts.png / cells.png / preview_cel.png / preview.png,见第 2 章。
24 回放
🧪 在做 · 代码 code/canvas.py 的 replay() · 命令 ./paint replay <作品>
能回放什么
canvas.replay(jsonl_path, session) 按日志重跑:
op | 回放 |
|---|---|
set_preset / set_color / brush | 重新设置 |
stroke / path / line / dab / ellipse / polygon | 精确重画——坐标和压感都在 |
set_pixels / ground / erase / filter | 跳过并计数——只记了 box 和哈希,没记像素 |
所以回放的前提是:写像素类的数据要另外重跑(本流程只有一次 ground(0,0,0),可以直接补)。
现在的 ./paint replay
只是统计,不重画。 它读 jsonl、按 op 计数、打印「真正的笔画 N 条 / 写像素 M 条」:
trace-origin 共 209004 条操作
按操作: {'set_pixels': 1, 'ground': 1, 'brush': 128333, 'stroke': 2664, 'path': 78005, ...}
其中真正的笔画 80669 条 (可精确回放); 写像素 2 条(只记 box/哈希, 要原始数据在场)
要真的回放,需要一个 job:建同样的树 → replay(jsonl, s) → 和原成品比。没写。
回放前必须修的两件事
- 图层定位。 第 22 章末尾那条:
"layer": "Marks"不唯一。replay()用session.find(e["layer"]),多个匹配会抛错。要么日志记全路径,要么回放时按顺序推断。 - 树的重建。 回放 job 得先建出同一棵树(307 节点、74 个矢量层)。
plan.json有全部曲线,所以可以从它重建;但日志本身不含树结构。
为什么值得做
回放是这本书判据的终极形式:如果一份 jsonl 能在一个空文档上重建出成品,那这张画就是「一笔笔画出来的」,没有任何辩解余地。 现在只做到了「数写像素次数 ≤ 2」(第 25 章),是必要条件,不是充分条件。
25 诚实判据
✅ 已验证
一条硬门
写像素(set_pixels + ground)次数 ≤ 2
只允许背景铺黑(ground 一次,加上 Session 建文档时的一次底层写入)。其余每个像素要么来自矢量层,要么来自落笔。
job_trace.py 收尾自己数一遍并打印:
[check] 落笔 80669 笔 · 写像素 2 次 (只允许 BG_Base 的 1 次)
gauge.py 也读日志再数一遍。两处都过才算。
它拦住过什么
旧路线的成品,看起来是一张 8K 分层画,.kra 有 103 层、181 MB、降采样回 1x 对原图 38.74 dB。所有判据都是绿的。
它的日志:55 条记录,全是 set_pixels,一笔都没落。
它是把 1x 的像素分块升采样后塞进图层。没有这条门,它就通过了。
三个次级判据
在硬门之后,用来判「画得诚实不诚实」:
| 判据 | 来源 | 抓什么 |
|---|---|---|
empty < 44 dB | measure.py | 假分辨率:大画布上没有对应尺度的信息 |
edge_ratio > 0.90 | measure.py | 撒噪声作弊:真实细节贴着边缘长,噪声铺满全图 |
| 阶梯指纹 < 源图 | gauge.py | 放大痕迹:1x 像素格放大后的周期信号 |
empty 单独用是反的(第 33 章):诚实的 4x 重建确实不含 1024 以上的信息,它会给最该抓的 nearest 放大发绿灯。三条一起才拦得住。
判据本身要能被审计
每个数字要能回答「在哪块、怎么量的」:
gauge.py打印归一化宽度和特写窗口的坐标- 每个探针的 docstring 写明判据、判读方法和锚点
stages/保留每一阶段的 PNG,任何一步可以单独重量
没量过的东西写明未验证。 这本书里 📐 和 🧪 的标记就是这条规矩的落地。
26 环境与 harness
✅ 已验证
本机
Fedora 44 · Krita 6.0.2.1(RPM)· PyQt6 · Xvfb · Python 3.14(numpy / scipy / scikit-learn / Pillow / matplotlib)· 46 GB 内存 · RTX 5070 Ti 16 GB(本书没用到 GPU)。
sudo dnf install krita python3-numpy python3-pillow python3-scipy python3-scikit-learn xorg-x11-server-Xvfb
装 RPM 不要装 Flatpak:Flatpak 的沙箱 Python 没有 numpy,路径还会被搬到 ~/.var/app/org.kde.krita/。
必须 Krita 6.0+。Node.paintLine / paintPath 是 6.0 新增的,是整套东西的地基。5.x 要走 tablet event 注入,每一条都要单独调试(坐标标定不可知、笔触只能落在第一层、saveAs 段错误、吞吐只有 200–600 笔/分)。本书代码不兼容 5.x。
harness:一个独立的 HOME
code/setup_fedora.sh <harness目录> <输出目录> 生成 krita-bot/:
krita-bot/
run_job.sh 启动脚本
.config/kritarc 最小配置: 关 autosave
.local/share/krita/pykrita/autopaint/ 一个 Krita 扩展: 启动 3s 后 exec $KRITA_JOB, 跑完退出
*.py code/ 的副本
用独立 HOME 的两个理由:批量作业的配置不污染你自己的 Krita 偏好;rm *autosave*.kra 删不到你真正的恢复文件。
run_job.sh 做的事:
export HOME=<harness> XDG_RUNTIME_DIR=/tmp/xdg-kritabot LANG=C.UTF-8 PYTHONIOENCODING=utf-8
export KRITA_JOB="$(realpath "$1")" # 所有路径在 cd 之前转绝对
cd "$HOME"; rm -f *autosave*.kra # 残留的 autosave 会弹恢复对话框, 卡死之后每一次
xvfb-run -a -s "-screen 0 1600x1200x24" krita --nosplash # 或 --gui 在真实桌面上看
改完 code/ 必须重跑 ./paint setup——harness 跑的是副本。setup_fedora.sh 先 rm -f 再 cp:只 cp 不删的话,改过名的旧模块会原地留下,漏改的 import 继续跑通而不报错。
四个环境级的坑
| 坑 | 症状 | 修 |
|---|---|---|
QT_QPA_PLATFORM=offscreen | Krita 6 启动即段错误 | 必须 Xvfb 或真实 display |
只设 LANG 不设 PYTHONIOENCODING | print("中文") 在作业跑到一半时抛 UnicodeEncodeError | 两个都设;读作业文件也要 encoding='utf-8' |
| 崩溃留下的 autosave | 之后每次运行无错误、无 traceback、持续失败 | 启动前 rm -f *autosave*.kra,EnableAutosave=false |
没有 view 时调 paint* | 段错误,C++ 层,无 Python traceback | Session.__init__ 已 addView;自己建文档必须也建 |
作业的骨架
import os, sys
sys.path.insert(0, os.path.expanduser("~")) # harness 就是 HOME, code/ 的副本在这
from canvas import *
s = Session(W, H, "作品名", journal=True) # document + view + 输出目录
... # 建树、落笔
s.stage("标签") # 存一张 stages/NN-标签.png
s.final() # final.png + final.kra + layers.json
Session 的输出根是 $ART_OUT,./paint 会设好。
自检
./paint check 跑 code/job_selfcheck.py,31 项,每项真跑一遍看像素:版本 ≥ 6 / ground / preset / stroke / path / ellipse / selection 限制与取消 / select_mask 羽化 / erase / filter box / blending / stage 与 final 落盘 / 目录布局 / build 建树 / 嵌套顺序 / GroupLayer 类型 / find 寻址 / by_role / by_label / 重名拒绝 / clipping / 局部 box 读写 / manifest / .kra 往返(层数、clip、blend/opacity/label)。
改了 canvas.py 之后必须全过。
27 落笔
✅ 已验证 · 全部在 Krita 6.0.2.1 + PyQt6 + Xvfb 上实测
Node.paint*
node.paintLine(QPoint(x0, y0), QPoint(x1, y1), pressure0, pressure1)
node.paintPath(QPainterPath(...))
node.paintPolygon([QPointF(...), ...], strokeStyle, fillStyle)
node.paintRectangle(QRectF(...), strokeStyle, fillStyle)
node.paintEllipse(QRectF(...), strokeStyle, fillStyle)
node.paintAbility() # "PAINT" / "VECTOR" / "UNPAINTABLE"
用当前 brush preset + 当前前景色(view.setCurrentBrushPreset / setForeGroundColor / setBrushSize / setPaintingOpacity)。
- 画在你传的那个 node 上,与 active node 无关。
setActiveNode(L2)后n1.paintLine(...)→ n1 变 5438 px,L2 零变化。 - pressure 真的驱动 brush engine:两头收尖的 pressure 曲线,粗细剖面 14 → 29 → 29 → 25 px。段间接缝不可见,可以逐段
paintLine做 taper。 paintLine收QPoint(整数),paintPath收QPointF(浮点)。canvas.line()用分段paintPath同时拿到浮点精度和粗细变化。fillStyle默认"None",要填充传"ForegroundColor"。填充是二值的,不抗锯齿(边界带偏差 0.498)。- group 的
paintAbility()是"UNPAINTABLE"。
canvas.Session 的封装
| 方法 | 做什么 |
|---|---|
stroke(pts, pressures, node) | 逐段 paintLine,pressure 线性过渡 |
path(pts, node, smooth=True) | 一次 paintPath,quadTo 平滑 |
line(pts, width, node, segments=3) | 分 3 段 paintPath,每段换笔径。段数不是越多越好(5 段 aliased 2.6%,12 段 14.5%) |
dab(x, y, pressure, node) | 一个点 |
brush(color, size, opacity, flow, preset) | 不 pump 的快路径,相同值跳过 |
set_preset(name, size, opacity, flow) | 子串匹配,找不到打印警告;带 pump |
set_color(r, g, b) | 带 pump |
stroke() 与 path() 的选择要看几何:长平滑曲线上 path() 干净 23–47%;短线段上反过来(stroke() K90 0.032 最好,path() 0.0437 最差),点少时 quadTo 会改变形状。
吞吐
8192×12288,单层空白文档(probes/probe_stroke_budget.py):
| 操作 | 吞吐 |
|---|---|
paintLine(~300px) | 450–850 笔/秒,与 size 无关 |
paintPath(8 点) | 310–390 笔/秒 |
不 pump 换色 + paintLine | 605 笔/秒 |
带 pump 换色 + paintLine | 71 笔/秒 |
真实图层树上(307 节点):path() 297 笔/秒,stroke() 逐段 130。每次调用约 233 KB 撤销栈,清不掉——预算是内存(第 19 章)。
8K 上填一块边缘平滑的色块
四条路同题对比(probes/probe_fill.py,判据是边界带里 |覆盖率−解析真值|,完美约 0.02–0.08,二值约 0.5):
| 路子 | 边界带偏差 | 耗时 |
|---|---|---|
矢量层 addShapesFromSvg | 0.0828 | 1.5s |
select_mask(抗锯齿 mask)+ 42 笔 | 0.3305 | 6.4s |
select_mask(二值)+ 42 笔 | 0.3668 | 1.4s |
paintPolygon(fill) | 0.4981 | 0.8s |
| 不加约束大笔硬涂 | 0.4361 | — |
只有矢量层接近解析级。select_mask 留不住分数覆盖率,paintPolygon 是二值填充。
擦除
「用笔擦」做不到:view.setEraserMode(True)、setCurrentBlendingMode("erase")、node.setBlendingMode("erase") 对 paintLine 全部无效(擦掉 0 px)。唯一有效的是清 alpha(s.erase(box=, mask=))。
这条判错过一次:一次测量显示 erase 擦掉了 2198 px。那是读到过期像素的伪影——换成读到稳定再判,三种齐刷刷是 0。
异步读数
image 操作是异步的。waitForDone() + refreshProjection() + pump 不保证 pixelData() 已更新。判「有没有生效」要循环读到连续两次相同(s.settle() / s.stable())。读一次就下结论可能得到相反答案。
28 图层
✅ 已验证
建
n = doc.createNode(name, "paintlayer"); parent.addChildNode(n, above)
g = doc.createGroupLayer(name) # 必须用这个, createNode(..., "grouplayer") 拿不到 setPassThroughMode
v = doc.createVectorLayer(name)
doc.createCloneLayer / createFilterLayer / createFillLayer / createTransformMask / createSelectionMask / createColorizeMask
addChildNode(child, None)放到该 parent 子栈的顶部;above=X插在 X 之上;childNodes()返回底→顶- 这些 layer 可以直接
paintLine,不用先写像素再差分 - 嵌套 4 级实测正常;但 group 是独立合成的,每级多存一份 tile,深度别超过 3–4
- reparent(
removeChildNode+addChildNode)可行
canvas.build(spec) 用 L() / G() 声明式建树,列表顺序 = 底→顶,建完回读 childNodes() 与 spec 比对,重名抛错。s.find("04_Color/Skin/Skin_Shadow") / s.find("Skin_Shadow") / by_role / by_label 寻址。
属性
setBlendingMode / setOpacity / setColorLabel / setVisible / setLocked / setAlphaLocked / setInheritAlpha 生效(后两者实测:锁 alpha 后空白处再画一笔像素数不变;inherit alpha 见第 13 章)。
setBlendingMode 完全不校验:传 "xyzzy" 原样读回,按 normal 合成。读回值相等不能证明模式有效。在 base(128,64,192)+top(100,200,80) 上实测确认改变合成结果的 39 个:
multiply screen overlay soft_light soft_light_svg hard_light hard overlay
dodge burn linear_dodge linear_burn vivid_light pin_light
add subtract divide inverse_subtract exclusion negation
darken lighten darker color
hue saturation color luminize lightness
grain_merge grain_extract allanon parallel geometric_mean
reflect glow freeze heat behind greater erase
darker color / hard overlay 中间是空格。linear_light hard_mix difference lighter color copy dissolve alphadarken 没能确认(合成与 normal 相同)。
group 的两个陷阱
passthrough 让 clip 失效(第 13 章)。
不透传时,组内的 multiply 不跟组下面的画面相乘。 09_Post 里放一个 multiply 暗角,纯黑背景被抬成 (6,6,6)。组内合成是先把兄弟层合到一起再整组盖到下面——暗角在跟同组的透明层相乘,得到一团灰。单层探针查不出来(单独测 multiply 叠纯黑是 (0,0,0))。必须在真实的组结构里量。 修法是给这个组 passthrough=True(它没有 clip 层)。
读
| 方法 | 读什么 |
|---|---|
node.pixelData(x,y,w,h) | 该层自己的像素,BGRA |
node.projectionPixelData(x,y,w,h) | 该 node 含子节点的合成 |
doc.projection / s.composite() | 整个文档的合成 |
- group 的
pixelData是空数组(cannot reshape array of size 0),要projectionPixelData - 矢量层读不到
pixelData,只能看合成 - 大画布多图层时一定传 box:2000×3000 一层 24 MB,上百层几个 GB
矢量层
vl = doc.createVectorLayer("v"); parent.addChildNode(vl, None)
vl.addShapesFromSvg('<svg xmlns="http://www.w3.org/2000/svg"><path d="…" fill="#…"/></svg>')
probes/probe_vector.py 5/5:进 composite(跨度与 SVG 完全一致);贝塞尔 C/Q 能渲染;坐标 1:1 像素,8192×12288 上 (7800,11800) 偏移 (+0,+0);.kra 往返保真;单条跨 7700px 正常。
vl.toSvg() 反向导出。node.save() 对矢量层段错误,进程直接死——不需要它,矢量本来就进 composite。
建完之后还能改:shapes() / setZIndex / setTransformation / createGroupShape /
setAntialiased 全部生效。但几何单位是「点 pt」不是像素(300 DPI 下 1 px = 0.24 pt),
拿像素坐标问 shapeAtPosition 会得到 None。详见第 30 章。
15 个矢量层 / 5000 控制点约 12s;addShapesFromSvg 之后 pump(6, 0.05)。
Node 级变换
move / rotateNode / scaleNode / shearNode / cropNode 在 group 里都生效,
clip(inherit alpha)关系跟着走。非破坏的版本是 TransformMask——读它的出厂
toXML() 骨架再改,手写的 transform_params 会被拒。见第 30 章。
transparency mask
挂在目标图层下才生效,挂 rootNode 是孤儿。mask.setSelection(sel) 后精确裁剪。可编辑,比 inherit alpha 更适合交给画师。
.kra
doc.saveAs("x.kra") 正常(5.x 会段错误)。往返保真:层数、inheritAlpha、blend / opacity / label、矢量层。doc.exportImage(path, InfoObject()) 出 PNG。存盘前 doc.setBatchmode(True)。
29 选区、filter、擦除、变换
✅ 已验证
选区
from krita import Selection
sel = Selection()
sel.select(x, y, w, h, 255) # 矩形
sel.setPixelData(mask.tobytes(), 0, 0, w, h) # 任意形状, numpy 造
sel.feather(15); sel.grow(20, 20); sel.shrink(...); sel.border(5, 5)
sel.invert(); sel.smooth(); sel.dilate(); sel.erode()
sel.add(o); sel.subtract(o); sel.intersect(o); sel.symmetricdifference(o)
doc.setSelection(sel)
- 选区限制
paintLine:矩形 x∈[50,200) → 笔触落在 [50,199];圆形 +feather(15)→ 向外羽化 15。全部精确。 - 不限制
filter.apply():半幅选区下跑 gaussian blur,整幅都被处理。 但createFilterMask/createFilterLayer的 Selection 重载是受选区限制的, 而且带羽化、非破坏(第 30 章)。 - 留不住分数覆盖率:喂 0..255 的抗锯齿 mask,画出来只从 aliased 100% 改善到 25.5%(边界带偏差 0.367 → 0.331,矢量层是 0.083)。要平滑的边别指望选区。
- 8192×12288 上建一次约 1.1s(整幅
setPixelData的成本)。 - 取消选区只有一种写法:
doc.setSelection(None)。setSelection(Selection())和sel.clear()是「全不选」,之后每一笔静默画出 0 px,不报错。症状是「笔刷突然全都不工作了」。 sel.selectAll()需要(node, value)两个参数。
filter
f = Krita.instance().filter("gaussian blur")
c = f.configuration(); c.setProperty("horizRadius", 25.0); c.setProperty("vertRadius", 25.0)
f.setConfiguration(c)
f.apply(node, x, y, w, h) # 成功也返回 None, 自己查像素
52 个:asc-cdl, autocontrast, blur, burn, colorbalance, colortoalpha, colortransfer, crosschannel, desaturate, dodge, edge detection, emboss(+5), fastcoloroverlay, gaussian blur, gaussianhighpass, gaussiannoisereducer, gradientmap, halftone, height to normal, hsvadjustment, indexcolors, invert, lens blur, levels, maximize, mean removal, minimize, motion blur, noise, normalize, oilpaint, palettize, perchannel, phongbumpmap, pixelize, posterize, propagatecolors, raindrops, randompick, resettransparent, roundcorners, sharpen, smalltiles, threshold, unsharp, wave, waveletnoisereducer。
范围只受 box 控制,刀切硬边——量模糊强度剖面的 0.9→0.1 过渡带是 7.2 px,
正好等于判据本身的地板(理想台阶经 9 宽平滑之后的读数)。
v5.1.0 更正:原来这里写着「要羽化的局部 filter 只能整幅跑一遍再用 numpy 按 mask 混回去」。那是
apply()的结论。doc.createFilterMask(name, f, sel)收Selection,sel.feather(40)给出 59.9 px 的过渡带、feather(120)给出 177.7 px,而且是非破坏的。 见第 30 章。
擦除
只能清 alpha:s.erase(box=, mask=)。三种「用笔擦」全部无效(第 27 章)。
变换
Node 级(都改变 bounds):scaleNode(QPointF(cx,cy), w, h, "Bicubic")(QPointF,传 QPoint 会 TypeError)、rotateNode(rad)、shearNode、cropNode、move。
Document 级:scaleImage(w,h,xres,yres,strategy)、rotateImage、shearImage、crop、resizeImage、flatten。策略 filterStrategies() → Bell, Bicubic, BSpline, Hermite, Mitchell, Bilinear, Lanczos3, NearestNeighbor。
scaleImage 的 strategy 不校验:传 "xyzzy" 尺寸照改,插值方式未知。之后 Session.w/h 不会自动同步,要回读 doc.width()。
Node 级变换在 group 里对 clip 的实测、以及它们的非破坏版本 TransformMask,
见第 30 章。
资源
Krita.instance().resources(类型):preset 144 / brush 84 / gradient 19 / pattern 112 / palette 13 / workspace 9。其它字符串返回空。createFillLayer(name, "gradient"|"pattern", InfoObject(), sel) 可铺成一层;"simplenoise" 段错误。
Krita.instance().actions() 688 个,任何 GUI 菜单项都能 .trigger()——API 没覆盖的功能的后门,无返回值。
gradient 和 pattern 在 Node 上没有「铺一层」的 API,要拿它们的光栅结果只能走
Scratchpad.fillGradient / fillPattern(第 30 章)。
其它
- wrap around:
view.canvas().setWrapAroundMode(True)实测影响绘制,越界的笔触卷回另一侧。 - 动画:能读、能设时间轴参数、能导入序列帧,没有创建 keyframe 的 API。
- 通道顺序:
ManagedColor.setComponents和Node.setPixelData/pixelData全是 BGRA,前者 0..1 浮点。canvas对外一律 RGB。
30 非破坏、生成与离屏
✅ 已验证 · Krita 6.0.2.1 + PyQt6 + Xvfb,六个探针(附录 C)
前面四章讲的是「怎么把像素写进图层」。这一章讲的是另外三类能力,它们的共同点是 不写像素:非破坏的效果层、Krita 自己算出来的内容、以及文档之外的一块画布。
书里第 29 章原来写着「filter 不受选区限制」——那只是 apply() 的结论。这一章的
第一节就是来改写它的。
非破坏的局部 filter
filter.apply(node, x, y, w, h) 是破坏性的,范围只受 box 控制,刀切硬边。
但 createFilterMask / createFilterLayer 的签名里直接收 Selection:
f = Krita.instance().filter("gaussian blur")
c = f.configuration(); c.setProperty("horizRadius", 25.0); c.setProperty("vertRadius", 25.0)
f.setConfiguration(c)
sel = Selection(); sel.select(x, y, w, h, 255); sel.feather(40)
m = doc.createFilterMask("FM", f, sel)
target.addChildNode(m, None) # 挂在【目标图层下面】, 不是 rootNode
判据是模糊强度剖面的 0.9→0.1 过渡带宽度(理想台阶经平滑后是 7.2 px,这是地板):
| 路 | 过渡带 | 耗时 | 破坏性 |
|---|---|---|---|
filter.apply(node, box) | 7.2 px(=地板,刀切) | 4.4s | 是 |
createFilterMask(f, sel.feather(40)) | 59.9 px | 4.1s | 否(源层逐字节没动) |
同上,feather(120) | 177.7 px | 7.6s | 否 |
createFilterLayer(f, sel.feather(40)) | 59.9 px | 3.9s | 否 |
createFilterMask(f, node) | 无——整幅都被处理 | 9.3s | 否 |
- 过渡带 ≈ 1.5 × feather(59.9/40 = 1.50,177.7/120 = 1.48)。
createFilterMask(name, filter, node)那个重载建得出来但不限制范围,源 node 可见、放在根节点也一样。要局部就用 Selection 重载。- 往返:
.kra里 filtermask / filterlayer 都存得住,重开后过渡带一模一样。 - 量它必须用
s.projection(group)。mask 是非破坏的,node.pixelData()一个 字节都不会变,量源层会得到「完全没生效」的反向结论。
ColorizeMask:线稿 + 几个色点 → flats
Krita 自带 lazy-brush。无头下跑得通,但调用顺序里有两步在 api.kde.org 的方法表里 根本没有,只在源码里:
m = doc.createColorizeMask("CM")
lineart.addChildNode(m, None)
m.setEditKeyStrokes(True)
m.initializeKeyStrokeColors([ManagedColor, ...], -1) # ← 少了这步, 下面全返回 False
cc = m.keyStrokesColors() # ← 用它返回的对象去 set
m.setLimitToDeviceBounds(True) # 否则背景色往整幅 8K 漫开
m.setKeyStrokePixelData(buf, cc[i], x, y, w, h) # buf 是 1 字节/像素, 返回 bool
m.setEditKeyStrokes(False) # ← 不关掉的话输出是 50% 混白的预览
m.updateMask(True); m.setShowOutput(True)
setKeyStrokePixelData 只往已存在的同色 keystroke 里写,找不到就返回 False
——它是老实的,不静默。颜色比较走 KoColor 精确相等,所以要用 keyStrokesColors()
返回的那些对象,别自己另造一个「一样的」颜色。
在 8192×12288 上、一条解析闭曲线被两条横线切成三块、线宽 6 px:
| 判据 | 结果 |
|---|---|
| 三个区各自的面积 / 解析面积 | 0.98 / 0.98 / 0.98(差的 2% 就是线宽) |
| 未归类像素 | 0.3% |
| 耗时 / 内存 | 12.3s,RSS +1100 MB |
| 边界带偏差 | 0.4981 = 二值硬边 |
.kra 往返 | colorizemask 存得住 |
区域归属很准,边很糟。 0.498 和 paintPolygon 同档,离矢量层的 0.083 差一个数量级
(第 27 章的四条路对比)。所以它的位置是「切 flats」,不是「出成品边」。
量它的时候判据自己也会骗人:用「离哪个色点最近」的硬分类去算覆盖率,边界带偏差 恒等于 0.5——那量的是我的分类器是二值的,不是 colorize 的边是二值的。换成 把像素投影到「背景色→区色」线段上的连续覆盖率,才是 0.4981 这个可信的数。
Preset:程序化改笔刷
from krita import Preset
p = Preset(res["b) Airbrush Soft"]) # 收一个 Resource
xml = p.toXML() # 1.7k - 22.7k 字符
p.fromXML(xml2)
| preset | xml | setBrushSize(90) 实际 | 系数 | fromXML(toXML()) |
|---|---|---|---|---|
| b) Basic-1 | 1689 | 92 px | 1.02 | 无损 |
| f) Bristles-5 Flat | 16516 | 40 px | 0.44 | 无损 |
| b) Airbrush Soft | 9552 | 82 px | 0.91 | 无损 |
| c) Pencil 1 Sketch (mypaint) | 22658 | 14 px | 0.16 | 有损:14 → 442 px(×31),不报错 |
- 改
brush_definition里的diameter生效:Airbrush Soft 90 → 200,之后setCurrentBrushPreset会把view.brushSize()自动带到 200,画出 184 px。 - 但对预定义笔尖的 preset(Bristles-5 Flat)改
diameter完全无效,不报错。 - 把
hfade/vfade归零,两个 preset 的系数纹丝不动(0.91 / 0.44)。 系数来自笔尖形状本身,第 21 章的宽度系数表作废不了。 fromXML的改动在进程内全局生效(重新resources("preset")拿到的是改过的), 但不写盘(paintoppresets/0 新增 0 改动),fromXML(原 xml)能还原。
Scratchpad:文档之外的一块画布
原来的指望是拿它绕开撤销栈(第 19 章:每次 paint* 约 233 KB
且清不掉)。这条路不成立——Scratchpad 上没有任何程序化落笔的 API,只有 fill*
和 loadScratchpadImage。paintEngine / paintEvent / paintingActive 是 QWidget
自带的,不要被它们骗了。
但它有另一个价值:这是拿到 19 个 gradient 和 112 个 pattern 光栅结果的唯一一条路
——Node 上没有「铺渐变」的 API。
from krita import Scratchpad
sp = Scratchpad(view, QColor(0, 0, 0), None)
sp.show(); sp.resize(1200, 800) # 顺序不能反, 见下
sp.setModeManually(False); sp.setMode("painting")
view.setCurrentGradient(Krita.instance().resources("gradient")[name])
sp.fillGradient(QPoint(0, 0), QPoint(1200, 0), "linear", "none", False, False)
img = sp.copyScratchpadImageData() # QImage
- 无头下能构造,不崩。
resize()/setGeometry()静默无效:出厂 640×480,调完还是 640×480。 必须先show()再resize()才变成 1180×780(还差一圈边框)。fillForeground填出的颜色和setForeGroundColor精确一致。fillGradient/fillPattern真的出东西(渐变 32 色;pattern 纹理梯度均值 54.9)。copyScratchpadImageData()→ numpy →setPixelData无损(最大逐通道差 0)。- 内存:200 次
fillForeground= 2.9 KB/次,同画布 200 次paintLine= 53.5 KB/次。
Shape:矢量层建完之后还能改
shape 的几何单位是「点 pt」,不是像素。 300 DPI 下 1 px = 0.24 pt。
拿像素坐标去问 shapeAtPosition 会得到 None,很容易被读成「这个功能不存在」。
PT = 72.0 / doc.resolution()
v.shapes(); v.shapesInRect(QRectF(...)); v.shapeAtPosition(QPointF(x * PT, y * PT))
sh.setTransformation(T0 * QTransform().translate(dx * PT, dy * PT)) # 见下
| 操作 | 结果 |
|---|---|
shapes() / shapesInRect / shapeAtPosition | 用 pt 正常;用 px 返回 None / 0 个 |
setTransformation(T0 * translate) | boundingBox 精确位移,光栅对「挪过的解析真值」偏差 0.0861 |
setTransformation(T0) | 精确还原(偏差回到 0.0891) |
setPosition(P0 + delta) | 与上等价 |
setZIndex | 生效,合成里的上下关系真的换了 |
createGroupShape(name, shapes) | 生效,2 个 shape → 1 个 groupshape |
setAntialiased(False / True) | 边界带偏差 0.2624 / 0.0891 —— 开关真的改边 |
setTransformation是替换不是叠加。 直接喂QTransform().translate(...)会把 shape 原本的摆放一起冲掉;而且QTransform.translate是在局部坐标里先平移, 要在父坐标里平移得写成T0 * QTransform().translate(...)。- 组的投影在形状挪走之后留残影:同一个大框里的不透明像素从 2,584,626 涨到
2,858,295。所以不要用重心判位移,会被残影拖着走。判位移要用
boundingBox()(对象读,精确)或者对着「挪过的解析真值」量偏差。
Node 级变换与 TransformMask
Node 级变换在 group 里都生效,clip(inherit alpha)关系跟着走。 一个 800×800 的 Flat + 一个裁到它的 Marks,合成不透明像素:
| 操作 | 合成不透明 px | Flat bounds |
|---|---|---|
| (原始) | 640,000 | 800×800 |
move(+400, +300) | 640,000 | 移到 (1800,1700) |
rotateNode(0.3) | 641,516 | 1002×1002 |
scaleNode(QPointF(cx,cy), 1200, 1200, "Bicubic") | 921,221 | 1199×1200 |
cropNode(x, y, 400, 400) | 2,983 | 43×138 |
TransformMask 是这些的非破坏版本,而且和 Preset 是同一个套路
——读出厂骨架再改,不要手写:
tm = doc.createTransformMask("TM")
target.addChildNode(tm, None)
xml = tm.toXML() # 926 字符的出厂骨架
xml = 改 xml 里的 transformedCenter # 手写一份 transform_params -> fromXML 返回 False
tm.fromXML(xml) # -> True
tm.finalAffineTransform()
改 transformedCenter 之后重心精确位移 (+500, +300),源层像素一个字节没动,
.kra 往返 transformmask 还在。
交付的 .kra 还能带什么
| 东西 | 建 | 对合成的影响 | .kra 往返 |
|---|---|---|---|
createSelectionMask + setSelection | ✅ | 逐通道差 0(不改画面,只是随文件带走) | ✅ |
createFileLayer(name, path, method) | ✅ None / ToImagePPI / ToImageSize 三种都行 | 正常渲染 | ✅ |
createCloneLayer(name, source) | ✅ | 与源逐通道差 0,改源会跟着变 | ✅ |
Palette + Swatch + addEntry | ✅ 内存里加得进去 | — | ❌ save() 返回 False,不写盘;也没有 Document 级 palette API |
setLayerStyleFromAsl | ❌ | 0 | 见第 31 章 |
前三个是净赚:给接手的画师带上每个部件的现成选区、一张源图对照层、重复部件的克隆。
Swatch 的方法是 color / id / isValid / name / setColor / setId / setName / setSpotColor / spotColor。
31 不能用 / 会崩 / 静默失效
✅ 已验证
这一章是整本书最值钱的表之一。Krita 的大量 setter 不校验参数、不报错、静默失效,而且有几个会直接崩掉进程(C++ 层,没有 Python traceback)。
会崩
| 东西 | 行为 |
|---|---|
没有 view 时的 paint* | 段错误,无 traceback |
QT_QPA_PLATFORM=offscreen | Krita 6 启动即段错误,必须 Xvfb |
node.save() 对 vectorlayer | 段错误,进程直接死。用 composite 取像素 |
createFillLayer(..., "simplenoise", ...) | 段错误("gradient" / "pattern" 正常) |
| 撤销栈吃光内存 | OOM killer 干掉进程,一句话不留。每次 paint* 约 233 KB,清不掉 |
v5.1.0 的六个探针(FilterMask / ColorizeMask / Preset XML / Scratchpad / 交付 / Shape)没有新增会崩的东西——失败的那几样都是老实返回 False 或静默无效。
静默失效
| 东西 | 行为 | 怎么发现 |
|---|---|---|
doc.setSelection(Selection()) / sel.clear() | 「全不选」,之后每笔画出 0 px | 笔刷「突然全不工作」 |
setBlendingMode("任意字符串") | 原样读回,按 normal 合成 | 量合成结果 |
scaleImage(..., strategy="xyzzy") | 尺寸照改,插值未知 | 先 filterStrategies() 校验 |
setPaintingOpacity(255) | 饱和成 1.0 | 量峰值 alpha |
f) Bristles-5 Flat + opacity | 完全无视 | 同上 |
| mypaint preset 画 1px 点 | 什么都不留 | 数像素 |
setBrushSize(n) | 画出来 0.2n–0.93n | 量宽度 |
| transparency mask 挂 rootNode | 孤儿节点,无影响 | 挂目标图层下 |
group passthrough=True + clip | clip 失效 | 看 alpha |
选区对 filter.apply() | 不限制 | 整幅都变了 |
| 崩溃留下的 autosave | 之后每次运行卡住 | rm -f *autosave*.kra |
| 相对路径的环境变量 | cd 之后失效 | 先 realpath |
Document.setBatchmode(True) 对撤销栈 | 无效(291 vs 297 笔/秒,RSS 照涨) | — |
kritarc 的 undoStackLimit 对 paint* | 无效 | RSS 照涨到 18.6 GB |
filter.apply() 返回值 | 成功也 None | 自己查像素 |
pixelData() 读到过期数据 | 读一次结论可能相反 | s.settle() 读到稳定 |
createFilterMask(name, f, **node**) | 不限制范围,整幅都处理 | 量剖面,没有过渡带 |
Scratchpad.resize() / setGeometry() | 无效,一直是 640×480 | 先 show() 再 resize() |
改预定义笔尖 preset 的 diameter | 无效 | 量笔宽 |
Preset.fromXML(toXML()) 对 mypaint | 有损:14 px → 442 px | 往返后量笔宽 |
Shape.setTransformation | 是替换不是叠加,冲掉原摆放 | 写 T0 * QTransform().translate(...) |
| shape 的几何单位是 pt | 拿 px 问 shapeAtPosition 得 None | PT = 72 / doc.resolution() |
| 组的投影在 shape 挪走后留残影 | 大框重心被拖偏 | 判位移用 boundingBox() 或解析真值 |
ColorizeMask 忘了 setEditKeyStrokes(False) | 输出是 50% 混白的编辑预览 | 量色块颜色 |
不存在 / 没有
| 东西 | 替代 |
|---|---|
Krita.instance().createInfoObject() | from krita import InfoObject |
Node.setFilter() | FilterLayer 上有 |
| 创建 keyframe 的 API | 一帧一个 document 导 PNG 序列 |
| 清撤销栈的 API | 少调用(第 19 章) |
createNode(..., "grouplayer") 的 setPassThroughMode | 用 createGroupLayer |
node.setLayerStyleFromAsl | 喂不进去:手写 PSD 描述符 XML 返回 False(XML 合法,是解析出的样式数 ≠ 1),layerStyleToAsl() 在没设样式时返回空串拿不到骨架,本机 asl/、layerstyles/ 和四个 bundle 里一个 .asl 都没有 |
Scratchpad 上的落笔 | 没有。只有 fill* / loadScratchpadImage;paintEngine / paintEvent / paintingActive 是 QWidget 自带的 |
Palette.save() | 返回 False,不写盘;也没有 Document 级 palette API,进不了 .kra |
手写的 transform_params | TransformMask.fromXML 返回 False。读出厂 toXML() 骨架再改就 True |
ColorizeMask.initializeKeyStrokeColors 的文档 | api.kde.org 的方法表里没有它,只有源码有。少了这步 setKeyStrokePixelData 全返回 False |
判错过的
这些曾经被记成「不能用」,后来发现是测量错了:
| 曾记 | 真相 |
|---|---|
setInheritAlpha 不生效(v1.0.0) | 把 base/top 建在根节点下,根下有全不透明 Background,累积 alpha 处处 255。放进 group 就生效 |
| transparency mask 无影响(v1.0.0) | 挂错了位置 |
| erase blending mode 擦掉 2198 px | 读到过期像素。读到稳定后是 0 |
| filter 不受选区限制(v5.0.0) | 那是 apply() 的结论。createFilterMask(name, f, **Selection**) 受选区限制且带羽化,过渡带 ≈ 1.5 × feather(第 30 章) |
| ColorizeMask「算不出东西」 | 少调了 initializeKeyStrokeColors,keystroke 根本没建;补上之后三个区面积吻合 0.98 |
| shape「挪不到位」 | 量的框比形状小,挪出框外被截断;换成对着「挪过的解析真值」量,偏差 0.0861 |
它们的共同点:判据看错了对象。第 34 章。
32 gauge.py
✅ 已验证 · 代码 code/gauge.py · ./paint trace 结束时自动打印
它回答什么
「这张成品像不像人画的」。标尺从真人画作上量出来(work/example ref/ 的两张 2508px 插画),门槛以源图为锚。
用法
python3 code/gauge.py <成品.png> # 用默认参考画算标尺
python3 code/gauge.py <成品.png> ref1.jpg ref2.jpg
python3 code/gauge.py --ref work/example\ ref/*.jpg # 只打标尺
ART_SRC=xxx.png python3 code/gauge.py <成品.png> # 换源图锚点
输出:
== 成品 final.png (量之前归一到 2508px 宽) ==
源图 41.6 色数 2900 阶梯 6.19 <- 源图锚点
梯度 p99 36.9 OK 源图的 89% (门槛 85%)
8级量化色数 1840 OK 源图的 63% (门槛 55%)
8px 阶梯能量 0.78 OK 要低于源图 6.19 (放大过就会高)
── 参考画区间(仅供参照, 不是门槛): p99 59.4–74.6 色数 2504–2557 阶梯 0.97–1.03
低频 likeness 33.57 dB
全图最暗像素 (0, 0, 0)
作画日志 落笔 80669 笔 · 写像素 2 次 OK
三个画面指标
在 1:1 特写(800×600)上量:
| 指标 | 量法 | 抓什么 |
|---|---|---|
| 梯度 p99 | 亮度梯度模的 99 分位 | 边利不利 |
| 8 级量化色数 | RGB 各除 8 取整后的唯一色数 | 层次是明确的几档还是连续糊 |
| 8px 阶梯能量 | 抽行做二阶差分,FFT 后 8px 周期附近的能量 ÷ 中位 | 1x 像素格放大 8 倍留下的指纹 |
三条设计决定
归一到 2508px 再量。 梯度 p99 随分辨率变(同一张 8K 成品 @8192 → 33.2,@2508 → 42.2,@1024 → 56.2)。load() 读进来先 resize 到 NORM_W=2508。
门槛以源图为锚。 参考画 p99 59.4–74.6,源图自己 41.6。门槛写成源图的百分比(p99 ≥ 85%,色数 ≥ 55%,阶梯 < 源图)。参考画区间只打印。
特写窗口自动选。 pick_crops() 按高频能量取 3 块互不重叠的 800×600,跳过能量低于最高的 5% 的空白块。中位数作为该指标的值。
一个 bug
pick_crops() 的不重叠判断曾经写成 and:「y 差够远 且 x 差够远」才算不重叠,于是正右方那块也被当成重叠排除,候选被砍到只剩边角,最后捡到纯黑背景块(均亮 11.4 / p99 0.8),判据读出 14.5 而实际画面是 43.5。改成 or + 空白块过滤后才对。
其它两项
- 低频 likeness:两边降到 256 宽再算 PSNR。临摹只该在这个尺度上像。
- 作画日志:读同目录
strokes.jsonl,数落笔与写像素(第 25 章)。
换参考画
换一套 --ref 重跑就是新标尺,不用手改判据。参考画应该是满构图的(本书的两张是)——有大片空白背景的参考画会让 pick_crops 的自动取景失去意义。
33 measure.py
✅ 已验证 · 代码 code/measure.py · 不依赖 Krita
gauge.py 看整张画像不像人画的;measure.py 看一条边干不干净。探针和自检都用它。
指标
measure.metrics(img, mask=None):
| 指标 | 量法 | 含义 |
|---|---|---|
K90 | 边缘像素 level-set 曲率 ` | div(∇I/ |
W | 沿法线量 10–90% 过渡宽度(px)的中位 | 边有多软 |
aliased | W < 1.0 的边像素比例 | 硬台阶 |
mushy | W > 2.5 的比例 | 糊 |
empty | 假分辨率判据(dB) | 大画布上有没有对应尺度的信息 |
edge_ratio(img):高频能量落在梯度脊 3px 内的比例。
三条实现细节
- 边像素必须加脊约束(梯度局部最大 + 强度门槛 + 法线剖面对比度门槛),否则柔和渐变会被当成边,指标失去意义。
- W 的采样走原图,不走平滑图:在平滑图上量等于把硬边也抹成 σ 宽的过渡,硬边和抗锯齿边量出一样的 1.50。平滑图只用来定法线方向。
- 边像素 < 30 个时返回
None,不硬给数。
锚点
| K90 | 说明 | |
|---|---|---|
| 解析抗锯齿(真值) | 0.037–0.064 | 下限 |
| 矢量层渲染 | 0.033–0.042 | 比真值还光滑 |
coverage(smooth=1.2) 1x 升采样 | 0.052–0.15 | |
| 二值 mask 直接升采样 | 0.31 | 全是台阶 |
指标会骗人
empty 在放大目标上是反的。 它抓「假分辨率」,但一张忠实的 4x 重建确实不含 1024 以上的信息:
| empty | 判 | |
|---|---|---|
| 4x NEAREST(最偷懒的放大) | 39.93 dB | ○ 通过 |
| 4x LANCZOS(诚实放大) | 51.58 dB | ✗ 不通过 |
而往上撒均匀噪声能把 empty 拉回 41——照样通过。所以配 edge_ratio:origin 0.997 / 诚实 4x 0.996 / 均匀噪声作弊 0.607。门槛 empty < 44 且 edge_ratio > 0.90,两项都要在隐藏 Texture 层和后期之后量。
梯度 p90 不能当细节密度指标:被 grain 这类均匀噪声主导。grain 从 26 降到 8(画面明显更干净)后 p90 反而从 11.7 掉到 4.5。
颜色数受色域宽度影响:单一皮肤色的椭圆 8 种,参照图「脸部 538 色」含眼睛、嘴、发梢。不可比。
K90 对软边内容没有判别力:真实厚涂的边本来就该软,参照图 K90 0.68 / mushy 68.8%,和「脏」是两回事。厚涂/风景只跑 empty。
大气层的伤害
grain(overlay)和 grade(soft_light)有硬上限,超过就伤边缘(1000×1300 多图层人物,梯度 p99):
| 强度 | grain | grade | p99 |
|---|---|---|---|
| 0% | 0 | 0 | 317 |
| 50% | 13 | 32 | 264 |
| 100% | 26 | 64 | 210(掉 34%) |
安全区:grain ≤ 8 / grade ≤ 24。
34 量错对象案例集
✅ 已验证——每一条都是真的栽过
「量错了对象」是这个项目里最贵的一类错误:它不报错,只给出一个看起来合理但方向相反的结论。五版下来至少栽了 28 次。按「症状 → 真相 → 规矩」记,这样下次症状出现时能对号。
量的层不对
1. 量累积合成图,当成量单层。 症状:「线稿层 K90=0.104 很脏」。真相:量的是 008-lineart.png 这张累积合成图,单独量 Line_Main 是 0.032,本来就达标。→ 量某一层就只量那一层。
2. 量 Flat 层,而塑造在 Shade 层。 症状:三种画法都「颜色数=1、σ 全 0」。真相:塑造画在 clip 的 Shade 层上。→ 量 group 的 projection。
3. 在透明层上量。 症状:多尺度能量 94%。真相:透明区 RGB=0,滤波器把黑边混进来,实际 7%。→ 用 projection,或把未覆盖处填成均值。
4. 只看 RGB 判 clip。 症状:「inherit alpha 不生效」。真相:clip 只写 RGB 不写 A,alpha=0 处 RGB 照样被写。→ 同时看 alpha。
5. 在根节点下测 clip。 症状:同上。真相:根下有全不透明 Background,累积 alpha 处处 255,什么也不裁是正确行为。→ 在 group 里测。
6. 单层测 multiply。 症状:暗角把纯黑抬成 (6,6,6),但单层测 multiply 叠黑是 (0,0,0)。真相:不透传的组先合兄弟层再整组盖下去,暗角在跟透明的兄弟相乘。→ 在真实的组结构里量。
11b. 量非破坏效果时量源层。 FilterMask / FilterLayer / TransformMask 一个字节都不改源层像素,node.pixelData() 前后逐字节相同。量它必须用 s.projection(group),否则结论是「完全没生效」。→ 效果在合成里,就去合成里量。
比的对象不对
7. 拿整幅比整幅。 症状:「局部对比度差 51 倍」。真相:参照图主体含多种材质,我们的 body 判据把背景算进去了。→ 在可比的语义区域上比,同类区域我们甚至更高。
8. 跨分辨率比 p99。 症状:8K 成品 p99 33.2 vs 参考画 59.4–74.6,「差一倍」。真相:同一张成品 @2508 是 42.2。→ 归一到同一物理宽度再量。
9. 以参考画为锚。 症状:p99 永远达不到 59。真相:源图自己只有 41.6,参考画有成套线稿而源图没有。→ 门槛写成源图的百分比。
10. 拿 8x 输出和放大的 origin 比。 真相:那是拿插值当真值。→ 降采样回 1x 再比。
11. 另画一张 1x 真值。 症状:锚点读 0.1245 而不是 0。真相:PIL 在不同超采样倍数下有约 1/8 像素系统偏移。→ 1x 真值由 8x 真值降采样得到。
指标本身不对
12. empty 在放大目标上是反的。 nearest 放大 39.93 通过,lanczos 51.58 不通过。→ 配 edge_ratio。
13. 整块的梯度 p99 判边缘。 症状:四条填色路子里三条读出 p99 = 0.0。真相:边只占 1%,p99 落在纯内部。→ 在边界带里量。
14. 边界带偏差判「边是不是曲线」。 症状:v4 四条、v5 十二条全部 0.47±0.02。真相:那条带 1px 宽,从 1x 二值 mask 恢复不出 8x 亚像素位置;它量的是「边在不在原位」。→ K90 + 形状偏差配对。
15. 梯度 p90 当细节密度。 grain 降低后 p90 反而掉。→ 它被均匀噪声主导。
16. 只看 K90 判矢量化好坏。 会奖励「抹圆一切」。→ 配形状偏差。
17. aliased 0.0% 读成「利」。 症状:用 c) Pencil 1 Sketch 画发丝,全糊。真相:它 W=3.25 是最软的笔,aliased 0 是因为软到没有台阶。→ 看 W。
17b. 判据比被测对象更粗。 用「离哪个色点最近」的硬分类去算 ColorizeMask 输出的覆盖率,边界带偏差恒等于 0.5——那量的是我的分类器是二值的。换成把像素投影到「背景色→区色」线段上的连续覆盖率,才读到可信的 0.4981。→ 判据的分辨率不能低于你想区分的差别。
17c. 判据没有地板。 filter.apply 的过渡带量出 7.2 px,看着像「有一点羽化」。其实理想台阶经过同样的 9 宽平滑就是 7.2 px——那是判据自己的读数。→ 先把「完美的那一端」和「最差的那一端」都算出来,再看被测的落在哪。
探针几何不对
18. 长曲线探针,短线段用法。 path() 在长曲线上比 stroke() 干净 23–47%,短线段上反过来。→ 探针几何匹配实际用法。
19. 量长直线的边,问一个点的形状。 f) Bristles-5 Flat aliased 0.0 / K90 0.065 全场最干净,但它是平头笔,短笔全是矩形。→ 问什么量什么,probe_markshape 就是为它写的。
工具本身不对
20. 半像素坐标约定。 matplotlib contour 返回下标空间,PIL/Krita 是像素空间,差 0.5。形状偏差 0.302 被读成「矢量化不如 coverage()」。→ 每个坐标约定都要验一次。
21. 取景函数的 and/or。 不重叠判断写成 and,只好捡纯黑背景块,判据 14.5 而实际 43.5。→ 打印取景坐标和均亮。
22. 读到过期像素。 erase 擦掉 2198 px。真相:异步,读到稳定后是 0。→ s.settle()。
23. 用重心判位移。 shape 挪了 300 px,重心只动 214.5 px。两个原因叠在一起:量的框比形状小,挪出去的部分被截断;而且组的投影在形状挪走之后留残影(同一个大框里的不透明像素从 258 万涨到 286 万)。→ 判位移用对象自己的 boundingBox(),或者对着「挪过的解析真值」量偏差。
24. 竖条纹量逐列方差。 模糊强度剖面取的是「每一列在行方向的标准差」,而竖条纹每一列都是常数,量出来处处是 0,四条路一起读成「全都没生效」。→ 探针的纹理方向要和判据的求和方向垂直。
25. 卷积的零填充。 剖面平滑用 np.convolve(..., mode="same"),两端被零填充拉低;右端被拉低就等于「变糊了」,剖面末尾伪造出一个强度回升,过渡带量出 1203 px 而不是 7 px。→ 复制边界再卷积。
一句话
每次量之前问三个问题:我量的是哪一层?它和什么比?这个指标能不能区分我想区分的两样东西? 第三个最容易漏。
附录 A 命令速查
./paint
./paint setup 装/更新 harness (改完 code/ 必须重跑)
./paint check 31 项自检
./paint trace [图] [倍数] 临摹: 读画 + 落笔 + 判据, 默认 work/input/origin.png, 8x, 约 8 分钟
./paint replay <作品名> 统计作画日志 (落笔 / 写像素次数)
./paint probe [名字] 跑一个能力探针; 不带名字就列出全部
./paint landscape 参考作业: 风景, ~6s
./paint portrait 参考作业: 画师金字塔流程, ~12s
./paint list 列出成品
./paint archive <作品名> 把成品拷一份带时间戳的到 work/archive/
./paint clean 清掉历史归档
./paint gui <作业.py> 在真实桌面上看它画
环境变量
读画(trace_prepare.py):
| 变量 | 默认 | 说明 |
|---|---|---|
ART_SCALE | 8 | 输出倍数 |
ART_STYLE | paint | paint / anime(第 20 章) |
ART_DETAIL_T | 34 | 细节笔的误差阈值,越大越少笔 |
ART_EXPAND | 0.6 | Flat 轮廓向外扩的像素 |
ART_SMOOTH | 0.8 | 矢量化前标量场的高斯 σ |
ART_EPS | 0.30 | RDP 容差 |
ART_LINE_THR | 999(关) | 暗脊线稿阈值,paint 风格用 |
落笔(job_trace.py):
| 变量 | 说明 |
|---|---|
ART_SEG | 读画产物目录(含 plan.json) |
ART_SCALE | 覆盖 plan.json 的 scale |
ART_OUT | 成品根目录 |
判据(gauge.py):
| 变量 | 说明 |
|---|---|
ART_SRC | 源图路径,做锚点 |
直接跑两步
ART_STYLE=anime python3 code/trace_prepare.py work/input/origin.png work/cache/origin-anime
./paint setup
ART_SEG=work/cache/origin-anime ART_SCALE=8 ART_OUT=work/output ./krita-bot/run_job.sh krita-bot/job_trace.py
python3 code/gauge.py work/output/trace-origin-anime/final.png
自检与探针
python3 code/contour.py # 矢量化自检 (v4 vs v5 同题)
python3 code/cel.py # 色阶分层在 origin 上跑一遍
./paint probe # 列出所有探针和它们各自要回答的问题
./paint probe filtermask # 跑一个 (等价于下面那条手写的)
ART_OUT=/tmp/x ./krita-bot/run_job.sh probes/probe_xxx.py 2>&1 | grep -aE '^\[|/s|判读'
探针不需要装进 krita-bot/:它们靠 sys.path.insert(0, expanduser("~")) 从 harness
的 HOME 里 import canvas,路径传绝对的就行。改了 code/ 才要重跑 ./paint setup。
探针清单在附录 C。
后台跑长作业
./krita-bot/run_job.sh krita-bot/job_trace.py > job.log 2>&1 &
grep -aE "\[paint\]|\[stage\]|JOB (OK|FAIL)|Traceback|Segmentation" job.log | tail
会话结束会杀掉后台进程,留下 krita 僵尸:pkill -f "krita --nosplash" 再重跑。
附录 B 代码索引
全部在 code/。裸 python 的不需要 Krita,可以在外面迭代;Krita 作业要走 run_job.sh。
流程主线
| 文件 | 运行在 | 做什么 | 章 |
|---|---|---|---|
trace_prepare.py | 裸 python | 读画:分割 → α → 色阶 → 矢量化 → 规划笔画,产出 plan.json + strokes.jsonl | 4–7, 17–20 |
job_trace.py | Krita | 落笔:建 307 节点的树,逐笔画,存盘,数写像素 | 12, 19 |
gauge.py | 裸 python | 判据尺 | 31 |
拆分
| 文件 | 入口 | 做什么 |
|---|---|---|
segment.py | load / regions / tiers / pick_flat / inpaint / deaa / residual / pseudocolor | 边界屏障分割、KMeans 分档、以及旧路线的残差分解(保留) |
contour.py | alpha_from_image / level_field / trace / to_svg / rasterize | 解 α、矢量化、自检 |
cel.py | levels / vectorize / clean_mask / pick_k | 嵌套色阶 |
upsample.py | _sdf / coverage / upsample_image | SDF;旧路线的升采样(coverage 在 contour 自检里当对照) |
lineart.py | depth_field / thin / trace_paths / rdp / _catmull / to_svg | 暗脊线稿、RDP、Catmull-Rom |
重绘
| 文件 | 入口 | 做什么 |
|---|---|---|
strokes.py | Sim / wash / soft / hatch / strand / detail / contour_lines / lines | 六种笔的规划器 + 1x 模拟画布 |
Krita 侧
| 文件 | 入口 | 做什么 |
|---|---|---|
canvas.py | Session / L / G / pump / replay | 建树、寻址、落笔、换装、像素读写、选区、filter、存盘、作画日志 |
measure.py | metrics / edge_ratio / report | 边缘质量 |
enrich.py | rim_band / orientation / trace_strand | 轮廓光、方向场、发束(旧路线的加料,保留) |
job_selfcheck.py | — | 31 项自检 |
job_landscape.py | — | 参考作业:风景 |
画师流程(另一条线)
| 文件 | 入口 | 做什么 |
|---|---|---|
workflow.py | Pyramid / scale_energy / local_contrast | 金字塔(笔刷尺度递减)、中灰 hard_light、归一化坐标 |
layout.py | art_tree | David Revoy 式图层骨架 |
job_portrait.py | — | 参考作业:按金字塔流程画人像(示范流程,造型很糙) |
这条线是「从零画」而不是「临摹」,本书主线没用它,但 canvas / measure 共用。它的两条实测规矩:塑造用中灰 + hard_light 单层(中灰叠上去分毫不动,浅灰提亮深灰压暗且保色相);金字塔是笔刷尺度递减不是画布尺寸递增(画布放大会让细节能量逐级递减)。
环境
| 文件 | 做什么 |
|---|---|
setup_fedora.sh | 生成 harness(独立 HOME + autopaint 扩展 + run_job.sh),先 rm -f 再 cp |
setup.sh | 包一层,把画师流程的脚本也拷进去 |
../paint | 唯一入口 |
canvas.Session 速查
s = Session(w, h, "name", journal=True)
s.build(spec) / s.find(path) / s.use(path) / s.on(path) / s.by_role(r) / s.by_label(n) / s.tree()
s.set_preset(name, size, opacity, flow) / s.set_color(r,g,b) / s.brush(color, size, opacity, flow, preset)
s.stroke(pts, pressures, node) / s.path(pts, node) / s.line(pts, width, node) / s.dab(x,y,p,node)
s.ellipse(...) / s.polygon(...)
s.layer(name, blend, opacity, clip, label) / s.group(...)
s.pixels(node, box) / s.projection(node, box) / s.composite(box) / s.set_pixels(rgba, node, box) / s.ground(r,g,b)
s.select(x,y,w,h) / s.select_mask(mask, feather) / s.select_none() / s.erase(box, mask)
s.filter(name, params, node, box)
s.settle(node) / s.stable(fn) / s.flush()
s.stage(label) / s.final() / s.save_kra(path) / s.manifest()
附录 C 探针清单
每个探针回答一个「Krita 无头下到底行不行」的问题,结论进正文。全部在 probes/,跑法见附录 A。
| 探针 | 问题 | 结论 | 章 |
|---|---|---|---|
probe_stroke_budget.py | 8K 上能落多少笔?换色换笔径要不要 pump? | paintLine 450–850/s 与 size 无关;不 pump 换色生效(24/24),带 pump 71/s | 19, 27 |
probe_throughput_tree.py | 真实图层树上为什么慢、为什么越画越慢? | 树只贵 7%;path() 比逐段 paintLine 快 2.3 倍;每次调用 233 KB 撤销栈,setBatchmode 无效 | 19 |
probe_taper.py | 36 个 preset 哪些收尖、哪些边干净? | mypaint 引擎 aliased ≈ 0,b) Basic-* 88–95%;Pencil 1 Sketch 收尖 0.28 但 W=3.25 最软 | 21 |
probe_markshape.py | 一笔是圆的还是方的? | Bristles-5 Flat 长宽比 2.52 是平头;mypaint 画不出 1px 点 | 21 |
probe_glaze.py | setPaintingOpacity 量纲?叠加曲线?笔径系数? | 收 0..1,255 饱和;叠加符合 1−(1−a)^N;系数 Airbrush 0.93 / Bristles-5 0.44 / Pencil 1 Sketch 0.20 | 17, 21 |
probe_fill.py | 8K 上填一块平滑边的色块,哪条路行? | 只有矢量层(0.083);select_mask 0.331;paintPolygon 二值 0.498 | 27 |
probe_clipvector.py | 笔画层能不能 clip 到矢量层? | 能,且分数 alpha 原样传递(③ = ① = 0.0828) | 13 |
probe_vecstack.py | group 能不能 clip 到矢量层?15 个矢量层多久? | 能,裁整组合成(7,147,891 = 剪影);12.3s;.kra 13.8 MB | 13 |
probe_vector.py | 矢量层在 8K 上靠不靠谱? | 5/5:进 composite、贝塞尔渲染、坐标 1:1 零偏移、.kra 往返、跨 7700px | 28 |
probe_clipping.py | inherit alpha 到底生不生效? | 在 group 内生效,裁到累积 alpha,passthrough 失效,必须看 alpha | 13 |
probe_layer_order.py | addChildNode(child, None) 放哪? | 顶部;childNodes() 底→顶 | 28 |
probe_bigcanvas.py | 8192×12288 能不能跑? | 建档 2.3s、整幅读 3.7s、峰值 RSS 4.9 GB、.kra 存/开正常 | 26 |
probe_hardlight.py | 中灰 hard_light 塑造成不成立? | 中灰叠上分毫不动,浅灰提亮深灰压暗,保色相 | B(画师流程) |
probe_filtermask.py | 带选区的 filter 行不行? | apply 过渡带 7.2px(=地板),FilterMask(Selection) 59.9px 且非破坏;过渡带 ≈ 1.5×feather;Node 重载不限制范围 | 29, 30 |
probe_colorize.py | ColorizeMask 无头能不能出 flats? | 能:三区面积 0.98,12.3s,RSS +1.1GB。但边是二值(0.4981)。必须先 initializeKeyStrokeColors | 30 |
probe_preset_xml.py | 能不能程序化造笔刷、系数表能不能作废? | toXML/fromXML 可用且不写盘;改 diameter 对 auto brush 生效、对预定义笔尖无效;系数消不掉;mypaint 往返有损 ×31 | 21, 30 |
probe_scratchpad.py | 离屏画布能不能绕开撤销栈? | 不能——没有落笔 API。但它是拿 gradient / pattern 光栅的唯一路,搬回文档无损 | 19, 30 |
probe_delivery.py | .kra 还能多带什么? | SelectionMask / FileLayer / CloneLayer 三样往返不丢;Palette 存不进;图层样式喂不进 | 16, 30, 31 |
probe_shape_edit.py | 矢量层建完还能不能改? | 能:setZIndex / setTransformation / createGroupShape / setAntialiased(0.2624 vs 0.0891)全生效。单位是 pt。Node 变换 + TransformMask 也成立 | 28, 30 |
probe_detail_energy.py | 平涂 vs 密集笔触的细节能量? | 平涂 σ8 0.1%,420 笔塑造 2.9% | B(画师流程) |
写探针的规矩
- 一个探针一个问题,docstring 第一行就是那个问题
- 判据写在 docstring 里,包括「什么算生效」和锚点
- 读像素用
s.stable()/s.settle(),读一次就下结论会判反 - 探针的几何匹配实际用法(长曲线 vs 短线段)
- 在真实的结构里量(group 里、8K 上),单层探针会漏掉组合成的行为
- 打印判读提示:
[判读] ③ 明显小于 ④ = …,让不了解上下文的人也能读结论 - 判据不能比被测对象更粗:用二值分类器去量「边是不是抗锯齿的」,答案恒等于
「是二值的」——那量的是分类器自己(
probe_colorize栽过) - 判据也要有地板:先算出「理想台阶」在这个判据下读多少(
probe_filtermask是 7.2 px),不然分不清「没生效」和「生效了但很窄」 - 量位移不要用重心:框比对象小会截断,组的投影还会留残影。用对象自己的
boundingBox(),或者对着挪过的解析真值量偏差(probe_shape_edit栽过两次)
附录 D 术语表
| 术语 | 含义 |
|---|---|
| 临摹 | 把源图当参考重新画一张。判据是像不像人画的、低频像不像,不是逐像素对上 |
| 复刻 | 逐像素重建源图。旧路线(v1–v4)的目标,判据是 PSNR |
| 1x / 8x | 源图分辨率(1024×1536)/ 交付分辨率(8192×12288)。规划器工作在 1x,落笔在 8x |
| 部件 Part | 语义分割出的一块:头发、脸、裙子……每个是 .kra 里的一个组 |
| 色阶 tier / cel | 部件内按亮度分的档:Flat / Shadow1 / Shadow2 / Light1……嵌套结构 |
| cell | (部件, 色阶) 联合标签,pid × 100 + tier。笔画的活动范围 |
| α / 亚像素覆盖率 | 一个交界像素有多大比例属于某部件,从抗锯齿边解出来 |
| SDF | 符号距离场,内正外负,单位像素。矢量化的标量场基础 |
| 等值线 | 标量场取某个值的曲线。零等值线 = 边界 |
| RDP | Ramer-Douglas-Peucker 折线简化,容差 eps |
| Catmull-Rom | 穿过所有控制点的样条,转成三次贝塞尔交给 SVG |
| clip / inherit alpha | Krita 的 setInheritAlpha(True):裁到同组内下方各层的累积 alpha |
| passthrough | group 的透传模式。开了 clip 失效;不开时组内 multiply 不跟组下面相乘 |
| 色トレス | 彩色线稿(日语 色トレス):线的颜色取自旁边的色块压暗,而不是黑线 |
| 赛璐璐 cel | 明确色阶 + 硬边的动画上色风格 |
| 软调厚涂 | 本书 paint 风格:airbrush + 半透明细节笔,水粉感 |
| 平涂 wash | 不看误差按固定间距扫满一块区域。只用在纯渐变上 |
| 收尖 taper | 笔的两头细中间粗,靠 pressure 0→1→0 |
| 梯度 p99 | 亮度梯度模的 99 分位,边利不利。随分辨率变 |
| 8px 阶梯指纹 | 1x 像素格放大 8 倍留下的周期信号。放大过就高 |
| K90 | 边缘 level-set 曲率的 90 分位,逐像素锯齿的直接度量。解析抗锯齿约 0.04–0.06 |
| W | 边缘 10–90% 过渡宽度(px)。小 = 利,大 = 软 |
| aliased% | W < 1 的边像素比例(硬台阶) |
| empty | 假分辨率判据(dB)。在放大目标上是反的,要配 edge_ratio |
| edge_ratio | 高频能量落在梯度脊 3px 内的比例。噪声作弊时掉到 0.6 |
| 低频 likeness | 两边降到 256 宽再算的 PSNR |
| 归一化 | 量画面指标前先 resize 到同一物理宽度(2508) |
| 源图锚点 | 判据门槛以源图自己的读数为基准,写成百分比 |
| 量错对象 | 测量不报错但结论反向的一类错误。第 34 章 |
| harness | 独立 HOME 的 Krita 运行环境(krita-bot/) |
| pump | 跑几轮 Qt event loop。读像素前要;换色换笔径不要(8.5 倍的税) |
| 撤销栈 | Krita 每次 paint* 存一份,约 233 KB,Python API 清不掉。预算是内存 |
| 探针 probe | 回答一个「到底行不行」的小作业,结论进书 |
| Hertzmann 1998 | Painterly Rendering with Curved Brush Strokes of Multiple Sizes,detail 笔的算法来源:多尺度、沿梯度法线生长、画布更接近就收笔 |
附录 E 版本史
这本书之前是一个 Claude skill 仓库,五个大版本。外层的 git 已经删了,提交列表导出在 E-版本史-git导出.md。这里是每一版做了什么、留下了什么。
v0.1.0 — 原版:Krita 5.2.2 + tablet event 注入
往 canvas widget 里 sendEvent 伪造 QTabletEvent。坐标标定事先不可知(0.79–1.05),笔触只能落在第一层,saveAs 段错误,200–600 笔/分。
v1.0.0 — Krita 6 改造
Node.paintLine / paintPath 取代 event 注入。画在指定 node,pressure 驱动 brush engine,5300 笔/分。误判:把 base/top 建在根节点下测 clip,记成「inherit alpha 不生效」。
v1.1.0 — 语义图层
build(spec) 声明式建树、寻址、manifest、color label 第二索引。撤回 v1.0.0 的误判:clip 在 group 内生效。
v1.2.0 — 从已有插画复刻分层 .kra
segment.py 边界屏障分割(三种颜色分类都错)+ 部件内分档。PSNR 28.32。
v1.3.x — 边缘质量自动判定
measure.py:K90 / W / aliased / empty。修掉大气层的伤害(grain ≤ 8 / grade ≤ 24)。对标顶级插画:局部对比 p50 是最能分辨的指标(origin 13–16,平涂 0.5–0.7)。
anime-illustration v0.1.0 — 画师流程
金字塔(笔刷尺度递减,不是画布放大——栽过一次)、中灰 hard_light 塑造、归一化坐标。四次「量错对象」记进 pitfalls。
v2.0.0 — 可用性重构
./paint 统一入口,成品固定名字覆盖式更新(之前 47 个带时间戳的目录里只有 3 个是成品)。
v3.0.0 — 4K:精确残差取代色阶量化
Flat + multiply 残差 + screen 残差,逐位无损。38.53 dB。新坑:组不透传时 multiply 不跟组下面相乘;残差不是带限的(bicubic 过冲被 clip 削平成描边,anti-ringing clamp);加料要在对账之后(轮廓光一口气吃 10.8 dB);empty 在放大目标上是反的。
v4.0.0 — 8K / 矢量线稿 / 笔画日志
8192×12288 跑通(RSS 4.9 GB)。probes/probe_vector.py 5/5。Session(journal=True) + strokes.jsonl。最贵的一次:三个修复(抽线稿 + 去抗锯齿 + 平滑 SDF)叠在一起比它们要修的问题糟 9 倍(38.53 → 24.99 dB),六轮才分清。partition of unity 在 alpha-over 上是错的;平滑 SDF 吃掉细长条。
v5.0.0 — 从复刻改成临摹(本书)
用户指出:v4 的日志 55 条全是 set_pixels,一笔没落;边缘是「奇妙锯齿」。诊断:整条流水线没有任何像素在 8K 上生成。
重做:矢量管边,笔画管里面。contour.py(从抗锯齿边解 α → 等值线 → 贝塞尔)、cel.py(嵌套色阶)、strokes.py(六种笔)、gauge.py(归一化 + 源图锚点)、Session.brush()、七个新探针。80,669 笔 / 写像素 2 次;阶梯指纹 2.23 → 0.78。
新记录 10 条坑(第 21 章、第 34 章)。丢掉逐位无损残差和 38.74 dB——那是复刻的判据。
v5.1.0 — 「Krita 的能力只有 strokes 吗」
起因是一个问题。把 libkis 的 41 个类的方法表逐个对照代码,发现缺口比第七部记的还大, 而且缺的几块正好压在书里已经写下的限制上。六个新探针(附录 C)、 新增第 30 章,第八部顺延为 32/33/34。
赢的:FilterMask(Selection) 给出带羽化的非破坏局部 filter(过渡带 ≈ 1.5 × feather),
改写了第 29 章「filter 不受选区限制」;ColorizeMask 无头能出 flats(三区面积 0.98);
SelectionMask / FileLayer / CloneLayer 三样能随 .kra 交付;矢量层建完还能改
(setZIndex / setTransformation / setAntialiased);TransformMask 非破坏变换成立。
输的,同样有用:Scratchpad 上没有落笔 API,省撤销栈这条路不成立(但它是拿
gradient / pattern 光栅的唯一路);Preset 的 XML 能改能生效,但宽度系数表作废不了;
图层样式和调色板都进不了交付。
新记录 8 条静默失效、5 条不存在、3 条判错过的(第 31 章)。 探针方法论也补了三条:判据不能比被测对象更粗、判据要先算出自己的地板、量位移不要用重心。
v5.2.0 — 变成一本 mdBook
结构调整,没有新结论。SUMMARY.md、README.md(封面)、assets/ 全部搬进 src/
——mdBook 看不见 src/ 外面的东西。仓库根上换了一个短的 README.md 当 GitHub 首页,
加了 book.toml,book/ 不进 git。mdbook serve --open 本地看,mdbook build
出静态站点。
mdbook build 顺带成了一个新的校验工具:没列进 SUMMARY.md 的章节和断链它都会报。
之后
skill 这种形态取消,改成这本书。anime 风格停在第三轮(第 20 章),补全一部是设计稿(第三部)。
提交历史(外层仓库删除前导出)
6f8cb58 2026-09-08 krita-painting v5.0.0:trace 从「像素复刻」改成「临摹」
7b8d8d3 2026-09-08 v4.0.0:8K 画布 / 矢量线稿能力 / 笔画日志
c26e5c3 2026-09-08 v3.0.0:4K 大作 CG —— 精确残差取代色阶量化
405935a 2026-09-08 v2.0.0:可用性重构 —— 改名、重组输出、统一入口
a8809ab 2026-09-08 新增 anime-illustration skill:按画师流程画插画
4384002 2026-09-08 krita-painting v1.4.0:移除人物原创示例
b34f19c 2026-09-08 krita-painting v1.3.1:对标顶级插画,补上可程序化的部分
798361b 2026-09-08 krita-painting v1.3.0:提升参考作业精细度
3acfc67 2026-09-08 krita-painting v1.3.0:边缘质量可自动判定,并修掉大气层的伤害
6050c61 2026-09-08 修 v1.2.1 漏升的 version 号
b91987e 2026-09-08 krita-painting v1.2.1:整理目录布局
d5dd35c 2026-09-08 krita-painting v1.2.0:把已有插画复刻成分层 .kra
466c2cf 2026-09-08 krita-painting v1.1.0:大量语义化图层
62a83f9 2026-09-08 krita-painting v1.0.0:改造到 Krita 6,换掉核心机制
d92a366 2026-09-08 krita-painting skill v0.1.0(改造前原版,Krita 5.2.2)
tag
anime-illustration-v0.1.0 按画师流程画插画的 skill
v0.1.0 原版:Krita 5.2.2 + PyQt5,tablet event 注入
v1.0.0 Krita 6 改造:Node.paintLine 取代 tablet event 注入
v1.1.0 大量语义化图层:clipping、图层树、寻址、manifest
v1.2.0 从已有插画复刻分层 .kra:边界屏障分割 + 部件内分档
v1.2.1 整理目录布局: harness 和产物收进项目目录
v1.3.0 边缘质量自动判定 + 修大气层 + 提升参考作业精细度
v1.3.1 对标顶级插画: 发束/褶皱生成器 + 差距分析
v1.4.0 移除人物原创示例
v2.0.0 可用性重构:命名、输出布局、统一入口
v3.0.0 4K 大作 CG:精确残差取代色阶量化
v4.0.0 8K 画布 / 矢量线稿能力 / 笔画日志
v5.0.0 trace v5:矢量管边,笔画管里面 —— 从像素复刻改成临摹