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_Waist85.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。
新记录的坑
- 量错对象 ×3:撤销栈那 233 KB/笔是 tile 密化;探针自己攥着旧 node 句柄 close 后内存不回;「重开后落笔没生效」是层已画满数不透明像素不涨。第 17 章 26–28。
pkill -f "<含自己命令行的模式>"在这个 harness 里杀掉发命令的 shell(exit 144),[m]ain.py老技巧也不管用;用pkill -x/fuser -k 端口。一条命令里cd过的工作目录会带到后面的调用,后台链一律绝对路径。lineart._degree数邻居个数,斜线台阶像素被当分叉:19678 骨架像素里 8572 个假分叉。用 crossing number。- 拟合容差 < 噪声 3σ 会一路切段(1.2 容差 → 176 段),先 σ=1.5 平滑再拟合。
- 拟合后的线离 mask 边最多 3px@1x:填色 Shape 也得用同一套拟合,否则线和填色边错开。填色环要比线的位置多扩 2.0px,不然外轮廓线和填色之间露一线黑底。
- 短碎片 100% 落在长线上不能算「重叠」淘汰长线;按候选自己被盖住的比例修剪。
- GroundingDINO 词要具体:
ribbon抓成整个人,red bow才对;抓不到的词退化成整个人(≈455k px)。 - transformers 5 两处:
BertModel.get_head_mask没了;get_extended_attention_mask第三个位置参数变 dtype。 wash在细的地方(鞋带)扫线中点落在 mask 外被跳过 → 84.7% 覆盖;覆盖图找洞小笔补。- 源图的 alpha 有洞:两腿之间 12px 的「缝」alpha 1–3、RGB 却是皮肤色(非预乘 PNG)。当背景会双线、黑底露出、amodal 漫进去。按 RGB 补回;主体阈值放宽到 80 反而退步,回到 200。
- amodal 的 α 要在「源图 + 只在补出来的区域换补全图」上解;整张用补全图解,可见部分的边会被带偏 ~1px。
- 「线底下最暗的像素」要只在主体里找,否则外轮廓线全取到黑背景。
- 补齐只能落在上层组的像素里,dilate 会漫进背景缝。
- p99 量不出「里面糊」(被外轮廓撑着);跨倍数比色数会反(4x 82–85% vs 8x 69% 是归一缩放的差别);同一份计划两次落笔差 0.6 dB / 3 个点色数,笔刷引擎有随机性,小于它的差别是噪声。
- 硬笔阈值 T×0.7 时一组 5.6 万笔——在抄噪声;T×1.4,R=1.5 只给发丝密的组。
- Krita 里
exec出来的作业没有__file__:搬进code/时给作业加了dirname(__file__),在裸 python 里py_compile过、在 Krita 里第一行就NameError。作业只靠expanduser("~")(harness 拷了全部code/*.py)。 ./paint的run()用grep … || true过滤输出,把作业的=== JOB FAIL一起吞了,退出码 0,后面的判据照跑在上一轮的成品上——主线./paint trace就这样坏了两个版本没人知道(KIND丢了detail)。现在run()见JOB FAIL/Traceback就退出;KIND抽到kinds.py,trace_prepare.py落盘前核对规划出的每种笔都有条目。第 17 章 35。- 作画日志把
ground(r, g, b)的b当 box 记:ground(0,0,0)因为 0 是假值没事,素描版的ground(220,220,220)就抛错、那次铺底没记进日志——写像素次数少记一次,诚实判据反而更好看。第 17 章 34。 - 作业里按 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_JOBimport 落笔模块)。删掉旧画师流程演示(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 的数字从此只剩数字)。 - 只在这一张图上验证过。
新记录的坑
- 杀进程按 PID,不按模式:
for pid in $(pgrep -f …); … grep -q "complete run" && kill把发命令的 shell 自己也杀了(exit 144)——和pkill -f "<含自己命令行的模式>"是同一个坑,换了个写法又栽一次。 - 会话崩溃留下的进程还在跑:上一个会话的
complete run和新起的一份同时写同一个缓存目录、同时往 ComfyUI 队列里塞任务,产物互相覆盖。开长作业前先pgrep -a看有没有同名的在跑;ComfyUI 队列POST /queue {"clear": true}+/interrupt清掉。 from x import *只能在模块顶层:想把 Krita 落笔代码和裸 python 的计划步放进同一个模块、把from dtp.krita.session import *挪进函数里——SyntaxError。落笔代码只能是独立的*_job.py模块。- 把脚本包成函数时别自作聪明加
nonlocal:原脚本没有global语句,helper 里对外层同名变量的赋值本来就是局部变量;闭包语义相同,加了nonlocal反而改语义。 python3 -m py_compile过了不等于跑得通:搬进包以后一个函数里的import arbitrate as ARB(缩进的、带别名)漏改,只有真跑到那一行才炸。grep要连缩进和别名一起匹配。- inpaint 的洞不能有剪影:洞的形状决定内容。要让模型「补这个物件」,洞得是它外面的一圈,不是遮挡物的形状。
- GroundingDINO 抓不到的词退化成整个人(第 1 章已知),但
person这种词本来就该是整个人——按「面积 > 90% 主体判废」的规矩会把它误杀,白名单放行。 - 归一到 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 一张图上验证过。
新记录的坑
- 从 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。 contour.py的等值线靠 matplotlib,系统 Python 一直有所以没人发现;装进干净的 venv 才炸。依赖要写全。- 拟合的单元测试一开始按「不平滑直接拟合」写,61 段——书里说的「先 σ=1.5 平滑再拟合」不是可选项,是规矩。
- 根目录的 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、头发的顶帽 σ/δ、眼睛的颜色阈值都是这张图上调的。
新记录的坑
- 闸门的图有了不等于看过了(案例 36):脖子黄了三个版本。每次换基线把脸、脖子、手、脚四处特写看一遍。
- 旋钮从没传进去过(案例 38):
nargs="*"拒掉--k=v,整条链全是默认值。旋钮要有「传了就看得见」的测试。 - 量尺的 mask 不互斥、档边用中位数、模拟画布上量锐度——三个「量错对象」(案例 39–41)。
- KMeans 分档被线的暗尾巴带跑(皮肤 k=2 切出 3% 的描边);Otsu 在 [固有色−60, +10] 里找谷才对。
- SAM 的
chest/upper body/skin都退化成整个人;neck只圈到 45%——种子 + 颜色,不指望词圈全。种子生长不限距离会顺着颜色跑到 100 px 外的头发上(grow_dist)。 - 1x 模拟和 Krita 会反着来:定向发丝在 Sim 上加 0.8 dB,Krita 上掉 0.7——Pencil-3 画出来比 Sim 的核重。模拟只能定方向,数字要落笔。
- 残差软笔在档之上,会把档里的小东西(高光点)抄没——小到抄不回来的形状,残差要绕开。
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重跑。
新记录的坑
- 发布的书里留了一个没填的占位符(第 6 章
〔F_p99〕)——填数脚本跑完没有再 grep 一遍。mdbook build前grep '〔'必须为空。 - 子项目搬家,书里的路径悬空(6 处
work/origin/)。书不引用work/里的路径,只在第 18 章按子项目名指 step。 - 库里的部件表模板会腐烂:
parts/default.py是 origin 的表的一份拷贝,比真的那份还旧(Arm类少了 v9 加的neck/shoulders)。模板不该是任何一张图的表。 - harness 和 step 都能被删掉:
krita-bot/不在了,trace的读画正常、作业「harness 没装」;dtp setup重建、dtp check31/31 再跑。删掉 step 就删掉了数字的来历——第 18 章每个数字后面写它来自哪个 step。 - 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。
新记录的坑
- 线在图层之前抽是顺序错误:归组只能靠启发式,被遮的部分没线。图层有了再抽线,归属由构造定。
- 书写的数和代码不符:AI 线的拟合容差书写 2.0、代码 1.2;修剪后最短段书写旋钮名、代码是常数。写数之前 grep 代码。
- 图例里的死类别:「黄框 = 输给别的线」在修剪规则改掉之后再没有任何路径产生它,图例和书都留着。
- 只看计数判线当墙会反:脖子框里 Hair_Front 变多了,是那缕真头发终于完整;要看特写。
- 章号写进标题和 mdBook 的自动编号重复(「5. 4 分割」);上手那几篇不该编号(前缀章)。
- 重排脚本把附录 E 里历史版本的章号也改了:那些是当年那版书的号,要还原;E 顶上加了一句说明。
ls */*.md只扫两层:线稿拆成子目录后审计漏掉一整章。- 本机提交别人看不到:dtp 0.3.0 / 0.4.0 和书的三个提交都只在本机;书的站点是 Cloudflare Pages 直接上传的,不从 GitHub 构建,push 了也是旧版。提交后立刻 push,书再
tools/publish.sh。 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(SIGGRAPH 2026,Apache-2.0):一张动漫立绘 → 最多 23 层语义透明层,每层补全,Marigold 微调的伪深度定前后顺序,Live2D 9,102 个模型当真值训练。代码 https://github.com/shitagaki-lab/see-through,ComfyUI 插件 https://github.com/jtydhr88/ComfyUI-See-through,机理解说 https://note.com/ai_driven/n/nafe907059646。
- Amodal Completion via Progressive Mixed Context Diffusion(CVPR 2024):https://k8xu.github.io/amodal/ —— 和我们的一圈一圈同源,差在干净的上下文和成功判据。
- pix2gestalt(CVPR 2024):https://gestalt.cs.columbia.edu/ —— 自然图像的整个物件,256 px。
- Amodal SAM https://arxiv.org/abs/2604.20748、SAMBA(CVPR 2025)https://openaccess.thecvf.com/content/CVPR2025/html/Liu_Towards_Efficient_Foundation_Model_for_Zero-shot_Amodal_Segmentation_CVPR_2025_paper.html、SAMEO https://arxiv.org/abs/2503.06261 —— 只给形状。
- Workflow-Aware Structured Layer Decomposition(2026):https://arxiv.org/abs/2603.14925 —— 线稿 / 平涂 / 阴影 / 高光四个功能层,对上色有参考价值。
做了什么
- 装了 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 以上显存。
新记录的坑
- 给模型看的图里不能有剪影:参考图外推填不满主体深处,留下的黑就是模型的提示词。同一个黑剪影后来又进了分层线稿的 solo 图一次(
step05-lines-legacy-wrong-1,提取器画出整个人的鬼影轮廓)——线稿提取根本不需要背景,改成只给这一层加平底色(dtp 0.5.2)。参考图本身还没修,只剩一圈一圈的补齐和辉光笔在用。 - 取回不能照单全收:SAM 对模型输出的图会把整个人框成「头发」;颜色规则的对手要是别的组,不是背景。
- 每圈换 seed 拼成斑块。
- 重画的可见像素不能进成品:See-through 的叠回只有 18 dB;用它只取被遮的部分。
- 第 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。
新记录的坑
np.savez_compressed的产物逐字节不稳定(zip 项带修改时间);比对要np.load后比数组。- 提取
arbitrate()时它顺手写审阅图的副作用要单独成函数,不然纯函数的调用方没法决定文件叫什么。 - 三处各写一遍的「每组可见 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做法还是上一轮那套(亮脊定缕),没过门槛;重做从讨论开始,不从代码开始。
新记录的坑
- 五项判据全过,人判不过:判据管「像不像人画的」(梯度、色数、阶梯、低频、锐度),管不了「是不是画」——误差驱动的复刻能把五项全刷过。审阅不能只看数字。
PAINT的 method 名打错以前到上色才炸(KeyError),部件表检查漏了它;登记表 +check_parts补上。- 基线的遍数表按组名特判两个组(
"Eye"/"Ribbon"),违反「库里没有部件名」——这次为保逐字节一致原样搬进paint/base.py,逐部位重写时消灭。 - 一张 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 分五层:按「谁做决定」切成 原子(
geometryrasterir)→ 能力 / 看图(opsread)→ 画法词汇(craft)→ 部位(paint)→ 工序接线(stages)+ 横切的尺(gauge)。按函数切不按模块搬(paint/hair.py422 行横跨三层)。四处依赖倒挂全清(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 | |
|---|---|---|
| 图层树节点 | 64 | 51 |
| Brushes | 68,316 | 50,855 |
| description numbers over source numbers | 16.54% | 12.61% |
| Krita 落笔 | 264.6 s | 180.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/(原计划要改),审阅图的耗时在带模糊蒙版的树上明显变长。
新记录的坑
- 一层可以混几种笔。 IR 的 strokes 节点只有一支笔,第一版把
<G>_Shade里 35,631 笔硬圆头的sharp全按软气刷的shade画了(两者宽度系数还差 7%)。改成「和层的笔不一样时这一笔自己带一支」。 - 一笔只能落一层。 老作业的
(组, 笔种) → 层字典是后建的覆盖先建的;不抄这个覆盖语义,68,316 笔会变成 99,921 笔,31,605 笔画两遍。 - 整幅纯色的填充必须是画层。 建成矢量层的话
setPixelData写不进它的 projection——成品少五个 dB,一句报错都没有。 - Krita 的笔刷有随机性:同一棵树连跑两遍也不是逐位相同。像素比对天生有天花板,PSNR 门槛只在那个尺度上有意义;要精确到「哪一笔不一样」得比作画日志(
probe_bake_equiv.py),而且要按层比——层之间谁先画不影响成品。 - 测试自己也会静默通过。 新加的 pyflakes 检查第一版只读 stdout,没装 pyflakes 时报错走 stderr、stdout 是空的,于是它对着零行输出「通过」——种一个假的未定义名字进去照样绿。检查外部工具的检查,必须先确认它真的跑了。
- 搬家留下的哑弹:
stages/draft.py的plan_groups用了 11 个在阶段 1 搬走的名字,read/tiers.py3 个,geometry/skeleton.py的__all__里 2 个不存在的函数——全都 import 得动、70 项测试全绿,一跑底稿那一步才 NameError。整包扫「名字有没有出处」是一秒钟的事,比一次六分钟的 Krita 作业便宜太多。 - 渐变生成层不能用:
createFillLayer("gradient", …)层建得出来,但给它的参数一个都不生效(五种 shape 画出来一样、前景背景色没用、start_position_x存进去还是 0);键名里根本没有 stop 的颜色。对照组color生成器的setProperty是生效的。⚠seexpr生成器会让 Krita core dump。 - 一层最多挂一个 FilterMask:24 个挂同一层是 O(n²)(每加一个 +1.6 s),挂 24 层各一个是 O(n)(+0.08 s)。
- 计划里写死的检查点数字会立不住:「预览对
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(要提亮),而乗算做不到提亮;把提亮折进绝对色又会被 255 截断(金发的 R 本来就 250)。实测残差因此从 12.0 掉到 15.3——而且一点报错都没有。
- 预览器的组返回的是预乘颜色。 父节点再按直通 alpha 合一次,每个组的半透明边就和黑底混一遍;一张几十个组的树上是几十圈暗边(头发那棵残差 12.9 变 18.1)。笔画节点早先踩过同一个坑(
Sim在黑底上叠),组这一份漏了。 - 缕与缕之间不能互相 clip。 clip 继承的是同一个组里下面已经累积的 alpha,缕组互裁的话第一缕底下什么都没有,直接消失。
- 换画法的时候把「完整形状」丢了。 新画法顺手用可见掩膜造剪影,被身体挡住的那半边头发就没了(Hair_Back 的剪影从 277,820 px 缩到 69,777)。amodal 是这条走法的立身之本,改画法时最容易忘。
- 一条线不要存两遍。 线同时存了贝塞尔和采样出来的折线,而落笔和预览都只看前者——白花一倍的描述数。
- 执行器不许自己造节点。 bake 曾给带形状的渐变自动挂一个透明蒙版去裁,于是 Krita 的树比
tree.json多出 61 个节点,「树是数据、bake 只是执行器」这句就不成立了。改成把形状烘进渐变自己的 alpha。 - 条带比渐变贵三倍。 想用「量化成几档 + 模糊揉回去」代替渐变:每多一档就多一圈轮廓(一缕约 100 个数),而渐变把整个剖面压进二十个数。
- 一百多条渐变必须各带自己的 box。 不给的话每条都往整幅 4096×6144 写一遍 RGBA。
- 新判据「每组 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 时代的截图,正文早就不引用了)。
新记录的坑
- 跨了仓库边界的检查,等于没有检查。 库里 11 条
cite=有 8 条指向不存在的标题,从 dtp 0.x 烂到 2.0.0 没人发现——因为验证它要同时打开两个仓库,于是两边都默认对方在管。 - 只审中文正本 = 英文版永远不被审计,而英文版正是外部读者读的那一份。审计脚本一旦和某一种语言长在一起,多出来的那一版就是盲区。
- 根
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()_onebucketfrmfiltas_之类改成说人话的全名;契约名一起改——parts.py七个字段、笔的五个键(Ropbezptssrc)、画法名tiers→shadow_tier、why=的包名、step README 里人写的## 结论→## Verdict。 - 规范变成闸门:
tests/test_style.py四道(ruffE,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 条(
E501482、N806298、E702148 打头,多是对齐的数据表和a = b; c = d这种成对赋值)。棘轮保证它只会变小,但它现在不是 0。 - 双语的术语表没有单独一份:它就是附录 D 加它在
po/en.po里的译文。多一份GLOSSARY.md就是多一处会漂的副本。
新记录的坑
- 一次成型那条走法从 dtp 0.2.0 起就是断的。
stages/trace.py调background.glow(),而那个函数在 0.2.0 被删了。从 0.2.0 到 3.0.0 隔着十几个版本没人发现,因为两个子项目的 trace step 都是 0.2.0 之前跑出来的,之后没人重跑过。全重跑第一次把它照出来。产物还在,不等于产它的代码还跑得动——「重跑一遍」本身就是一项检查。 - 正则抓日志 = 抓不到和真的是 0 长得一模一样。
gauge/report.py的node_count()抓共 (\d+) 节点,而ops/bake.py打的是[tree] %d 节点——没有「共」,永远返回None,模板一直在用它。现在改成从layers.json数。 - 来历只是字符串,没有东西解析它。 四条
why="parts.*"指着一个在 dtp 1.1.0 就改叫paint/的包,于是每一份tree.json里的来历都指向一个不存在的模块,两版无人察觉。test_why_resolves现在解析每一条。 - 翻译会悄悄换掉格式串的参数顺序。 我把
templates/lines/main.py里一句话的两个占位符换了位置而没换参数,README 于是写出「228% of these 85 visible lines」。每个数字都是真的,句子是错的;除了人读那一句,没有任何闸门会红。 _是个已经被占用的名字。 拿它当 gettext 的翻译函数,和lab0, _ = ...这种丢弃变量的写法直接撞车。改叫tr()。- 先格式化再翻译等于不翻译:
tr("%d 条线" % n)查的是已经填好数字的那一句,永远查不到。要写成tr("%d lines") % n。 msginit -l en会把 msgstr 填成 msgid。 于是覆盖率一上来就是 100%,而审计扫 msgstr 时扫到的全是中文。英文的.po要从 POT 手工生成,msgstr 留空。- 改一个已翻过的句子时,同一处改动要同时套到
po/en.po的 msgid 上,否则那条译文掉回未翻。附录 G 那次改六个字段名,24 条译文就是这么保住的,msgmerge之后 0 条 fuzzy。 - 审计的例外要按「每一页」算。 一条译文可以出现在好几页上(
#: a.md:3 b.md:7);只有每一页都在例外里,这一条才算例外。第一版写成「看第一条来历」,等于给例外页共享的句子开了个后门。 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 时代的历史截图文件名还是中文。它们只被两篇不译的中文页引用,改名只有风险没有收益。
新记录的坑
.po的转义必须一遍扫过去,不能连着replace。s.replace("\\n", "\n")会把 MathJax\\neg里那个\\n当成换行、\\text里那个\\t当成制表符。19 处\\t加 1 处\\n就这样烂在已经发布的英文版里,而且没有任何闸门会红:公式渲染不出来,只有人眼看得见。这个 bug 同时在翻译工具和tools/audit.py里,写替换工具的时候才炸出来。- 按行号做位置替换,要从文件末尾往前做。
.po记的是原文的行号;只要有一条译文的行数和原文不同,后面所有行号立刻错位。第一版顺着做,01-分割.md从第 77 行起全线脱靶。 - 翻译是有损的,方向一掉头这份损失就固定了。 上面那 50 个词元不是 bug,是信息在第一次翻译时就已经丢了。要是早知道会掉头,当初就该在译文里保住区分。
- 覆盖率 100% 不等于页面上没有另一种语言。 gettext 不抽代码块,所以「2,463 条全译完」的同时还有 222 行中文躺在 ``` 里。统计的分母是工具能看见的东西,不是读者能看见的东西。
tr()套两层,第二层必然查不到。 原来的tr在正本语言下是恒等函数,所以「先tr再拼字符串再进一个也会tr的函数」这种双重调用一直看不出来;掉头之后第二次查表落空,中文版会悄悄退回英文。改法是让接收方不再翻译,标题进来时就必须是最终文字。- 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/只许 importops和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。这是拆包的固定开销,没有办法。
新记录的坑
- 删掉
__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白送的,删了才发现。 - 闸门在拆包后不是报红,是静默空过。
test_layering.py用ROOT.rglob扫一棵树,包搬走了就一个文件都扫不到;「新包必须登记」那条是减法,扫到零个包照样通过;两个 jobs 测试glob到空目录也照样通过;pyflakes 只指着那一棵树。一道会空过的闸门比没有闸门更坏,因为它还在绿着。改法:所有发现都走多根,「必须登记」改成双向相等,jobs 测试加一条「至少四个作业文件」。 - 同一次拆分里,硬炸的那一处反而是好事。
test_style.py的SCANNED里有字面量"src",ruff 拿它当命令行参数,目录没了直接非零退出——一秒就红。它和上一条是同一件事的两个极端:把路径当参数传出去的会炸,把路径拿来遍历的会空过。 - 判断「纯搬家」的尺子,必须自己不碰文件系统。
test_frozen.py是全套测试里唯一一个纯 import、零路径的,四个 sha256 一位没变,这才是「代码没搬坏」的证据。其余测试改到全绿只能说明测试改对了。 - 拿既有 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 行,但那是按题目判的,不是逐条追出来的。
新记录的坑
tools/audit.py的EXEMPT里硬编码着页名。 它写着"16-measuring-the-wrong-thing";文件一改名成17-,那条豁免就静默失效,那一章满页的origin和版本号会突然全被当成错报。页名一改,就要 grep 工具里有没有人把它写死。- 附录 E 自己的章号不许平移。
src/里 10–18 区间的中文章号 24 处全在这一页,而且全是当年那版的号。批量 sed 前必须把它排除——这条规矩 AGENTS.md 早写着,但一条sed -i不会替你读规矩。 po/zh.po的批量替换要按条目跳过附录 E。 它在 po 里有 341 条、只翻了 6 条,中文章号活在 msgid 里;盲 sed 会把 msgid 改坏、和src/对不上,而 msgfmt 不会报任何错。- 在一张表的最后一行后面直接插一句话,会被 gettext 并进那一行。 少了一个空行,「文件名在附录 F」和一整行表格挤成了一条 msgid。加完句子要看抽出来的 pot,不是只看
mdbook build过没过。 - 书跟着代码改了两遍之后才立规矩,晚了一遍。 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():数学环境里出现'"--...就红。
新记录的坑
- 智能标点会进数学。 markdown 不知道
\\( … \\)里面是 LaTeX,该换的引号照换。撇号最坏:它不报错,只是印出一只反着的引号,而且只有懂这条公式的人才看得出来。撇号写^{\prime}。 - 「没有横向滚动条」不等于「没有溢出」。 页面的
scrollWidth一直老老实实等于 375——因为溢出的是公式那个div,被上层容器裁掉了。量溢出要量元素自己的scrollWidth > clientWidth,量整页量不出来。 - 公式的宽度是判据的一部分。 这本书的每一条显示公式都得在 375 px 上放得下;放不下就按语义拆,不要指望读者去滑。
v19.1.0 — 细长笔触的三个来源,默认全关(dtp 4.1.0,2026-09-13)
用户看着一次成型的成品说:「能不能不要有这些细长神秘的线」——不只是头发,还有脖子。
三个来源,不是一个
按「长 > 3×笔宽」数了一遍(长度换算成 4x 画面像素):
| 来源 | 谁身上有 | 笔数 | 笔宽 | 长度中位 / 最长 | 细长占比 |
|---|---|---|---|---|---|
| 发丝 | 只有头发(部件表的 strand 组) | 3,008 | 7 px | 62 / 356 px | 64.7% |
| 排线 | 只有皮肤(hatch 组,含脖子胸口) | 190 | 11 px | 94 / 113 px | 100% |
| 细节笔的长尾 | 所有部件 | 约 1,900(占 detail 的 4–7%) | 12–48 px | 12 / 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,977 | 65,271 / 70,343 |
| Krita 落笔 | 112.5 s | 91.5 s |
| 渐变 p99 | 37.4(源图的 90%) | 37.2(89%) |
| 8 级色数 | 1,870(64%) | 1,818(63%) |
| 阶梯指纹 | 0.78 | 0.81 |
| 低频相似度 | 35.31 dB | 35.79 dB |
| 内部锐度 p90 | 9.5(头发 10.7) | 7.2(头发 5.3) |
| 描述数 / 源图数 | 15.04% | 14.22% |
| 裸写像素 | 0 | 0 |
前四条在噪声内,相似度还升了;代价全在内部锐度上,尤其头发——它原来唯一的高频内容就是发丝(源图是 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。
新记录的坑
- 事后截断和生长时封顶不是一回事。 事后把笔切短,模拟画布上留着的还是没切之前的效果,而后面每一笔的种子是按那张画布挑的——计划和成品从此对不上。沉到库里就要沉到生长循环里。
tier不在io.STROKE_KEYS里。 一次成型的笔靠 (部件, 档, 种类) 认层,而重存时用默认 keys 会把tier丢掉、全压成 0,建树时直接 raise。改笔列表的main.py必须keys=None。- 打印出来的计数不是产物。 「133 layers」是数出来的,目录里只有两个文件。要么让文件名唯一是构造上的事,要么就得有一条测试去数文件。
- 改默认值会让旧 step 的
main.py不再复现它自己的数字。 记着把旧默认显式写回去——不然那一步的 README 就成了没有来历的数字。 Args:只写一半比不写更红。 ruff 的 D417 在有Args:的时候才要求每个参数都有描述;给一个新参数补一行说明,会把同一个函数的另外十个参数一起点名。