Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Appendix E Version history

This appendix is a historical record. It is not translated with the rest of the book; in the English edition it stays in Chinese.

这本书之前是一个 Claude skill 仓库,五个大版本。外层的 git 已经删了,提交列表也不再保留。这里是每一版做了什么、留下了什么。

每一节里的章号是那一版书的章号(书的目录改过几次);只有最后一节按现在的目录。

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 条坑(第 6 章、第 17 章)。丢掉逐位无损残差和 38.74 dB——那是复刻的判据。

v5.1.0 — 「Krita 的能力只有 strokes 吗」

起因是一个问题。把 libkis 的 41 个类的方法表逐个对照代码,发现缺口比第七部记的还大, 而且缺的几块正好压在书里已经写下的限制上。六个新探针(附录 C)、 新增第 15 章,第八部顺延为 32/33/34。

赢的:FilterMask(Selection) 给出带羽化的非破坏局部 filter(过渡带 ≈ 1.5 × feather), 改写了第 14 章「filter 不受选区限制」;ColorizeMask 无头能出 flats(三区面积 0.98); SelectionMask / FileLayer / CloneLayer 三样能随 .kra 交付;矢量层建完还能改 (setZIndex / setTransformation / setAntialiased);TransformMask 非破坏变换成立。

输的,同样有用:Scratchpad 上没有落笔 API,省撤销栈这条路不成立(但它是拿 gradient / pattern 光栅的唯一路);Preset 的 XML 能改能生效,但宽度系数表作废不了; 图层样式和调色板都进不了交付。

新记录 8 条静默失效、5 条不存在、3 条判错过的(第 16 章)。 探针方法论也补了三条:判据不能比被测对象更粗、判据要先算出自己的地板、量位移不要用重心。

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 的章节和断链它都会报。

v6.0.0 — 从「临摹残差」改成「画师工序」:素描版 → 人工闸门 → 成品版

起因是用户对 v5 成品的六条批评,全部核对成立:图层不是人要的(KMeans 按亮度切的档,瞳孔和脖影一层)、分太多(317 节点)、不相关内容聚在一层、没有重画完整内容(只补差额,遮挡补全没做)、「内存限制」其实是没有中间状态、笔画是短点不是线(path 类中位 24px@8x)。

工序改成:素描版 .kra(线稿描黑 + 平涂底色,语义分层)→ 人工检查 → 成品版,从腿部生成到整体,每个 phase 一个闸门。实验全在 docs-tmp/ 里跑,满意了才进 code/ 和书。v5 的临摹流程原样保留(./paint trace),两条路各自成章:新开第九部「流程」(第 35–48 章)。

赢了什么

  • 语义分层:7 组(Hair_Back / Skin / Eye / Shoe / Dress / Ribbon / Hair_Front),每组 = 矢量 Shape(amodal)+ 底色 + 软笔明暗 + [发丝] + 线,全图 42 节点(v5 317)。SAM 多数票取代手写 ASSIGN:Ribbon_Waist 85.5% 回到头发(第 1 章)。
  • 图层是可以重叠的:每组的 Shape 是 amodal 形状。后发在身体后接成一块(+28k px),裙子在手臂下连续,额头在刘海下。线画进组内,隐藏上层看到完整的下层(第 3 章)。
  • 线是一条:候选池(分割轮廓 + AI 线稿)按源图打分仲裁,Schneider 拟合代替 Catmull-Rom 插值。曲率变号 18–21 → 2.1 次/100px,两腿之间 8 笔 → 2 笔。线的粗细颜色按源图那条线底下有多黑定(色トレス),淡边不画(第 2 章)。
  • 每个像素都是笔:底色 wash 长笔铺满(7 组 ≥ 97.5%),明暗用软笔三个尺度 + 硬笔在组内抄源图,被遮的部分抄补全图。没有 detail 残差点(第 6 章)。
  • 内存归因修正:涨的是层的 tile + 组的 projection,不是撤销栈(局部重复 12000 笔 +0.06 GB,随机撒满 +4.2 GB)。预算 = 层数 × 面积;19.3 万次调用 5 分钟画完,RSS 没事(第 18 章重写)。
  • 第一次用别的 AI:ComfyUI 上的线稿提取 / GroundingDINO+SAM / Animagine XL inpaint,本体不改(第 11 章)。
  • 判据(归一 2508px,源图为锚,4x):梯度 p99 48.2(源图 116%)、8 级色数 2383(82%)、阶梯指纹 0.92、写像素 2、低频 likeness 30.4 dB(同一份计划上一次落笔 29.8;v5 同 4x 34.8;分档版 25.4)。新加三把尺:线(变号/链数/偏差)、图层(节点数/覆盖率/每组单独渲染)、内部锐度(gauge.py 新加的一行:离外轮廓 30px 的 p90,源图 10.5 / v5 9.5 / v6 6.6;头发 9.1 / 11.0 / 7.9,都是 4x)(第 8 章)。
  • ./paint comfy / sketch / paint 三个子命令;code/ 加 15 个模块,semantic_template.py 是唯一一份语义表;probes/ 加 2 个探针。

输了什么

  • ./paint trace 自初版提交起就跑不通。 job_trace.py 的 KIND 表丢了 detail(规划出的 80,669 笔里一半是它),作业在建完 307 节点的树之后 KeyError。书里所有 v5 的数字都来自提交之前的一次运行(work/archive/trace-origin-v5.0.0-*)。一直没发现,是因为 ./paint 的 run() 用 grep … || true 过滤输出,把 === JOB FAIL 吞掉、退出码 0,随后的 gauge.py 在上一轮的 final.png 上照样打出漂亮的表。收尾回归时才炸出来。这一版补回条目(code/kinds.py,规划器落盘前核对每种笔都有条目),trace 按 4K 重跑、重量。
  • 低频 likeness 仍低于 v5(29.8–30.4 vs 34.8 dB,骑在 30 的门槛上):v6 每组各画各的,组间边界和线稿都是重画出来的,差的是结构性的。
  • 里面比 v5 软得多(同 4x 内部锐度 6.6 vs 9.5,源图 10.5;v5 在 8x 上只有 6.1,跨倍数比会读反):砍掉每档色阶的矢量形状是分层的代价,要追平得在组内再放矢量色阶 clip——那又是 v5 的树。取舍,不是 bug。
  • 成品版第一到三轮是赛璐璐分档——posterize 加描边,likeness 掉到 25 dB,眼睛一团黑。用户一句「为啥效果这么差」是对的:那是我图省事选的,用户没要过。第四轮改成软笔抄源图才回到 29–30 dB。上色方式不该由我替画师定。
  • 「AI 线全局优先」被用户否掉——来源不能当判据。后来的仲裁是对的,但走了三版(下标两两比 → NMS → 够好/长度/分数 + 修剪)。
  • 固有色规矩试了三条才对(偏亮中位 → KMeans 最大档 → 亮的一半里的众数);Ribbon 还得按色度分簇。
  • 检查点(存盘→close→重开)对内存没用,只对「中间状态给人看」有用。
  • inpaint 补「皮肤」把上衣去掉、刘海改白——衣物遮住的身体不交给生成模型,几何外扩 12px,负面词带 nsfw/nude。
  • 眼睛没有高光点的线(AI 线稿把眼内当实心块);嘴没有线;眼白(SAM eyes mask)比眼眶大一圈;头发内部的线仍多于源图。
  • 裙边红白格子带:SAM 没词、颜色会碎,唯一还沿用旧分割的地方。
  • 隐藏区(补出来的部分)的明暗抄的是补全图——是编的;complete_*.png 与 amodal.npz 的 region 就是账。
  • 8x 太慢(Krita 5 分钟只是成品版的一半),用户叫停后改成默认 4x。
  • 只在这一张图上验证过;换图要重跑 SAM 词表和 GROUPS。

新记录的坑

  1. 量错对象 ×3:撤销栈那 233 KB/笔是 tile 密化;探针自己攥着旧 node 句柄 close 后内存不回;「重开后落笔没生效」是层已画满数不透明像素不涨。第 17 章 26–28。
  2. pkill -f "<含自己命令行的模式>" 在这个 harness 里杀掉发命令的 shell(exit 144),[m]ain.py 老技巧也不管用;用 pkill -x / fuser -k 端口。一条命令里 cd 过的工作目录会带到后面的调用,后台链一律绝对路径。
  3. lineart._degree 数邻居个数,斜线台阶像素被当分叉:19678 骨架像素里 8572 个假分叉。用 crossing number。
  4. 拟合容差 < 噪声 3σ 会一路切段(1.2 容差 → 176 段),先 σ=1.5 平滑再拟合。
  5. 拟合后的线离 mask 边最多 3px@1x:填色 Shape 也得用同一套拟合,否则线和填色边错开。填色环要比线的位置多扩 2.0px,不然外轮廓线和填色之间露一线黑底。
  6. 短碎片 100% 落在长线上不能算「重叠」淘汰长线;按候选自己被盖住的比例修剪。
  7. GroundingDINO 词要具体:ribbon 抓成整个人,red bow 才对;抓不到的词退化成整个人(≈455k px)。
  8. transformers 5 两处:BertModel.get_head_mask 没了;get_extended_attention_mask 第三个位置参数变 dtype。
  9. wash 在细的地方(鞋带)扫线中点落在 mask 外被跳过 → 84.7% 覆盖;覆盖图找洞小笔补。
  10. 源图的 alpha 有洞:两腿之间 12px 的「缝」alpha 1–3、RGB 却是皮肤色(非预乘 PNG)。当背景会双线、黑底露出、amodal 漫进去。按 RGB 补回;主体阈值放宽到 80 反而退步,回到 200。
  11. amodal 的 α 要在「源图 + 只在补出来的区域换补全图」上解;整张用补全图解,可见部分的边会被带偏 ~1px。
  12. 「线底下最暗的像素」要只在主体里找,否则外轮廓线全取到黑背景。
  13. 补齐只能落在上层组的像素里,dilate 会漫进背景缝。
  14. p99 量不出「里面糊」(被外轮廓撑着);跨倍数比色数会反(4x 82–85% vs 8x 69% 是归一缩放的差别);同一份计划两次落笔差 0.6 dB / 3 个点色数,笔刷引擎有随机性,小于它的差别是噪声。
  15. 硬笔阈值 T×0.7 时一组 5.6 万笔——在抄噪声;T×1.4,R=1.5 只给发丝密的组。
  16. Krita 里 exec 出来的作业没有 __file__:搬进 code/ 时给作业加了 dirname(__file__),在裸 python 里 py_compile 过、在 Krita 里第一行就 NameError。作业只靠 expanduser("~")(harness 拷了全部 code/*.py)。
  17. ./paint 的 run() 用 grep … || true 过滤输出,把作业的 === JOB FAIL 一起吞了,退出码 0,后面的判据照跑在上一轮的成品上——主线 ./paint trace 就这样坏了两个版本没人知道(KIND 丢了 detail)。现在 run() 见 JOB FAIL / Traceback 就退出;KIND 抽到 kinds.py,trace_prepare.py 落盘前核对规划出的每种笔都有条目。第 17 章 35。
  18. 作画日志把 ground(r, g, b) 的 b 当 box 记:ground(0,0,0) 因为 0 是假值没事,素描版的 ground(220,220,220) 就抛错、那次铺底没记进日志——写像素次数少记一次,诚实判据反而更好看。第 17 章 34。
  19. 作业里按 8x 写死的特写坐标在 4x 上裁出空图(cannot write empty image);按 SCALE 换算。

The numbers

42 节点(v5 317)· 134,864 笔 / 192,778 次调用(v5 80,695 / 99,802)· 默认倍数改成 4(trace 7 分钟、paint 7 分钟)· 线 175 笔中位 297px@8x、变号 2.1/100px(v5 24px)· 写像素 2 · 4x Krita 5 分钟 · 成品版走了十轮(三轮分档 → 软笔抄源图 → 线的颜色 → 填色边 → alpha 洞 → 主体阈值试错回退 → Shape 多扩 2px → 4x + 硬笔)。

v7.0.0 — 按工序重排:书 = 工序 → 部件 → 做法,代码目录 = 书的目录,补齐全交给 diffusion,默认 4K

用户对 v6 的判断(全部成立):书不是给人看的、「一图流」名不副实(P1 根本没有补全那一步,第三部四章全是 📐 空谈)、章节按技术点堆、两条流水线被拆成 14 个小章、代码 31 个平铺模块 8000 行且两条流水线各写一套、8K 太慢。这一版四步:代码重构 → 补齐重做 → 书重写 → 提交,每步给人看。

赢了什么

  • 书按画一张画的工序重排:第一部 8 章一道工序一章(分割 / 线稿 / 补齐 / 叠层 / 底色 / 上色 / 背景 / 验收),章内按部件分节(头发 / 眼睛 / 皮肤 / 衣服布料 / 丝带装饰 / 鞋),节内是做法;第二部一条流水线一章(P1 一次成型、P2 先素描后上色),以后每做出一条加一章;第三部 Krita 无头底座(原 26–31 章);第四部教训(案例集 + 预算);附录加了 F 文件格式。48 章 + 5 附录 → 18 章 + 6 附录,删掉 31 个文件、21 张没人引用的图。
  • 第 6 章「上色」老实标 📐:现在跑的是「软笔 + 硬笔在组内抄源图」的通用做法;二次元的头发 / 眼睛 / 皮肤 / 布料怎么画还没研究出来,每一节写的是源图里它长什么样、通用做法画成了什么、差在哪、要做的探针。
  • 代码目录 = 书的目录:code/dtp/ 包,stages/ 一道工序一个模块,parts/ 一个部件一个模块(换图改这里),pipelines/ 一条流水线一个模块;./paint 变成一行壳,命令全在 code/paint.py;Krita 作业统一走 jobs/job_run.py(按 ART_JOB import 落笔模块)。删掉旧画师流程演示(workflow / layout / enrich / job_portrait / job_landscape)、旧路线的 upsample / residual / 暗脊线稿。重构不改画法:两条流水线的规划产物与重构前逐字一致(P1 80,695 行、P2 1,071 / 293 / 175 / 133,618 行),Krita 落笔的判据一致。
  • 补齐完全交给 diffusion(用户的规矩:编出最原始的形状,叠回去没有差异,单独一层也对):可见部分钉死为源图;补全图上 SAM 按 parts/*.py 的 AMODAL 词取回形状(Skin 用 person 减 hair / shoes);衣物之下也编,只取形状。做法是一圈一圈往外长(洞 = 已知部分外 48px 的一圈,模型不再往圈里画就停):后发在身体后面长出一整片(+363%),皮肤长成完整的身体(+246%),裙子 +6% 两圈就停。新加叠回验收:各组按遮挡顺序叠回,主体内逐像素和源图比(review-restack.png),0 差异 0 洞。第 3 章。
  • 默认 4K:paths.SCALE = 4;P1 7 分钟、P2 4 + 7 分钟。

输了什么

  • 「遮挡区一次全抹掉让模型编」不成立:对 inpaint 模型来说洞的形状就是提示词——裙形的洞画出裙子,人形的洞画出一个灰绿色的人形剪影,提示词和上下文(只给它自己看)都改不了。两个版本留在第 3 章当反面教材(图和结论),代码只留一圈一圈长的那一种。
  • 一圈一圈长出来的后发是先验不是证据(换 seed 形状会变);皮肤那一层把前发也收了进去(减 hair 那一步 GroundingDINO 退化成整个人);补齐从 106s 变成 447s。
  • 上色仍是通用做法,P2 的 likeness(30.4 dB)和内部锐度(6.6 vs P1 9.5)没变——这一版没碰画法,只把问题写清楚。
  • 补出来的部分是编的:账在 amodal.npz / layer_<G>.png,第 3 章标明。
  • 第三部(Krita 底座)基本是原 26–31 章原样搬过去,没按新书的口气重写。
  • 删掉的:P1 的 anime 画风(没收敛)、P2 只做几个部件的 ART_PARTS 开关、补齐的 once / solo 两种失败模式的代码、旧画师流程的两个探针(hardlight / detail_energy)、附录 E’ 的旧提交列表、work/ 里的旧成品与中间物、work/archive 的三份归档(含 v5.0.0 的 8x trace——书里 v5 8x 的数字从此只剩数字)。
  • 只在这一张图上验证过。

新记录的坑

  1. 杀进程按 PID,不按模式:for pid in $(pgrep -f …); … grep -q "complete run" && kill 把发命令的 shell 自己也杀了(exit 144)——和 pkill -f "<含自己命令行的模式>" 是同一个坑,换了个写法又栽一次。
  2. 会话崩溃留下的进程还在跑:上一个会话的 complete run 和新起的一份同时写同一个缓存目录、同时往 ComfyUI 队列里塞任务,产物互相覆盖。开长作业前先 pgrep -a 看有没有同名的在跑;ComfyUI 队列 POST /queue {"clear": true} + /interrupt 清掉。
  3. from x import * 只能在模块顶层:想把 Krita 落笔代码和裸 python 的计划步放进同一个模块、把 from dtp.krita.session import * 挪进函数里——SyntaxError。落笔代码只能是独立的 *_job.py 模块。
  4. 把脚本包成函数时别自作聪明加 nonlocal:原脚本没有 global 语句,helper 里对外层同名变量的赋值本来就是局部变量;闭包语义相同,加了 nonlocal 反而改语义。
  5. python3 -m py_compile 过了不等于跑得通:搬进包以后一个函数里的 import arbitrate as ARB(缩进的、带别名)漏改,只有真跑到那一行才炸。grep 要连缩进和别名一起匹配。
  6. inpaint 的洞不能有剪影:洞的形状决定内容。要让模型「补这个物件」,洞得是它外面的一圈,不是遮挡物的形状。
  7. GroundingDINO 抓不到的词退化成整个人(第 1 章已知),但 person 这种词本来就该是整个人——按「面积 > 90% 主体判废」的规矩会把它误杀,白名单放行。
  8. 归一到 2508px 之后跨倍数比任何读数都会反(P1 内部锐度 8x 6.1 / 4x 9.5,色数 63% / 65%);表里两列必须同倍数。同一份计划两次落笔差 0.6 dB / 3 个点色数,是笔刷引擎的随机性。

The numbers

P1(4x):80,695 笔 / 99,802 次调用 · p99 37.7 · 色数 65% · 阶梯 0.81 · likeness 34.8 dB · 内部锐度 9.5(头发 11.0)· 7 分钟。P2(4x,补齐后):42 节点 · 228,420 笔 / 317,850 次调用 · p99 48.6 · 色数 93% · 阶梯 0.87 · likeness 30.4 dB · 内部锐度 6.6(头发 7.7)· 线 232 笔变号 2.2 · 叠回 0 差异 · 补齐 447s + 素描版 2 分 + 成品版 11 分。全部来自提交的代码。

v8.0.0 — 代码搬出书:dtp 通用库(uv)+ 一张图一个子项目 + 一道工序一个 step + AGENTS.md

用户要的是纯工程化:一个能 import 的库,所有重要代码都在里面;以后每给一张图就在 work/ 下建一个子项目,图移进去,每一道工序是子项目里的 stepNN-<工序>/,临时代码放 step 里,通用的沉到库里;规矩写成 AGENTS.md。

赢了什么

  • 库:Krita/dtp/,uv 项目,自己的 git(v0.1.0)。src/dtp/ 里 stages/ 一道工序一个模块(第一部的章)、parts/default.py 部件表模板(各章的节)、pipelines/ 一条流水线一个模块(第二部的章)、jobs/ 只在 Krita 里 import 的落笔作业、krita/session.py + harness.py、geometry/、project.py(Project / Step)、runner.py(工序 → runner)、cli.py(uv run dtp …)。95 处 os.environ 全变成函数参数,库里只剩 settings.py(机器级设置)和 Krita 进程边界(jobs/run.py 读 DTP_JOB)碰环境变量。tests/ 5 个 pytest。
  • Krita 不再拷代码:harness 的 run_job.sh 把 dtp/src 放进 PYTHONPATH,作业 import 的就是源码,「改完 code/ 必须重跑 setup」这条规矩消失(v6 之前静默成功的根源之一)。
  • 子项目与 step:uv run dtp new 图.png → work/<名字>/(图、parts.py、project.toml、README.md);uv run dtp run <名字> <工序> 新开 stepNN-<工序>/ 并跑,step 之间只通过目录传文件,step.toml 记输入 / 参数 / 耗时 / Krita 作业参数,README.md 由 run 自动写数字、人补结论;返工同一道工序用新号。run.py 放临时代码,dtp run <名字> stepNN 跑它。
  • 部件表按图:parts.py 里 ASSIGN / CLASSES / GROUPS / LINES / SAM 词 / AMODAL 提示词 全在一处,库里任何地方不 import 具体部件。
  • 两条流水线在新布局下从头跑通(origin,work/origin/step01-segment … step07-gauge):segment 的四个数组(多数票 parts、手写表 parts、两种主体)与 v7 的缓存逐字一致;补齐的 amodal.npz 逐字一致、叠回 0 差异;P1 80,695 笔 / 99,802 次调用、p99 37.7、色数 64%、阶梯 0.80、likeness 34.79 dB、内部锐度 9.5(头发 11.0);P2 42 节点、228,419 笔 / 317,849 次、p99 48.6、色数 93%、阶梯 0.87、likeness 30.40 dB、内部锐度 6.6(头发 7.8)、线 231 笔变号 2.1——和 v7 的差都在噪声内(第 8 章)。全程 27 分钟:segment 29s · lineart 35s · complete 444s · trace 265s(Krita)· sketch 55s · paint 541s · gauge 4s。
  • 书:附录 A 按 uv run dtp 重写、附录 B = 库的目录树、附录 F 按 step 目录重写;code/、paint、probes/ 从书的仓库里删掉(探针在 dtp/probes/)。上一级的 AGENTS.md 是规矩,CLAUDE.md 只剩本机环境。

输了什么

  • stages/lineart.py 与 pipelines/p2.py 里还有从旧脚本整段搬来的函数体(contour_candidates、线归组),签名是显式参数了,内部没有再拆。
  • 每道工序的 runner 只有一个默认实现;run.py 的机制有了,还没有一个真的用它做过的实验。
  • 只在 origin 一张图上验证过。

新记录的坑

  1. 从 uv 的 venv 里起 Krita,它内嵌的系统 Python 会读到 VIRTUAL_ENV / .venv/bin,插件系统起不来(“Please make sure that the needed packages are installed”),作业永远不开始、进程挂着。run_job() 起进程前去掉 PYTHONHOME / PYTHONPATH / VIRTUAL_ENV / .venv/bin。
  2. contour.py 的等值线靠 matplotlib,系统 Python 一直有所以没人发现;装进干净的 venv 才炸。依赖要写全。
  3. 拟合的单元测试一开始按「不平滑直接拟合」写,61 段——书里说的「先 σ=1.5 平滑再拟合」不是可选项,是规矩。
  4. 根目录的 pytest 会把 ComfyUI/tests-unit 一起收进来(82 个 collection error),testpaths 要写死。

v9.0.0 — 第 6 章不再是 📐:皮肤 / 眼睛各自的画法、头发的一次未遂,和两个躺了三个版本的错

书自己写的「下一版的活」:第 6 章的探针。三个部件各用一个 step 的 run.py 做实验(run.py 机制第一次真用上),赢家沉进库(dtp 0.2.0)。做的过程里抓到两个从 v6 起就在、所有数字都受它影响的错。

赢了什么

  • 皮肤:一档硬边阴影 = 亮度直方图双峰 → Otsu 分档 → 矢量 Shape + 平涂 + 残差软笔在档之上(shade_skin)。数字和通用做法打平(档的边 33 vs 通用 31 vs 源图 30),赢在分层——画师叫得出名字的 Skin_Shadow;只有档没有残差会输(likeness 掉到门槛下)。
  • 眼睛:眼白 / 虹膜 / 暗块 / 高光点从源图颜色来(SAM 的 mask 只给位置),各是一个形状(shade_eye);高光点从 0 个到 3 个(源图 3);残差软笔要避开高光 5px(外扩 2px 的第一版把高光盖没了,成品里 0 个)。没有残差的赛璐璐眼(E1)每条边都比源图硬,PSNR 掉 1.3 dB——「赛璐璐分档对眼睛是错的」这回有数字。
  • 两个错:(1) 脖子从 v6 起归前发(区域 88k px、hair 90%,没有词;素描版脖子是黄的,三个版本没人看)→ parts.py 加 neck / shoulders,segment.vote 种子生长(SAM 圈到一小块 + 颜色明显不同 → 按颜色长 15 px 以内);(2) 上层 Shape 外扩压住下层一圈(前发沿脸 1,502 px)→ contour.trace(forbid=)。基线重跑(step23 起)。
  • 每组的尺:gauge.group_metrics(内部锐度 / 硬边处 / 1x PSNR,mask 互斥)、tier_edges(档的边)、--ref_paint 对照列、节点规则从 layers.json 数;特写加 eye / neck / hair-body。
  • 库(v0.2.0,测试 5 → 14):brushes.KIND 唯一一张(P2 作业不再有私有表,丢笔就 raise);p2.group_masks / exclusive_masks;shade_parts.plan_group 按 parts.py 的 PAINT 表分派(generic / tiers / hair / eye);CROPS 出库到 parts.py;--k=v 旋钮修好(v0.1.0 一个也没传进去过);Session.manifest 递归整棵树,成品终于有 layers.json;step.toml 嵌套表(dtp gui 修好);flats.py 删了中间的死块。
  • 书:第 1 章种子生长、第 3 章两条缺陷、第 4 章第四条规矩、第 6 章重写、第 8 章每组的尺和节点新尺、第 17 章案例 36–41、附录 A/B/F。
  • 成品(step38-paint,皮肤 tiers · 眼睛 eye · 其余通用):216,470 笔 / 写像素 2 · 57 节点 · p99 49.0(118%)· 色数 97% · 阶梯 0.90 · likeness 29.98 dB(通用 30.22,差在噪声内,骑在门槛下)· 内部锐度 6.2(头发 7.1)· 眼高光团 3(通用 0)· Skin 阴影档 17092 px 边带、覆盖 96.1%。

输了什么

  • 头发没赢:缕(顶帽亮脊)、每缕一笔高光带、定向发丝都做出来了(shade_hair),去掉硬笔 likeness 掉到 29.06——硬笔不能省;加回硬笔 29.91(和通用 30.22 差 0.3,噪声内)但两组 1x PSNR 各掉 0.7 dB(刚出噪声地板)、内部 p90 10.0 高于源图 8.4——画多了。高光带压淡压少读数一个不变,掉的来自定向发丝。默认仍是通用做法;PAINT 里把 method 换成 "hair" 就能用。
  • 布料 / 丝带 / 鞋没动(裙子的「硬边处」1.9 vs 源图 18.9 是全图差最远的一组,皮肤的档法应该能直接套,没试)。
  • 遮挡区仍是编的(第 3 章);刘海之间的额头还归前发(face 的 mask 圈不到);前发压在胸口的那缕浅金头发和脖子颜色分不开(限距救的,不是分开的)。
  • 节点从 42 涨到 57(layers.json 口径;[tree] 口径 55):每组一个有名字的档是设计不是回退,尺重新立(每组 ≤ 10,全图 ≤ 5 + 10 × 组数)。
  • 只在 origin 一张图上验证过;皮肤的 Otsu、头发的顶帽 σ/δ、眼睛的颜色阈值都是这张图上调的。

新记录的坑

  1. 闸门的图有了不等于看过了(案例 36):脖子黄了三个版本。每次换基线把脸、脖子、手、脚四处特写看一遍。
  2. 旋钮从没传进去过(案例 38):nargs="*" 拒掉 --k=v,整条链全是默认值。旋钮要有「传了就看得见」的测试。
  3. 量尺的 mask 不互斥、档边用中位数、模拟画布上量锐度——三个「量错对象」(案例 39–41)。
  4. KMeans 分档被线的暗尾巴带跑(皮肤 k=2 切出 3% 的描边);Otsu 在 [固有色−60, +10] 里找谷才对。
  5. SAM 的 chest / upper body / skin 都退化成整个人;neck 只圈到 45%——种子 + 颜色,不指望词圈全。种子生长不限距离会顺着颜色跑到 100 px 外的头发上(grow_dist)。
  6. 1x 模拟和 Krita 会反着来:定向发丝在 Sim 上加 0.8 dB,Krita 上掉 0.7——Pencil-3 画出来比 Sim 的核重。模拟只能定方向,数字要落笔。
  7. 残差软笔在档之上,会把档里的小东西(高光点)抄没——小到抄不回来的形状,残差要绕开。
  8. pgrep -f "dtp run origin" 再 kill 又把发命令的 shell 杀了一次(exit 144)——AGENTS 里写着,还是犯了。先 ps 列出来看,再按 PID 杀。

v10.0.0 — 书改成手册:origin 退成算例;库只存工序代码;两个子项目各跑一条走法

用户三条批评,核实后都成立:pipelines/ 名不副实(只被 runner.STAGES 三行引用,里面是三道工序,没有串联逻辑——书的目录倒灌进了代码);库里还有一张具体的图的东西(parts/default.py 是 origin 的旧表,compare.py 和两个作业里写死着 origin 的特写坐标);整本书是 origin 的实验报告,不是手册——第一部每章的「按部件」六节和数字都是 origin 上发生的事,第 6 章还留着一个没填的占位符 〔F_p99〕,6 处 work/origin/… 悬空。

做了什么

  • 书:目录重排成六部(上手 / 工序 / 走法 / 底座 / 量与预算 / 算例)+ 附录 A–G。第二部八章同一副骨架(做什么 · 输入 → 输出 · 做法 · 旋钮 · parts.py 的字段 · 审阅与改判 · 会坏在哪 · 在 origin 上);正文不认识 origin,每个数字只在第 18 章和这里;「P1 / P2」改叫「一次成型 / 先素描后上色」;版本史叙事从正文里拿掉,历史归这里;第 16 章的案例保留。加了一档状态标记 👤 人工验证(人看过、非常满意;✅ 只表示写书的人自己觉得过了)。tools/audit.sh 扫这张图的读数 / 版本号 / 部件名,mdbook build 前跑。
  • 布局:一张图一条走法一个子项目(<图>-basic / <图>-pro,再试一次 -2),图在 input/origin.png。work/shinku-hug-basic-1/ 在 dtp 0.2.0 上把一次成型跑到底(第 18 章 18.2):数字与 v9 书第 6 章逐项一致或在噪声内;书里错的四条——源图头发内部 9.1 → 8.4、「六种笔」→ 4 种、「每部件 2–5 档」→ 4–5、307 / 317 混用(307 是部件子树,317 是整棵树);Skin_Face 最暗两档是两只眼睛,不是书里说的「瞳孔 + 嘴 + 鼻孔 + 耳影 + 脖影」;脖子在手写表里和前发是同一个区域,一次成型的脖子是头发画的。
  • 库(0.3.0,见下面待完成)。

输了什么

  • v5 那个 8x 的 trace-origin 文件夹早已不在,v8/v9 的 39 个 step 这次也删了:先素描后上色的读数暂时只剩 v9.0.0 的历史(第 18 章 18.3 标「历史」),要等 shinnku-hug-pro 重跑。

新记录的坑

  1. 发布的书里留了一个没填的占位符(第 6 章 〔F_p99〕)——填数脚本跑完没有再 grep 一遍。mdbook build 前 grep '〔' 必须为空。
  2. 子项目搬家,书里的路径悬空(6 处 work/origin/)。书不引用 work/ 里的路径,只在第 18 章按子项目名指 step。
  3. 库里的部件表模板会腐烂:parts/default.py 是 origin 的表的一份拷贝,比真的那份还旧(Arm 类少了 v9 加的 neck / shoulders)。模板不该是任何一张图的表。
  4. harness 和 step 都能被删掉:krita-bot/ 不在了,trace 的读画正常、作业「harness 没装」;dtp setup 重建、dtp check 31/31 再跑。删掉 step 就删掉了数字的来历——第 18 章每个数字后面写它来自哪个 step。
  5. 307 / 317:作业自己数的和 layers.json 数的不是一个口径。数节点只认 layers.json。
  • 库(dtp 0.3.0):pipelines/ 并入 stages/(trace.py / sketch.py / paint.py / groups.py),走法不是代码;dtp new 把图放到 input/;parts/default.py 改成模板(不是任何一张图的表)、ASSIGN 空时 segment 只出区域图就停、config.check_parts 查自洽;特写坐标只从 CROPS 来(layers.review_crops,三个作业共用;一次成型的作业也写 review/);gauge 只有 trace 也能量、--ref_final 跨子项目对比;测试 14 → 18。三份计划文件(trace / sketch / paint,11 个产物)与 0.2.0 逐字一致——只是搬家。shinku-hug-basic-2 在 0.3.0 上把一次成型再跑一遍:计划逐字一致,两次落笔的判据差 < 0.03(第 18 章 18.2)。

待完成(tag 前)

没有按这个计划完成:pro 跑到第 2 步时走法被否掉,见 v11.0.0。这一版没有打 tag。

v11.0.0 — 走法改成先分层后上色;书的第一部一步一章,线稿拆成子章节

用户看了第 2 步的位图线稿之后否定了「先素描后上色」:名字错(那一步是分层 + 底色 + 线的底稿,不是素描),顺序也错——线在图层存在之前从源图抽一次,再靠「盖住 ≥ 50% 采样点」的启发式归组,被遮住的部分只补一圈不打分的轮廓。要的顺序:先有线稿和部件 → 人用它们定图层 → 每一层交给 diffusion 补完整 → 每一层再抽自己的线 → 用线在 Krita 里画。核对代码:大半已经存在,两处是真缺口——线没参与分层;补全之后没有每层再抽线。

做了什么

  • 库 0.4.0:新工序 lines(每一层单独放在背景上抽线稿,候选池和仲裁复用第 2 章的函数,打分的是这一层的图;每笔带 group / made_up;与 lineart 的一致率);sketch 改名 draft,删掉线归组和遮挡区轮廓那两段;paint 的线深浅按每层的图;segment --walls=<lineart step> --assign_from=<segment step> 线当墙(区域沿线切开,标签继承老表,改判交给多数票);gauge 报编出来区域里的线数;dtp run 先找上游再开 step 目录;线稿审阅图去掉永远不会出现的黄框。测试 18 → 25。
  • pro:step03 线当墙(锁骨上的假头发块没了、鞋头还给鞋,脖子本身 step01 就对;全图 1.24% 的像素变了)→ step04 补齐(Hair_Back 8 圈 +256,831、Skin 7 圈 +163,001、Dress 2 圈 +8,313,叠回差异 0)。后面四步在跑。
  • 书:编号之前的四篇(前言、安装、概念、十分钟)不编号;第一部一步一章:分割 · 线稿 · 补齐 · 分层线稿(新)· 底稿 · 上色 · 判据 · 一次成型;「叠层」「底色」「背景与收尾」三章拆进底稿 / 上色 / 判据 / 一次成型 / 底座的图层章;走法一章;标题里不再写数字(mdBook 自动编号,写了会重复)。线稿一章拆成子章节:两个来源、每一种位图线稿的提取器一小节(Manga2Anime / AnimeLineArt / LineartStandard / 其他十种 📐)、骨架化、仲裁。第 18 章按新 step 记数。
  • 发布:tools/publish.sh(审计 → build → wrangler pages deploy);每次提交后立刻 push,书再部署(AGENTS.md)。tools/links.py 链接检查;tools/audit.sh 扫三层目录。

输了什么

  • 旧走法在这张图上没跑完,先素描后上色的读数只剩 v9.0.0 的历史表(第 18 章标「历史」)。
  • 让线机械地决定图层还没有做,线当墙是第一步;图层仍由人看区域图和线稿图写 GROUPS。

新记录的坑

  1. 线在图层之前抽是顺序错误:归组只能靠启发式,被遮的部分没线。图层有了再抽线,归属由构造定。
  2. 书写的数和代码不符:AI 线的拟合容差书写 2.0、代码 1.2;修剪后最短段书写旋钮名、代码是常数。写数之前 grep 代码。
  3. 图例里的死类别:「黄框 = 输给别的线」在修剪规则改掉之后再没有任何路径产生它,图例和书都留着。
  4. 只看计数判线当墙会反:脖子框里 Hair_Front 变多了,是那缕真头发终于完整;要看特写。
  5. 章号写进标题和 mdBook 的自动编号重复(「5. 4 分割」);上手那几篇不该编号(前缀章)。
  6. 重排脚本把附录 E 里历史版本的章号也改了:那些是当年那版书的号,要还原;E 顶上加了一句说明。
  7. ls */*.md 只扫两层:线稿拆成子目录后审计漏掉一整章。
  8. 本机提交别人看不到:dtp 0.3.0 / 0.4.0 和书的三个提交都只在本机;书的站点是 Cloudflare Pages 直接上传的,不从 GitHub 构建,push 了也是旧版。提交后立刻 push,书再 tools/publish.sh。
  9. dtp run 先开 step 目录再找上游:上游不在就留下一个只有 step.toml 的空 step,编号被占。先找再开。

v11.1.0 — 补齐换成 See-through 合并(dtp 0.5.0)

pro 的 step04 补齐出来的 layer_Hair_Back.png / layer_Skin.png 是黑白斑块,头发层还收进了裙子、腿、鞋。用户判不正确,要求先查原因、再看外面怎么做。

为什么错

主要不是 AI 的错。给模型看的 solo 图里有一个黑色人形剪影:辉光参考图用 σ=24 的归一化卷积从主体外面往里填,只填得到离边约 96 px,再往里是黑的(主体内 21% 黑)。模型顺着黑剪影画黑裙子、黑发;SAM hair 对一张到处是头发和黑块的图把整个人框进来,前 7 圈每圈收 84–99%;颜色规则的对手是背景,任何比背景亮的都算头发;每圈换 seed 拼成斑块。v7 起就这样,当时只看形状没看像素。

查了什么(2026-09-10)

做了什么

  • 装了 See-through(Krita/see-through/ 自己的 venv:torch 2.8.0+cu128,不碰 ComfyUI 的),在 pro 上跑(step05,group offload,348 s):15 层,形状好——后发一整头、额头在刘海下、裙下两条腿、手臂和头发下的裙子;可见部分是重画的:叠回 PSNR 18.4 dB,手指、发丝、蕾丝丢,层边锯齿,丝带拆进头发和裙子。
  • 库 0.5.0:complete 默认 --method=seethrough(stages/seethrough.py):被遮 = 它的层 ∩ 上层区域 − 可见,可见钉死源图,边开闭平滑、只留和可见连通的、黑像素不收、按可见区重叠的均值差调色、接缝羽化 3 px;没映射的组只铺 2 px 接缝;parts.py 加 SEETHROUGH 映射表;--seethrough_from=<step> 复用产物;一圈一圈留作 --method=rings。测试 25 → 30。
  • pro step04(合并 7 s;一圈一圈的那次和 See-through 实验挪成 legacy 目录):Hair_Back +203,988、Skin +59,122、Dress +10,652、Ribbon 1,056、Eye 360,叠回 0。用户:很可以。走法暂停在这里。

输了什么

  • 它的可见像素、丝带层、嘴鼻层不用;它的头发分法(刘海 / 其余)不用,分组仍靠我们的分割。
  • 裙下的躯干不编(它没有这一层)。
  • 每张图要多跑一次 6 分钟的模型,12 GB 以上显存。

新记录的坑

  1. 给模型看的图里不能有剪影:参考图外推填不满主体深处,留下的黑就是模型的提示词。同一个黑剪影后来又进了分层线稿的 solo 图一次(step05-lines-legacy-wrong-1,提取器画出整个人的鬼影轮廓)——线稿提取根本不需要背景,改成只给这一层加平底色(dtp 0.5.2)。参考图本身还没修,只剩一圈一圈的补齐和辉光笔在用。
  2. 取回不能照单全收:SAM 对模型输出的图会把整个人框成「头发」;颜色规则的对手要是别的组,不是背景。
  3. 每圈换 seed 拼成斑块。
  4. 重画的可见像素不能进成品:See-through 的叠回只有 18 dB;用它只取被遮的部分。
  5. 第 3 章要按新做法重写,这一版只加了说明。

v12.0.0 — 库只做原子的事,每个 step 一个 main.py(dtp 1.0.0,2026-09-10)

用户否掉了库里的编排:retire / renumber 这类目录管理、run <名字> <工序> 这类工序调度、Project / Step / step.toml 这整套,都不是库该做的事。「库就应该做库需要做的事情,最好是原子的」;每个 step 里一个临时的 main.py 调库。

做了什么

  • 库:删了 cli.py、runner.py、project.py、命令行入口、部件表里的 parts/default.py;八道工序的 run(step, project, ctx, **knobs) 全删,接线的逻辑各抽成纯函数:segment.class_masks / plan_json / walls_from_strokes、lineart.arbitrate(不再写文件)+ save_review + kept、complete.upper_of / build_layer / restack / review_*、lines.extract_layer / agreement、groups.visible_masks / full_masks / fill_masks(三处重复的「每组可见 mask」规则合一)、draft.check_orphans / plan_json / preview、paint.plan、trace.plan、gauge.tree_rule / made_up_count。新 io.py:固定格式产物的读写(plan / amodal / strokes / mask / 部件表 / README,README 重跑保留人写的「## 结论」)。settings.py 只剩机器路径。一圈一圈 inpaint 的补齐(--method=rings)和 Comfy.inpaint() 一起删了;部件表不再要 AMODAL / PROMPTS / SAM_WORD。
  • 模板:库仓库 templates/<工序>/main.py 八个,templates/project/parts.py、README.md;每个 main.py 头都一样(HERE / ROOT / 部件表 / # ---- 上游 / # ---- 旋钮),main() 接线,末尾写 README。环境类命令变成 tools/ 六个脚本。
  • 目录约定归人:phase 按里程碑、编号全局连号、错的 step mv 成 -legacy-wrong-<k>、同一个号重跑、引用用 grep 改——都写进 AGENTS.md,不再是代码。每跑完一步报告、等裁决。
  • 验证:算例子项目活着的五步(分割 → 线稿 → 线当墙的分割 → 补齐 → 分层线稿)各放一个 main.py,在一份拷贝里顺序重跑(ComfyUI 的地址指向死端口,缓存漏了就炸而不是悄悄重生成),产物和原来的逐字节比:.npz 比数组(压缩包里带时间戳),其余 cmp——84 个产物全部相同,唯一的差别是线稿那一步的审阅图图例文字(原来的是 0.3.0 跑的,图例在 0.4.0 改过)。测试 34 → 38。
  • 书:「十分钟」「安装与自检」「概念」按 main.py 约定重写;附录 A 从「命令速查」变成「跑法速查」;B / F / G 跟着改;八章的状态行「命令」改成「step 模板」。

输了什么

  • 没有「默认取最新的上游」:上游写死在 main.py 顶上,换上游改常数。
  • 没有 --k=v:旋钮是常数;一次试验就是改文件再跑。
  • 没有编号器、没有退役命令:mkdir、mv、grep。

新记录的坑

  1. np.savez_compressed 的产物逐字节不稳定(zip 项带修改时间);比对要 np.load 后比数组。
  2. 提取 arbitrate() 时它顺手写审阅图的副作用要单独成函数,不然纯函数的调用方没法决定文件叫什么。
  3. 三处各写一遍的「每组可见 mask」规则(补齐、语义组、底色)本来就该是一个函数;合一时 min_px=12 的去小块不能漏。

v13.0.0 — 上色按部位:第 6 章一部位一页(占位 ⬜),库 dtp/paint/ 一部位一模块(dtp 1.1.0,2026-09-10)

用户看了 shinnku-hug-pro 的 step07-paint(第一次用 1.0.0 的 main.py 跑到底的上色):「画出来的内容很垃圾」。它是抄像素——误差驱动的软笔(airbrush 三个尺度)和十四万笔硬圆头小笔在组内把源图的明暗复刻一遍,1x 规划、4x 落笔,整张糊;没有干净的阴影形状,头发是随机发丝加喷枪,荷叶边是几万个小圆点,眼眶的线沿着 SAM 的 mask 走。五项判据全过、likeness 比旧走法高,人一看:不是画。用户的方向:上色按部位分,不同部位完全不一样的画法,人决定每一层用哪个;这张图里没有的部位先占位;先改书的编排,再一个个部位讨论,头发第一个。

做了什么

  • 书:第 6 章变成目录 01-工序/06-上色/:README 是总纲(为什么按部位、PAINT 表、基线「抄」、线、笔、树、存盘、交付),十二页一部位一页——头发 🧪 · 皮肤(脸 / 脖子 / 手臂 / 手 / 大腿 / 小腿 / 脚)🧪 · 眼睛与嘴 🧪 · 丝袜 ⬜ · 衣物 📐 · 鞋 📐 · 金属与饰品 ⬜ · 建筑 ⬜ · 树木 ⬜ · 天空与云 ⬜ · 水 ⬜ · 背景 ✅;每页同一副骨架(画师怎么画 · 源图里它长什么样 · 我们的做法 · 图层 · PAINT 怎么写 / 旋钮 · 判据 · 会坏在哪 · 要定的事 · 在 origin 上)。新状态标记 ⬜ 占位(骨架先立着,没研究;只有画师怎么画和要定的事,代码:无)。做过的三个部位成品被判不过,从 ✅ 回到 🧪。全书章号不动(照第 2 章「线稿」子章节的先例)。
  • 库 dtp 1.1.0:stages/shade_{skin,hair,eye}.py 和 shade_parts.py 的分派拆成 dtp/paint/——一部位一模块(skin / hair / eye 各带 plan()),__init__ 的 METHODS 登记表 + plan_group(),base 共用的原子(参考图、遍数、档内平涂),generic 基线「抄」(明写不是画法),background 辉光,八个占位模块(docstring 是画师怎么画 + 要定的事,plan() 报「还没定」带手册的页名)。config.check_parts 对着登记表查 PAINT 的 method 名——以前名字打错要到上色那一刻才 KeyError。stages/shade.py 只剩线的深浅和 PSNR,死码 shade_groups() 删了。测试 38 → 41。
  • 行为不变:搬家前后在一份拷贝里重跑 step07 的计划(RUN_JOB = False,51 s,不起 Krita),strokes_paint.jsonl / plan_paint.json / plan_draft.json / strokes_lines.jsonl / preview_paint.png 逐字节相同,masks_paint.npz 比数组相同;搬家前的那份和真 step07 的也逐字节相同。
  • 算例:step07 / step08 判不过,挪成 -legacy-wrong-1,同号等新画法;第 18 章补上底稿、上色、判据三段和两条走法的实测并排(一次成型 34.79 / 内部 9.5 vs 基线「抄」31.03 / 5.8),四张同位置对比图。

输了什么

  • 通用做法降为基线,不再是任何部位的画法;它仍是 "*" 的默认(用户定的:没定画法的组照旧抄),所以下一次上色跑出来的还是抄,直到各部位的画法一个个定下来。
  • 八个部位只有骨架没有代码;两个设计过的(衣物、鞋)也还是骨架。
  • 头发的 hair 做法还是上一轮那套(亮脊定缕),没过门槛;重做从讨论开始,不从代码开始。

新记录的坑

  1. 五项判据全过,人判不过:判据管「像不像人画的」(梯度、色数、阶梯、低频、锐度),管不了「是不是画」——误差驱动的复刻能把五项全刷过。审阅不能只看数字。
  2. PAINT 的 method 名打错以前到上色才炸(KeyError),部件表检查漏了它;登记表 + check_parts 补上。
  3. 基线的遍数表按组名特判两个组("Eye" / "Ribbon"),违反「库里没有部件名」——这次为保逐字节一致原样搬进 paint/base.py,逐部位重写时消灭。
  4. 一张 3088×1044 的同位置对比图贴进对话里,agent 看不见;用户看的是 cmp/ 里的那些图,报告里要指名文件。

v14.0.0 — 从「一串笔」到「一棵图层树」:库分五层,落笔作业合成一个,判据换成「描述数」(dtp 2.0.0,2026-09-10)

用户看了三轮上色成品后判定:「最大的问题,还是你只用了笔刷,并且不是遵循绘画的逻辑,整个代码逻辑从一开始就是错的,dtp 本身这个库也得再次拆分,拆分成更原始的原子库和组合库。」勘查下来这个判断成立,而且比表面更严重。

证据(step07 的 66,823 笔):笔长不足五个笔半径的有 91.2%——那是点不是笔,语义上等于 set_pixels 换个 API;真正「顺着形体走的一笔」(发丝 + 缕缝)只有 0.20%;19.9% 在「把一块区域填满」;矢量层已经解析级填对的形状(边界带偏差 0.0828,全库最好、分辨率无关、免费)被笔盖到只剩 < 2.5% 可见——同一块面积画两遍。Session 三十个公开方法,三个落笔作业只用九个:选区、52 个 filter、erase、TransparencyMask、FilterMask、19 个 gradient 全部零使用;39 个可用混合模式,全流水线只用过一次 soft_light。

根源是输出模型。 画师的动作是「建一棵图层树:填充 + 蒙版 + 混合模式 + 不透明度 + 少量滤镜 + 笔刷」;我们的输出模型是「一串笔」。于是阴影没法写成「乗算 65% 的一层」,只能把合成后的 RGB 算出来平涂——这就是阴影发闷发粉的原因;「先画整条光带再擦断」没法表达。逼出它的是两条判据:「写像素 ≤ 2」把「不许抄源图」和「只许用笔刷」混成一条,「笔画盖住 Shape ≥ 95%」直接要求把矢量填充盖掉。

用户裁决:中间产物换成一棵图层树;判据换成**「每一笔都能用几个数说清楚」**;先重构,头发在新地基上重做;一个包里分层 + 硬依赖规则(测试卡住),不拆成两个仓库。

做了什么(六个阶段,每阶段仓库都能跑、测试全绿)

  • 阶段 0 冻结基线:tests/test_frozen.py——四个画法过一份玩具输入比 sha256。整条链以前零回归测试,这是迁移唯一的护栏。
  • 阶段 1 分五层:按「谁做决定」切成 原子(geometry raster ir)→ 能力 / 看图(ops read)→ 画法词汇(craft)→ 部位(paint)→ 工序接线(stages)+ 横切的尺(gauge)。按函数切不按模块搬(paint/hair.py 422 行横跨三层)。四处依赖倒挂全清(config → paint 改参数注入)。tests/test_layering.py 用 AST 扫全部模块含函数体里的 import,卡住 rank。产物逐字节不变。
  • 阶段 2 图层树上线(只读):ir/{schema,build,count,render,svg}.py + stages/tree.py 转换器。检查点:不开 Krita 就把树画出来,比老的 1x 模拟更贴近真成品。
  • 阶段 3 三个落笔作业合成一个:删 jobs/{draft,p1_trace,p2_paint}.py(757 行),换成 ops/bake.py + jobs/bake.py——树是数据,执行器不认识画法。三条走法各真跑一遍对账(新探针 probe_bake_equiv.py,按层逐笔比作画日志)。
  • 阶段 4 填色不用笔:删档内平涂 wash_fill 和它的三个调用点、底稿的扫满(1,341 笔)、判据「盖住 Shape ≥ 95%」。三个调用点上一行都已经是 sim.rgb[mask] = col,矢量层也已经填了同一块形状——那些笔画不出任何新东西。
  • 阶段 5 能力补齐 + 判据切换:ops/atoms.py(零审美常数的能力原子);背景 11,991 笔 → 一张 32×48 的色场;档的边 → 一个高斯模糊 FilterMask;判据换成「描述数 / 源图数 ≤ 5% + 裸写像素 = 0」。两个新探针。

赢了什么(step07,同一份输入)

阶段 3(等价重跑)阶段 5
图层树节点6451
Brushes68,31650,855
description numbers over source numbers16.54%12.61%
Krita 落笔264.6 s180.2 s

档的边从「比源图还硬」回到源图之下;头发内部锐度回到源图之下;梯度 p99 是源图的 100%。三条走法的节点路径、clip/blend/opacity、作画日志的 by_op 计数全部与老作业一致(读数见第 18 章)。

输了什么

  • 低频 likeness 掉了一个多 dB,还在 30 的门槛之下。删掉的那些笔在偷偷干一件事——把档的边揉软;阶段 5 用模糊蒙版补回了档边,但整体的「像」还没回到重构前。
  • 描述数 12.61% 仍然超 5% 的门槛,而且 96% 集中在三层 _Shade(Ribbon 33.5% · Dress 25.6% · Skin 14.8%)——那是基线「抄」的软笔逐点补差。判据现在明确指着它说「这里在用点重编码源图」,但要等各部位的画法一个个重写才动得了。
  • 头发还是上一轮那套,没在新地基上重做。
  • paint/ 没改名成 parts/(原计划要改),审阅图的耗时在带模糊蒙版的树上明显变长。

新记录的坑

  1. 一层可以混几种笔。 IR 的 strokes 节点只有一支笔,第一版把 <G>_Shade 里 35,631 笔硬圆头的 sharp 全按软气刷的 shade 画了(两者宽度系数还差 7%)。改成「和层的笔不一样时这一笔自己带一支」。
  2. 一笔只能落一层。 老作业的 (组, 笔种) → 层 字典是后建的覆盖先建的;不抄这个覆盖语义,68,316 笔会变成 99,921 笔,31,605 笔画两遍。
  3. 整幅纯色的填充必须是画层。 建成矢量层的话 setPixelData 写不进它的 projection——成品少五个 dB,一句报错都没有。
  4. Krita 的笔刷有随机性:同一棵树连跑两遍也不是逐位相同。像素比对天生有天花板,PSNR 门槛只在那个尺度上有意义;要精确到「哪一笔不一样」得比作画日志(probe_bake_equiv.py),而且要按层比——层之间谁先画不影响成品。
  5. 测试自己也会静默通过。 新加的 pyflakes 检查第一版只读 stdout,没装 pyflakes 时报错走 stderr、stdout 是空的,于是它对着零行输出「通过」——种一个假的未定义名字进去照样绿。检查外部工具的检查,必须先确认它真的跑了。
  6. 搬家留下的哑弹:stages/draft.py 的 plan_groups 用了 11 个在阶段 1 搬走的名字,read/tiers.py 3 个,geometry/skeleton.py 的 __all__ 里 2 个不存在的函数——全都 import 得动、70 项测试全绿,一跑底稿那一步才 NameError。整包扫「名字有没有出处」是一秒钟的事,比一次六分钟的 Krita 作业便宜太多。
  7. 渐变生成层不能用:createFillLayer("gradient", …) 层建得出来,但给它的参数一个都不生效(五种 shape 画出来一样、前景背景色没用、start_position_x 存进去还是 0);键名里根本没有 stop 的颜色。对照组 color 生成器的 setProperty 是生效的。⚠ seexpr 生成器会让 Krita core dump。
  8. 一层最多挂一个 FilterMask:24 个挂同一层是 O(n²)(每加一个 +1.6 s),挂 24 层各一个是 O(n)(+0.08 s)。
  9. 计划里写死的检查点数字会立不住:「预览对 preview_paint.png ≥ 40 dB」这条根本不可能达到——老预览填的是 amodal 掩码不是矢量形状,它自己对真成品也只有 29 dB 出头。检查点要对着能达到的那个基准定,定之前先量一下基准是多少。

v15.0.0 — 头发:一缕一根圆柱,两条渐变(dtp 2.1.0,2026-09-11)

图层树的地基铺好之后,第一个在它上面重做的部位。用户:「头发在新地基上重做,重做到你自己满意为止。」

诊断先于动手。 把两组头发抠出来和源图并排,一眼看见的不是「档不够细」,是粉丢了:源图里明显偏粉的像素占 56.4%,成品只剩 34.3%。源图的头发从发根到发梢有一条黄到粉的色相位移,而一缕只有一个固有色加几档量化的影,那条位移被整个抹平。五项判据当时全是绿的。

做了什么

一缕 = 一根圆柱 = 两条渐变(新的画法词汇 craft/cylinder.py):顺轴一条绝对色(这一缕被照到的地方是什么颜色),横切一条乗算(光从芯往两边怎么衰减)。分成两条不只是为了像——画师想换发色改前者,想调光改后者。天使の輪仍是先画整条再沿缕缝削断;缕缝、散发、毛先留给笔。误差驱动的残差笔全删。

画法从此产的是一棵子树,不是一串笔(entry["nodes"],相对 id,stages/tree.graft 接上去)。这是 v14 那句「画法产的是子树」第一次真的兑现。

赢了什么(读数见第 18 章)

两组头发的描述数少了 47%,笔从约 5,300 根降到 210 根,头发区残差 17.43 → 13.05,偏粉回到 53.7%,组内 8 级色数 11 → 101(源图 123)。组的直接子节点 6-7 个,九十几个缕折在 Locks 一个文件夹里,画师展开就是一缕一条。

输了什么

  • 分缕在细缕密排的地方把几条真发缕并成一条;参数扫过,残差只小幅改善。
  • 上游:分割那一步的 subject 掩膜在左侧有洞,丢掉的发丝约相当于头发面积的 15%。画法只能画掩膜说有头发的地方。
  • 落笔比上一版慢(多了一百多个渐变层);加了 batch 之后 248 笔/秒,还是不如没有渐变层时的 646。

新记录的坑

  1. 顺轴那条渐变要取亮部色,不能取中位色。 取中位的话横切的乗算因子会大于 1(要提亮),而乗算做不到提亮;把提亮折进绝对色又会被 255 截断(金发的 R 本来就 250)。实测残差因此从 12.0 掉到 15.3——而且一点报错都没有。
  2. 预览器的组返回的是预乘颜色。 父节点再按直通 alpha 合一次,每个组的半透明边就和黑底混一遍;一张几十个组的树上是几十圈暗边(头发那棵残差 12.9 变 18.1)。笔画节点早先踩过同一个坑(Sim 在黑底上叠),组这一份漏了。
  3. 缕与缕之间不能互相 clip。 clip 继承的是同一个组里下面已经累积的 alpha,缕组互裁的话第一缕底下什么都没有,直接消失。
  4. 换画法的时候把「完整形状」丢了。 新画法顺手用可见掩膜造剪影,被身体挡住的那半边头发就没了(Hair_Back 的剪影从 277,820 px 缩到 69,777)。amodal 是这条走法的立身之本,改画法时最容易忘。
  5. 一条线不要存两遍。 线同时存了贝塞尔和采样出来的折线,而落笔和预览都只看前者——白花一倍的描述数。
  6. 执行器不许自己造节点。 bake 曾给带形状的渐变自动挂一个透明蒙版去裁,于是 Krita 的树比 tree.json 多出 61 个节点,「树是数据、bake 只是执行器」这句就不成立了。改成把形状烘进渐变自己的 alpha。
  7. 条带比渐变贵三倍。 想用「量化成几档 + 模糊揉回去」代替渐变:每多一档就多一圈轮廓(一缕约 100 个数),而渐变把整个剖面压进二十个数。
  8. 一百多条渐变必须各带自己的 box。 不给的话每条都往整幅 4096×6144 写一遍 RGBA。
  9. 新判据「每组 8 级色数」。 把一块有色相位移的东西量化成几个平色,全图色数几乎不动(别的组撑着),这一组却塌掉——而梯度 / 阶梯 / likeness / 锐度全部正常。按组拆开才看得见画法的毛病。

v15.1.0 — 双语基建:gettext 跑通,锚点改成 ASCII,审计和断链检查覆盖两语(2026-09-11)

用户:「手册也变成多语言版,一个中文一个英文,你看看 mdbook 支不支持多语言。」mdBook 0.5.4 没有原生多语言,官方的做法是 Google 的 mdbook-i18n-helpers(gettext)。

整个计划里只有一个真的未知数:那个插件的 0.4.0 和本机的 mdBook 0.5.4 对不对得上。对不上就得改成两套 src/ 目录——所以它排在翻译任何一个字之前。验证的办法是不带 .po 构建现有的中文书:53 个 HTML 与原来逐字节相同,再生成 po/messages.pot(3,267 条)和空的 po/en.po,MDBOOK_BOOK__LANGUAGE=en 构建确认替换生效、没翻的回落中文。

中文 src/ 仍是唯一正本,英文是 po/en.po 里的译文。中文留在站点根、英文在 /en/:这本书发出去过,把中文挪进 /zh/ 会让所有既有链接一次全断,换不到任何东西。

做了什么

  • 给库里 cite= 引用的九个标题加 ASCII 稳定锚点(## 底色 { #flat-color } 这类),翻译之后照样有效。
  • tools/links.py 补上锚点校验(以前 # 后面全扔),并扫到仓库根的 README.md。
  • tools/audit.sh 改写成 tools/audit.py:同一条内容策略两张模式表,中文扫 src/,英文扫 po/en.po 的 msgstr。
  • tools/publish.sh 两语构建,并且先 audit 再 links(AGENTS.md 早就要求跑 links.py,一直没跑)。
  • tools/figures.py 按 locale 出两套图;顺手修好 dtp 2.0.0 之后就 import 不到的模块名和三个笔键。
  • 删 19 张孤儿图(8.8 MiB,v6/v7/v9 时代的截图,正文早就不引用了)。

新记录的坑

  1. 跨了仓库边界的检查,等于没有检查。 库里 11 条 cite= 有 8 条指向不存在的标题,从 dtp 0.x 烂到 2.0.0 没人发现——因为验证它要同时打开两个仓库,于是两边都默认对方在管。
  2. 只审中文正本 = 英文版永远不被审计,而英文版正是外部读者读的那一份。审计脚本一旦和某一种语言长在一起,多出来的那一版就是盲区。
  3. 根 README.md 指着 src/04-教训/…,那个目录两版前就改叫 04-量 了。链接检查只扫 src/ 不扫仓库根,就会漏掉门口这一条。

v16.0.0 — 库全面英文化与规范化(dtp 3.0.0),手册出英文版(2026-09-11)

用户:「把整个库完整的添加注释,并且全部改成英文……然后每个函数的命名都得正确清晰,实现优雅。参考 Google Python Style Guide。」定下来的边界:运行时输出和产物一起换英文;docstring 走 Google 风格;跨边界的契约名也一起改;行宽 100;不写产物迁移脚本,两个子项目从 step01 全重跑。

做了什么

  • 英文化:3,071 行含非 ASCII → src/ templates/ tools/ probes/ 全部归零,tests/ 剩 2 行(一行是手册的中文目录名,一行是给 CJK 标题切 slug 的正则,都是结构性的)。翻译不是缩写:每一条实测数字和它的出处都留着。
  • 命名:废掉 35 种大写模块别名(CP 在一个文件里指 complete、在另一个里指 copy);L() G() SN() _one bucket frm filt as_ 之类改成说人话的全名;契约名一起改——parts.py 七个字段、笔的五个键(R op bez pts src)、画法名 tiers → shadow_tier、why= 的包名、step README 里人写的 ## 结论 → ## Verdict。
  • 规范变成闸门:tests/test_style.py 四道(ruff E,W,F,N,D / 非 ASCII / why= 解析 / cite= 解析)。前两道是只许变小的棘轮:旧账写成一张计数表,新违例立刻红。ruff 从 2,312 条压到 1,152 条;why 和 cite 的例外表现在是空的。
  • 手册:2,463 条带汉字的条目译掉 1,950 条;除附录 E 和第 18 章外全书 100%。
  • 全重跑:两个子项目从 step01 跑到底,旧产物作废。13 份人写的裁决一条不少地活了下来(重跑前 13 份,重跑后 13 份)——靠 write_readme 在过渡期里两个标记都认。

赢了什么

  • 一份非中文读者能接手的库和手册。
  • 三次逐字节的证明,而不是「我看着像没改坏」:阶段 1–3 只动注释和内部名字,86 项测试全绿、产物三次重放逐字节相同、31 项 Krita 自检 31/31;阶段 4 的契约改名把改名映射套回改名前的产物,16 份里 15 份逐字节相同。
  • 重跑之后有两个数字变了(线 237 → 246、描述数 56,593 → 21,900)。用本次改动之前那个提交开一个 git worktree、对着重跑前的产物副本再跑一遍 step05 / step06,结果完全一样——变化是上一版带来的,不是这次改坏的。没有这一步,这两个数字会一直挂在这次改动头上。

输了什么

  • 附录 E(266 条)和第 18 章(249 条)不译,两页页首各挂一句说明。它们是历史账不是教学内容,而且版本史每改一次书就要追加一节,翻译它等于给每次改动加一倍工作量。
  • IR 节点上的显式 role 字段没做。本来是阶段 3 的一项:让 ops/session.py 读字段,而不是从图层路径 rsplit("_")[-1] 反推。放弃的理由是它会给每个节点加一个当时没人读的字段,和阶段 3「产物逐字节不变」这把最强的锁直接冲突,也和「Krita 图层名不动」的决定绑在一起。反推还在,这是这次留下的唯一一处隐式约定。
  • ruff 还欠 1,152 条(E501 482、N806 298、E702 148 打头,多是对齐的数据表和 a = b; c = d 这种成对赋值)。棘轮保证它只会变小,但它现在不是 0。
  • 双语的术语表没有单独一份:它就是附录 D 加它在 po/en.po 里的译文。多一份 GLOSSARY.md 就是多一处会漂的副本。

新记录的坑

  1. 一次成型那条走法从 dtp 0.2.0 起就是断的。 stages/trace.py 调 background.glow(),而那个函数在 0.2.0 被删了。从 0.2.0 到 3.0.0 隔着十几个版本没人发现,因为两个子项目的 trace step 都是 0.2.0 之前跑出来的,之后没人重跑过。全重跑第一次把它照出来。产物还在,不等于产它的代码还跑得动——「重跑一遍」本身就是一项检查。
  2. 正则抓日志 = 抓不到和真的是 0 长得一模一样。 gauge/report.py 的 node_count() 抓 共 (\d+) 节点,而 ops/bake.py 打的是 [tree] %d 节点——没有「共」,永远返回 None,模板一直在用它。现在改成从 layers.json 数。
  3. 来历只是字符串,没有东西解析它。 四条 why="parts.*" 指着一个在 dtp 1.1.0 就改叫 paint/ 的包,于是每一份 tree.json 里的来历都指向一个不存在的模块,两版无人察觉。test_why_resolves 现在解析每一条。
  4. 翻译会悄悄换掉格式串的参数顺序。 我把 templates/lines/main.py 里一句话的两个占位符换了位置而没换参数,README 于是写出「228% of these 85 visible lines」。每个数字都是真的,句子是错的;除了人读那一句,没有任何闸门会红。
  5. _ 是个已经被占用的名字。 拿它当 gettext 的翻译函数,和 lab0, _ = ... 这种丢弃变量的写法直接撞车。改叫 tr()。
  6. 先格式化再翻译等于不翻译:tr("%d 条线" % n) 查的是已经填好数字的那一句,永远查不到。要写成 tr("%d lines") % n。
  7. msginit -l en 会把 msgstr 填成 msgid。 于是覆盖率一上来就是 100%,而审计扫 msgstr 时扫到的全是中文。英文的 .po 要从 POT 手工生成,msgstr 留空。
  8. 改一个已翻过的句子时,同一处改动要同时套到 po/en.po 的 msgid 上,否则那条译文掉回未翻。附录 G 那次改六个字段名,24 条译文就是这么保住的,msgmerge 之后 0 条 fuzzy。
  9. 审计的例外要按「每一页」算。 一条译文可以出现在好几页上(#: a.md:3 b.md:7);只有每一页都在例外里,这一条才算例外。第一版写成「看第一条来历」,等于给例外页共享的句子开了个后门。
  10. origin 在英文里是个普通名词。 这本书里它是算例那张图的名字,而正文不许出现这张图。审计抓到两次:「coloured by origin」和「origin top left」(坐标原点)。中文那张模式表抓不到它们——两种语言的模式表不是同一张表的翻译,各自要挡各自语言的意外。

v17.0.0 — 正本掉头:英文成为唯一正本,中文退成 po/zh.po(2026-09-11)

用户:「书得主英文,副中文,里面的代码都的是纯英文。」v15 和 v16 的双语是中文正本 + 英文译文,英文挂在 /en/。这一版把方向整个掉过来。

做了什么

  • 正本掉头。 po/en.po 里 1,950 条译文按它自己记的行号替换回 src/——不是重新渲染一遍 markdown,而是逐行定位、只换那一句,所以表格的竖线、对齐的列、代码围栏一个字符没动,diff 是纯粹的语言改动。对照物是 mdBook 的 markdown 后端渲染出来的英文版:53 页里 50 页正文逐字相同,剩下三处是 SUMMARY.md 的前缀章(mdBook 把它们并成一条 msgid)和一个破折号。
  • 页名改成 ASCII,49 页:01-工序/05-底稿.md → 01-stages/05-draft.md,99-附录/G-部件表字段.md → 99-appendix/G-parts-table-fields.md。全书的相对链接按「解析 → 映射 → 相对化」重指了一遍,两个中文锚点换成稳定的 ASCII 锚点。
  • po/zh.po 由 en.po 反转而来:旧的 msgid(中文)变成 msgstr,旧的 msgstr(英文)变成 msgid,再按新抽出来的 POT 对号入座。po/en.po 删除。
  • 英文在站点根,中文在 /zh/。 旧链接两批(旧的中文页在根、旧的英文页在 /en/)由 tools/_redirects 的 100 条 308 接住,publish.sh 把它拷进 book/。
  • 代码块里的 222 行中文全部译掉:概念页的目录树、两条走法的流程图、一次成型的图层树、命令速查、封面的工序表。gettext 不抽代码块,所以这一批不在那 1,950 条里,只能手工。
  • tools/figures.py 的标签正本也掉头:ZH 表是「英文 → 中文」,默认出 fig-*.png(英文),--lang=zh 出 fig-*-zh.png;查不到中文的标签跑完一次报出来,不再一条 KeyError 只报第一个。
  • tools/audit.py 两张模式表对调:英文扫 src/,中文扫 po/zh.po 的 msgstr。
  • 库跟着走(dtp 3.0.1):八条 cite= 改指 ASCII 页名,test_style 的非 ASCII 欠账从 2 变成硬零——最后那两行本来就是手册中文页名的影子。

赢了什么

  • 外部读者拿到的根 URL 就是英文,路径也是英文。这才是「主英文」。
  • 库里再没有一个中文字符,那道闸门从棘轮变成硬零:以后冒出一行中文立刻红。
  • 改书的流程比以前短一环:以前改中文正本还要记得把同一处改动套到 en.po 的 msgid 上,现在改英文、msgmerge、翻新出来的空条目,就完了。

输了什么

  • 中文版有 0.23% 的用词漂移(21,493 个词元里 50 个)。原因是翻译有损:中文的「现象」和「症状」都译成 symptom,「改法」和「改哪里」都译成同一句,掉头之后 gettext 一个 msgid 只能配一条译文,分不回去了。中文版从此是英文的译文,用词比原来统一,但不是当初手写的那一份。
  • 中文版的代码块、树形图、示意图全是英文——这是「代码纯英文」这条要求的直接代价,接受。
  • 旧的中文 URL 全部变成 308。 v15.1 那一节写着「中文不挪进 /zh/,挪一次既有链接全断」;这一版推翻了它,理由是「主英文」这个要求本身就要求根是英文。代价用重定向表缓冲,不是消除。
  • src/assets/ 里 v9–v13 时代的历史截图文件名还是中文。它们只被两篇不译的中文页引用,改名只有风险没有收益。

新记录的坑

  1. .po 的转义必须一遍扫过去,不能连着 replace。 s.replace("\\n", "\n") 会把 MathJax \\neg 里那个 \\n 当成换行、\\text 里那个 \\t 当成制表符。19 处 \\t 加 1 处 \\n 就这样烂在已经发布的英文版里,而且没有任何闸门会红:公式渲染不出来,只有人眼看得见。这个 bug 同时在翻译工具和 tools/audit.py 里,写替换工具的时候才炸出来。
  2. 按行号做位置替换,要从文件末尾往前做。 .po 记的是原文的行号;只要有一条译文的行数和原文不同,后面所有行号立刻错位。第一版顺着做,01-分割.md 从第 77 行起全线脱靶。
  3. 翻译是有损的,方向一掉头这份损失就固定了。 上面那 50 个词元不是 bug,是信息在第一次翻译时就已经丢了。要是早知道会掉头,当初就该在译文里保住区分。
  4. 覆盖率 100% 不等于页面上没有另一种语言。 gettext 不抽代码块,所以「2,463 条全译完」的同时还有 222 行中文躺在 ``` 里。统计的分母是工具能看见的东西,不是读者能看见的东西。
  5. tr() 套两层,第二层必然查不到。 原来的 tr 在正本语言下是恒等函数,所以「先 tr 再拼字符串再进一个也会 tr 的函数」这种双重调用一直看不出来;掉头之后第二次查表落空,中文版会悄悄退回英文。改法是让接收方不再翻译,标题进来时就必须是最终文字。
  6. mdBook 的 markdown 后端是现成的对照物。 MDBOOK_OUTPUT='{"markdown":{}}' mdbook build 会把 gettext 的译文渲染成 markdown;拿它和自己做的替换逐页比正文,就能证明替换是忠实的。不用它的输出当正本,是因为它会重排表格、把 README.md 改名成 index.md——手写的对齐全没了。

v18.0.0 — 库拆成七个包:一个 rank 一个 distribution,import 路径一个字没改(dtp 4.0.0,2026-09-12)

dtp 从一个 distribution 拆成七个(dtp-core / read / craft / paint / stages / gauge / krita)外加一个只有依赖的伞包 dtp,都在 dtp/packages/ 下,仍是同一个 git 仓库。业务代码零改动:七个包用 PEP 420 命名空间共同拼出 dtp.,所以 import dtp.craft.copy、库里 24 处 why=、work/ 下所有 main.py 全不用动。真正改的只有「库是一棵目录树」这个假设藏着的那二十来处。

拆之前先量了两件事:按 rank 一刀一个包,distribution 级依赖图本来就无环(test_layering.py 的 ALLOW 表已经把形状养好了);以及改 import 路径的代价远大于收益,所以选了命名空间包。

赢了什么

  • rank 规矩从一道闸门变成两道。 原来只有 AST 那一遍;现在每个包在自己的 pyproject.toml 里声明依赖哪几个 dtp-*,test_layering.py 拿它和 AST 扫出来的边对账。同一条规矩写两遍,两遍不许对不上。uv 还在更前面卡了一道:workspace 依赖漏登记 tool.uv.sources 直接装不上。
  • run_job.sh 的 PYTHONPATH 改成运行时从 $DTP_SRC 取,生成时烤进脚本的那份只作兜底。以后加一个包也不用重跑 setup_harness.py——这比拆包之前更稳。
  • 顺手清掉 22 个 probe 里 PYTHONPATH 时代之前的死代码(sys.path.insert(0, expanduser("~")),指向的 HOME 现在会被 harness.setup 主动扫干净)和 gauge/lines.py 的自插路径。F401 从 13 降到 3,E401 从 17 降到 8。
  • 文档漂移修了一处:AGENTS.md 写着「jobs/ 只许 import ops 和 ir」,而 ALLOW["jobs"] 一直放行 gauge(jobs/bake.py 要它出审阅图)。按代码为准改了文档,并让 dtp-krita 明着依赖 dtp-gauge。

输了什么

  • src/dtp/__init__.py 那 31 行架构 docstring 没了。 命名空间包不能有 __init__.py。搬进了 dtp/README.md,但它不再是 import dtp 之后随手 help() 得到的东西。
  • 手册 28 页正文里的 dtp/src/dtp/<包>/<模块>.py 全改成 import 路径 dtp.<包>.<模块>。 好处是以后再挪目录永不失效,代价是这一版 po/zh.po 里 174 处 msgid 跟着变,得重过一遍。
  • 多了 8 个 pyproject.toml。这是拆包的固定开销,没有办法。

新记录的坑

  1. 删掉 __init__.py 之后,sys.path 上任何叫 dtp 的目录都成了命名空间 portion。 仓库目录 dtp/ 自己就是一个——工作区根当 cwd 时它必然在 sys.path 上,于是 dtp.tools、dtp.tests、dtp.templates 全部意外可 import,还会混进算给 Krita 的 PYTHONPATH 里。settings._srcs() 因此只收父目录名是 src 的 portion。原来那个「一个包挡住其它同名目录」的保护,是 __init__.py 白送的,删了才发现。
  2. 闸门在拆包后不是报红,是静默空过。 test_layering.py 用 ROOT.rglob 扫一棵树,包搬走了就一个文件都扫不到;「新包必须登记」那条是减法,扫到零个包照样通过;两个 jobs 测试 glob 到空目录也照样通过;pyflakes 只指着那一棵树。一道会空过的闸门比没有闸门更坏,因为它还在绿着。改法:所有发现都走多根,「必须登记」改成双向相等,jobs 测试加一条「至少四个作业文件」。
  3. 同一次拆分里,硬炸的那一处反而是好事。 test_style.py 的 SCANNED 里有字面量 "src",ruff 拿它当命令行参数,目录没了直接非零退出——一秒就红。它和上一条是同一件事的两个极端:把路径当参数传出去的会炸,把路径拿来遍历的会空过。
  4. 判断「纯搬家」的尺子,必须自己不碰文件系统。 test_frozen.py 是全套测试里唯一一个纯 import、零路径的,四个 sha256 一位没变,这才是「代码没搬坏」的证据。其余测试改到全绿只能说明测试改对了。
  5. 拿既有 step 产物做逐字节比对,先把与本次改动无关的历史变化摘出来。 重跑 step07 的规划半段,strokes / masks / preview 全部逐字节相同,但 tree.json 和 plan_paint.json 不同——差异全是 cite= 的页名(v17 改 ASCII 页名那个提交干的,比这次改动早两小时)和 source.path 里子项目改过名。把这两项归一化之后 237 个节点逐节点零差异。没查来源就下结论,会把一次干净的搬家判成搬坏了。

v19.0.0 — 第二部拆成两章;正文和库的代码脱钩(2026-09-12)

第二部「走法」原来只有第 9 章一页 91 行,把两条走法挤在一起:两张 ASCII 链图、一张 phase 表、几段对比。它是概览,不是流水账——没有一个 step 一节,连每个 step 读什么、写什么、人该看哪张图都没写。这一版拆成第 9 章(一次成型)和第 10 章(先分层后上色),一个 phase 一节、一个 step 一小节;后面第 10–18 章整体后移成 11–19,九个页面文件跟着章号改名。

同一版里还做了第二件事,而且是它决定了第一件怎么写:正文不再引用库的名字。v18 刚为了库拆包把 28 页头部的目录路径改成 import 路径——第二遍了。这一版把规矩立死:正文只讲人干的事,名字全查附录。

赢了什么

  • 正文里 427 处库的名字清零(产物文件名 151、部件表字段和旋钮 119、import 路径 47、模块文件名 40、函数名 37、模板与脚本路径 19、探针名 14),分布在 42 页上。附录接住它们:附录 B 新增「一道工序调哪些模块」一张表,附录 F 新增审阅图文件名一张表,附录 A 新增八道工序的上游常数与旋钮名一张表,附录 C 的「章」一栏重填。
  • 加了一道机器闸门。 tools/audit.py 新增 audit_code():英文扫 src/、中文扫 zh.po 的 msgstr,围栏代码块整段跳过,附录和三处历史账豁免。先拿它在 v18 的正文上跑了一遍,抓到 427 处——抓不到就说明正则是摆设,这一步不能省。
  • 划清了一条界线:Krita 的 API 名不算库的代码(camelCase,第三、四部整部就是那套 API 的参考,113 处 API 名 + 41 处 xxx()),工作目录的骨架词(main.py / parts.py / overrides.json / phaseN- / stepNN-)也不算——那是走法本身的形状。
  • 顺手修掉三处不一致:概念页的目录树漏了「线当墙」那次 segment(和「十分钟」、和真实子项目的 01–08 对不上);概念页走法对照表的 layered (ch. 9) 本来就指错(第 9 章是「两条走法」);第 8 章那句「in ch. 9」改的是文字不是数字。

输了什么

  • 译文掉了 564 条。 改的是句子本身,--no-fuzzy-matching 一律当新条目。全部重翻了一遍,但这是这次最大的一笔开销。
  • 第 3 章「补齐」的「人在这里定什么」一节留着两条已经不在部件表里的字段(AMODAL / PROMPTS,一圈一圈那套做法 1.0.0 就从库里删了)。这一版只是明说了它们属于旧做法,没有重写这一章——重写要等 See-through 那套稳定。
  • 附录 C 的「章」一栏原来写着 21、19、9 这些号,是好几版之前的编号,早就不对。这一版按每个探针自己问的问题重填了 23 行,但那是按题目判的,不是逐条追出来的。

新记录的坑

  1. tools/audit.py 的 EXEMPT 里硬编码着页名。 它写着 "16-measuring-the-wrong-thing";文件一改名成 17-,那条豁免就静默失效,那一章满页的 origin 和版本号会突然全被当成错报。页名一改,就要 grep 工具里有没有人把它写死。
  2. 附录 E 自己的章号不许平移。 src/ 里 10–18 区间的中文章号 24 处全在这一页,而且全是当年那版的号。批量 sed 前必须把它排除——这条规矩 AGENTS.md 早写着,但一条 sed -i 不会替你读规矩。
  3. po/zh.po 的批量替换要按条目跳过附录 E。 它在 po 里有 341 条、只翻了 6 条,中文章号活在 msgid 里;盲 sed 会把 msgid 改坏、和 src/ 对不上,而 msgfmt 不会报任何错。
  4. 在一张表的最后一行后面直接插一句话,会被 gettext 并进那一行。 少了一个空行,「文件名在附录 F」和一整行表格挤成了一条 msgid。加完句子要看抽出来的 pot,不是只看 mdbook build 过没过。
  5. 书跟着代码改了两遍之后才立规矩,晚了一遍。 v18 把路径改成 import 路径,看着像是把问题解决了——其实只是把下一次失效推远了一点。真正的解法是正文根本不提名字。「改一个更耐用的引用方式」和「不引用」是两件事,前者还会有第三遍。

v19.0.1 — 修公式:被智能标点吃掉的撇号,和手机上被切掉半条的显示公式(2026-09-12)

用户:「修一下公式呗。」全书只有五页有数学(第 1 章分割,第 2 章线稿的四小节:分割轮廓、LineartStandard、骨架化、仲裁),两处真错。

做了什么

  • 撇号。 markdown 的智能标点把 ' 换成弯引号,数学里就成了错的字形。曲率那条公式 x'y'' - y'x'' 印出来是 x’y’’ − y’x’‘——最后一只成了反向的左引号,因为智能标点把两只 '' 当成一对引号配上了。另一处是「线当墙」那里的 B'。两处都改成 ^{\prime},markdown 碰不到。
  • 手机上被切掉的公式。 正文列不横向滚动,所以一条比列宽的显示公式右半截既看不见也够不着。第 4 章那条 support 的定义在 375 px 上是 627 px 宽——一半没了,而且没有任何办法看到。四处都按语义拆开:support 和 score 本来就是两个定义,拆成两行;种子生长的三个条件、Zhang-Suen 的四个条件用 gathered 换行(正文本来就写着「三个条件是」「满足所有」)。改完五页十四条显示公式在 375 px 上全部放得下,中英两版都是。
  • 兜底的 CSS。 theme/math.css 给显示公式加 overflow-x: auto:以后再写出一条太宽的,读者至少能滑动看到,不会被无声地切掉。两套选择器都写了(MathJax 2 的 .MathJax_Display 和 3 的 mjx-container),mdBook 现在插的是 2.7.1。
  • 闸门。 tools/audit.py 加一条 math_punctuation():数学环境里出现 ' " -- ... 就红。

新记录的坑

  1. 智能标点会进数学。 markdown 不知道 \\( … \\) 里面是 LaTeX,该换的引号照换。撇号最坏:它不报错,只是印出一只反着的引号,而且只有懂这条公式的人才看得出来。撇号写 ^{\prime}。
  2. 「没有横向滚动条」不等于「没有溢出」。 页面的 scrollWidth 一直老老实实等于 375——因为溢出的是公式那个 div,被上层容器裁掉了。量溢出要量元素自己的 scrollWidth > clientWidth,量整页量不出来。
  3. 公式的宽度是判据的一部分。 这本书的每一条显示公式都得在 375 px 上放得下;放不下就按语义拆,不要指望读者去滑。

v19.1.0 — 细长笔触的三个来源,默认全关(dtp 4.1.0,2026-09-13)

用户看着一次成型的成品说:「能不能不要有这些细长神秘的线」——不只是头发,还有脖子。

三个来源,不是一个

按「长 > 3×笔宽」数了一遍(长度换算成 4x 画面像素):

来源谁身上有笔数笔宽长度中位 / 最长细长占比
发丝只有头发(部件表的 strand 组)3,0087 px62 / 356 px64.7%
排线只有皮肤(hatch 组,含脖子胸口)19011 px94 / 113 px100%
细节笔的长尾所有部件约 1,900(占 detail 的 4–7%)12–48 px12 / 168 px—

脖子上的和头发上的是两个不同的函数画的。三支笔各自的来历本来就写在库的 docstring 里:细节笔是 Hertzmann 1998 的多尺度贪心,排线的方向「取区域主梯度而不是逐点」是对着参考画作调的,发丝是「8K 上多出来的细节大半在这儿,实测是参考画作的 3.3 倍」。而 stages/trace.py 顶上写着:这条路只有一种画风——soft painterly,anime 那一版试过没收敛、删了。所以这些细毛不是 bug,是当年选的画风;用户要的干净平整,反而更靠近被删掉的那一版。

做了什么

  • 先在一个 step 里试(shinku-hug-basic-2/phase3-paint/step03-trace):部件表的两个集合在那一步里置空(不动子项目的表,基线才可复现),再在 main.py 里把细节笔事后截断。判决「还可以吧」。
  • 沉到库里(dtp 4.1.0):细节笔加 max_aspect,在生长循环里按步数封顶,不是事后切;一次成型加 strands / hatch 两个开关,三个默认都是「不画长条」。模板的部件表两个集合改成空的,模板的 PLAN 把三个旋钮摊开写。库版重跑一遍和手工版对账:65,271 笔对 65,133 笔,判据全部在噪声内。
  • 审阅图的文件名:jobs/bake.py 拿节点自己的名字当文件名,而一次成型里每条带的两层都叫 Marks / Soft——133 次保存全写进两个文件,日志照样打印「133 layers」。纯函数搬进 gauge/review.py(Krita 侧的模块在 pytest 里 import 不了),tag 改成节点路径,补一条测试。
  • 旧 step 钉住旧默认:基线那一步的 PLAN 现在显式写上三个旧值,否则它自己 README 里的数字不再可复现。

代价,量出来的

基线(旧默认)→ 新默认,同一张图、同一个分割、4x。这两个 step 判完就删了——它们要回答的是「这条路默认该怎么画」,答案现在落在库里而不是某个目录里,所以下面这张表和 v5 的 8x 一样,从此只剩数字:

基线新默认
笔 / 调用68,353 / 77,97765,271 / 70,343
Krita 落笔112.5 s91.5 s
渐变 p9937.4(源图的 90%)37.2(89%)
8 级色数1,870(64%)1,818(63%)
阶梯指纹0.780.81
低频相似度35.31 dB35.79 dB
内部锐度 p909.5(头发 10.7)7.2(头发 5.3)
描述数 / 源图数15.04%14.22%
裸写像素00

前四条在噪声内,相似度还升了;代价全在内部锐度上,尤其头发——它原来唯一的高频内容就是发丝(源图是 10.5 / 8.4)。要回来就把旋钮打开。

留下来的那一步是 8x(shinku-hug-basic-2/phase3-paint/step02-trace,8192×12288,新默认):65,271 笔 / 70,343 次调用、295 节点、描述数 14.23%、裸写 0;渐变 p99 37.9(源图的 91%)、8 级色数 1,834(63%)、阶梯 0.79、低频相似度 35.83 dB、内部锐度 7.2(头发 5.3)。归一到 2508px 之后 4x 和 8x 读数在噪声内,这也是默认倍数取 4 的理由。Krita 那半段 922 s:建树 191.8、落笔 111.4(586 笔/秒)、出审阅图 602.3——8x 上写 148 张分层图比画画本身贵五倍,峰值 RSS 9.6 GB。

新记录的坑

  1. 事后截断和生长时封顶不是一回事。 事后把笔切短,模拟画布上留着的还是没切之前的效果,而后面每一笔的种子是按那张画布挑的——计划和成品从此对不上。沉到库里就要沉到生长循环里。
  2. tier 不在 io.STROKE_KEYS 里。 一次成型的笔靠 (部件, 档, 种类) 认层,而重存时用默认 keys 会把 tier 丢掉、全压成 0,建树时直接 raise。改笔列表的 main.py 必须 keys=None。
  3. 打印出来的计数不是产物。 「133 layers」是数出来的,目录里只有两个文件。要么让文件名唯一是构造上的事,要么就得有一条测试去数文件。
  4. 改默认值会让旧 step 的 main.py 不再复现它自己的数字。 记着把旧默认显式写回去——不然那一步的 README 就成了没有来历的数字。
  5. Args: 只写一半比不写更红。 ruff 的 D417 在有 Args: 的时候才要求每个参数都有描述;给一个新参数补一行说明,会把同一个函数的另外十个参数一起点名。