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

From a diffusion image to a layered painting

diffusion-to-painting: turning an AI-generated flat illustration into a layered Krita painting where every pixel has a provenance. This is its handbook.

A diffusion image looks like a painting, but it was not painted: no layers, no brush marks, blurry when enlarged, and pixel steps along its edges. This handbook shows how to use dtp (a Python library plus headless Krita plus a local ComfyUI) to turn one into a .kra an artist can take over, following an artist’s own stages: every layer named, whole, and editable on its own; the lines individual strokes; and not one pixel taken from scaling the source up.

One step per stage, one chapter per step (part one):

one diffusion image
  segment   cut it into named parts (boundary barriers + the SAM majority vote; lines can be walls)
  lineart   the lines on the source: two sources into a candidate pool, judged by the source,
              fitted into single strokes
  complete  each layer's hidden part is handed to diffusion to invent a whole shape, while the
              visible part is pinned
  lines     lineart extracted from each layer alone and arbitrated against that layer's image; the
              invented lines are accounted for
  draft     the layer tree + each layer's shape + flat colour + its own lines -> a person looks
  paint     each part's own method: one shadow band for skin, four shapes for an eye, the baseline
              for the rest; plus the background colour field
  gauge     numbers normalized to one scale and anchored to the source + review images + the journal
  trace     the other route: paint straight after segmenting, two commands to a finished picture

Two routes (part two). One-shot: segment -> trace, which resembles the source but whose layers are not what a person wants. Layered: segment -> lineart -> complete -> lines -> draft (which a person looks at) -> paint, whose layers are made for a person.

How to run it

There is no command line. One stage is one directory with one main.py in it, copied from that stage’s template with the upstream paths and knobs at the top edited. Run that:

mkdir -p work/pic/input && mv pic.png work/pic/input/origin.png && cp dtp/templates/project/{parts.py,README.md} work/pic/
mkdir -p work/pic/phase1-layers/step01-segment && cp dtp/templates/segment/main.py work/pic/phase1-layers/step01-segment/
uv run python work/pic/phase1-layers/step01-segment/main.py       # the first run writes only the region map; fill in ASSIGN in parts.py and run it again, then trace, or lineart -> complete -> lines -> draft -> paint -> gauge

How to read this

  • Just want it running -> Installing and self-checking for the environment, then Ten minutes for a run-through.
  • Want to know what each step does, what to look at, and what to change when it is wrong -> part one, one chapter per step, all with the same skeleton.
  • Want to change how a part is painted -> ch. 6, “Paint”, one page per part, and appendix G, parts.py field by field.
  • Want a different lineart extractor -> the subsections of ch. 2, one section per method.
  • Want to take over the code -> appendix B, the code index, and appendix F, the file formats.
  • Want to do something else in Krita -> part three, a reference for what headless Krita can do, all of it measured.
  • Want to save yourself some detours -> ch. 17, the three iron rules of measuring the wrong thing and the case collection – the most expensive chapter in the book.
  • Want to see what a real picture comes out like, and what the numbers are -> ch. 19, the worked example (origin). The main text never quotes its numbers.

Status marks

The first line of every chapter carries a mark, so that nothing unmeasured ends up in a table of measurements:

markmeaning
👤 checked by a humana person looked at the output and the review images and was very satisfied. Only a person can award this
✅ verifiedthere is a probe or measured data, the numbers reproduce, and they come from a step produced by the committed code – the author considers it passed, but no one else has accepted it
🧪 in progressit runs, but has not settled into something satisfying
📐 designedonly the questions, the interface and the probes to be written; nothing has been run
⬜ placeholderthe skeleton is up but nothing has been studied: only “how an artist paints it” and “what has to be decided”, with an empty module in the code

Repository layout

book.toml         the mdBook config
po/               the Chinese edition (gettext; the English src/ is the single source of truth)
src/
  README.md       the cover (this file)
  SUMMARY.md      the table of contents
  00-start/  01-stages/  02-routes/  03-krita/  04-measure/  05-worked-example/  99-appendix/
  assets/         review images and comparisons
tools/            audit.py the content audit - links.py the link check - figures.py the diagrams -
                  publish.sh builds both editions and deploys to Cloudflare Pages
../dtp/           the library (a uv project, its own git): src/dtp/ the five ranks, io.py the
                  readers and writers, templates/ one main.py template per stage plus the parts
                  table template, tools/ environment scripts, jobs/ the painting job, probes/
../work/<name>/   subprojects: input/ the picture + parts.py + one stepNN-<stage>/ per stage, each
                  with one main.py (the conventions live in AGENTS.md one level up)

Environment

Fedora 44 · Krita 6.0.2.1 (RPM) · PyQt6 · Xvfb · Python 3.14 (numpy / scipy / scikit-learn / Pillow / matplotlib) · ComfyUI (lineart extraction, GroundingDINO+SAM) · See-through (the layering model used for completion; one card with 16 GB of VRAM). Krita 5.x has no Node.paintLine, and this book’s code is not compatible with it (Installing and self-checking).

Preface: what this handbook is for

✅ verified (everything described here is in the current code and has been run on ch. 19’s worked example; anything that has not been run is marked 📐)

What it sets out to do

An illustration a diffusion model generated is not something an artist can take over: no layers, blurry when enlarged, no brush marks. What this handbook does is turn it into a layered painting – a Krita .kra whose layers have names, where each layer is a whole thing that can be hidden or edited on its own, where the lines are individual strokes, and where every pixel has a provenance: it came either from an analytic curve or from a real brush stroke, and not one pixel is the source image scaled up.

This is not a replica (matching the source pixel for pixel), it is a study: the source is the reference and the output is a fresh painting made from it. So the criteria are not PSNR but “are the edges curves”, “is there an upscaling fingerprint”, “do the value bands look like a person’s”, and “does it still look right when stacked back together” (ch. 7).

It paints at 4x by default (a 1024-wide source is painted 4096 wide). 8x is an option only; it more than doubles the Krita half of the time (ch. 18).

How the handbook is arranged

Painting a picture has a fixed set of stages: cut it into parts, decide the lines on the source, complete the occluded part of every layer, give each layer its own lines, lay out a draft for a human to look at, paint part by part, and check it. Part one follows those steps, one chapter each (the one-shot route’s single step belongs to another route but lives in part one too), and every chapter has the same skeleton: what it does -> inputs and outputs -> the method and its knobs -> which parts.py fields govern it -> how it is judged and what to change when it is wrong -> where it breaks. The lineart chapter is split further: the two sources, each way of extracting raster lineart, skeletonizing, arbitration.

Steps strung together make a route (part two). There are two. One-shot: paint straight after segmentation, two commands to a finished picture – it looks right, but the layers are value bands cut by luminance and have no names. Layered: segment, decide the lines, complete, give each layer its lines, produce a draft for a human first, and paint only once that passes – the layers are made for a person. They answer two different questions; “Concepts” says how to choose.

The four unnumbered pieces before the chapters are getting started: installing, a few concepts, a ten-minute run-through. Part three is a reference for what headless Krita can do: which APIs really work and which fail silently, all measured. Part four is measuring and budgeting: how to measure without fooling yourself, and where the time and memory go. Part five is the worked example: one real picture (origin) taken end to end down both routes, with all its numbers – the main text never quotes them, and each step’s chapter ends with a single line “on origin” pointing here. The appendices: commands, code index, probes, glossary, version history, file formats, parts-table fields.

Honesty

  • Every number has to come from a step directory produced by this code and be traceable to a file; anything not measured is marked 📐.
  • Description numbers over source numbers, at most 5%: how many numbers it takes to say what this painting is. Writing pixels is allowed, but every single write has to justify itself, and not one unjustified write is permitted.
  • Whatever a generative model invented is labelled as invented; when two sources disagree, the source image decides, not the source of the line.
  • A gate’s images are to be looked at; having produced them is not enough.

How to read this

Just want to run it: “Installing and self-checking” and “Ten minutes”. Want to change one step: that step’s chapter in part one. Want to change how one part is painted: ch. 6 and appendix G. Want a different lineart extractor: the subsections of ch. 2. Want to take over the code: appendices B and F. Want to do something else in Krita: part three. Read ch. 17 first: it teaches you how not to be fooled by your own measurements – this project has been caught out more than thirty times, and not once did anything raise an error.

The first line of every chapter carries a status: 👤 checked by a human (a person looked and was very satisfied) · ✅ verified (there is data, and the author considers it passed) · 🧪 in progress (it runs but has not settled) · 📐 designed (only the questions and the interface exist) · ⬜ placeholder (the skeleton is up but nothing has been studied: only “how an artist paints it” and “what has to be decided”, with no code).

Installing and self-checking

✅ verified · the setup, self-check and ComfyUI scripts are listed in appendix A · the code behind them: appendix B

What you need

thingversion and notes
Fedora44. No other distribution has been tried; the package names here are Fedora’s
Krita6.0 or newer, installed from RPM. Node.paintLine and paintPath are new in 6.0 and are the foundation of all of this; 5.x leaves only injected tablet events (coordinate calibration unknowable, strokes landing only on the first layer, saveAs segfaulting, throughput a few hundred strokes a minute) and is not supported. Flatpak does not work either: the sandboxed Python has no numpy, and the paths are moved to ~/.var/app/org.kde.krita/
XvfbHeadless Krita still needs an X display (QT_QPA_PLATFORM=offscreen segfaults)
uv0.12 or newer. The library is a uv workspace whose member is dtp/
the system PythonKrita embeds the system Python (3.14). The numpy / Pillow / scipy / scikit-learn a job imports have to be system packages; it cannot see the ones in a venv
ComfyUIThree stages need it (segment, lineart, per-layer lineart), through two custom_nodes: comfyui_controlnet_aux (lineart) and comfyui_segment_anything (GroundingDINO + SAM). 16 GB of VRAM is enough
See-throughCompletion needs it (ch. 3). It lives in see-through/ (beside dtp/; seethrough_dir in dtp.toml) with its own venv (torch 2.8 + cu128, transformers 5.0, diffusers 0.37 – leave ComfyUI’s alone), and its weights download from Hugging Face on the first run. 12 GB of VRAM or more, and stop ComfyUI before running it
sudo dnf install krita python3-numpy python3-pillow python3-scipy python3-scikit-learn python3-matplotlib xorg-x11-server-Xvfb
cd Krita/ && uv sync          # the library's dependencies (numpy scipy pillow scikit-learn matplotlib) plus pytest
uv run pytest                 # the library's unit tests, which never touch Krita

ComfyUI lives in ComfyUI/ (beside dtp/ and diffusion-to-painting/; the location can be changed with comfy_dir in dtp.toml or DTP_COMFY_DIR) with its own .venv (Python 3.12, torch + cu130, transformers 5). ComfyUI itself is not modified; only custom_nodes and models are added. comfyui_segment_anything needs two patches on transformers 5 (in local_groundingdino/models/GroundingDINO/bertwarper.py: BertModel.get_head_mask is gone, so pass None; and the third positional argument of get_extended_attention_mask became a dtype, so drop it), with the originals kept as .orig.

The harness: a separate HOME

Running jobs in headless Krita rests on a separate HOME, krita-bot/ (the location can be changed with harness_home in dtp.toml). The harness script (appendix A) creates it; it is idempotent and is done once:

krita-bot/
  run_job.sh                                   the launcher
  .config/kritarc                              the minimal config: autosave off, undoStackLimit=3
  .local/share/krita/pykrita/autopaint/        a Krita extension: 3s after startup it execs
                                               $KRITA_JOB, then os._exit

Two reasons for a separate HOME: a batch job’s settings never pollute your own Krita preferences, and rm *autosave*.kra cannot reach your real recovery files.

What run_job.sh does: sets HOME, XDG_RUNTIME_DIR, LANG=C.UTF-8 and PYTHONIOENCODING=utf-8, puts every package’s src on PYTHONPATH (colon-separated: the library is one distribution per rank, all sharing the one dtp. namespace), deletes any leftover *autosave*.kra, and runs xvfb-run -a krita --nosplash (with --gui it runs on a real desktop, which is how tools/gui.py <step dir> replays a step). The job process imports the library’s source directly – no code is copied, and changing the library needs no re-setup. Before starting Krita, harness.run_job() strips the uv venv’s PYTHONHOME, PYTHONPATH, VIRTUAL_ENV and .venv/bin: Krita’s embedded system Python reads them, its plugin system fails to start (“Please make sure that the needed packages are installed”), and the job never begins while the process hangs.

31 self-checks

The self-check script (appendix A) starts Krita once and really runs each check, reading the pixels back: version at least 6 / filling the ground / preset / stroke / path / ellipse / a selection confining and releasing / select_mask feathering / erase / a filter’s box / blending / stage and final saving / the output layout / building the tree / nesting order / the GroupLayer type / addressing / by_role / by_label / rejecting a duplicate name / clipping / a partial box read and write / the manifest / the .kra round trip (layer count, clip, blend / opacity / label). After changing the session wrapper, all of them have to pass.

ComfyUI

uv run python dtp/tools/comfy.py     # starts on 127.0.0.1:8188 in the background, logging to work/comfy.log; exits 1 if it is not up within 60 seconds
fuser -k 8188/tcp             # to stop it: kill by port

Which stages need it: segment (one mask per SAM word), lineart (extracting lineart) and lines (one lineart per layer). complete (See-through, its own process), trace, draft, paint and gauge do not. ComfyUI’s output is cached in the step’s ai/, and it is not called again when that is present. The driver is HTTP (POST /prompt plus polling /history); LoadImage only accepts a filename under ComfyUI/input/, so the driver copies the file there first.

How far each model can be trusted: lineart extraction is discriminative and steady; text-prompted segmentation depends on the word (ch. 1). The completion model (See-through) is generative, so only the shape and pixels of what is hidden are taken from it, and every one of them is reviewed (ch. 3).

It will not start, or it dies halfway

symptomcausefix
Krita segfaults on startupQT_QPA_PLATFORM=offscreenIt needs Xvfb or a real display (run_job.sh already uses xvfb-run)
the job never starts and the process hangsKrita was started from inside a uv venv, carrying VIRTUAL_ENV / PYTHONHOME in with itharness.run_job() already strips them; do the same if you start it yourself
print("中文") raises UnicodeEncodeError partway throughonly LANG was setPYTHONIOENCODING=utf-8 has to be set too, and job files must be read with encoding='utf-8'
every later run fails with no error and no tracebackan autosave left by a crash popped up the recovery dialogrm -f krita-bot/*autosave*.kra (run_job.sh deletes them every time too), and EnableAutosave=false
a segfault in the C++ layer, with no Python tracebackpaint* was called with no viewSession.__init__ already calls addView; if you create a document yourself you must too
the criteria report on the previous round’s outputa failed job was swallowedthe harness raises on JOB FAIL; do not work around it
“the harness is not installed”krita-bot/ was deletedrun the harness script, then the self-check (appendix A)

Ending a session kills the background processes and can leave a krita zombie: pkill -x krita and rerun. Do not pkill -f "<a pattern that matches your own command line>", and do not for pid in $(pgrep -f ...) grepping your own keyword either – both kill the shell that issued the command (exit 144). Before starting a long job, pgrep -a -f main.py shows whether one is still running from an earlier session.

Concepts: subproject, parts table, step, gate, delivery

✅ verified · the conventions live in AGENTS.md one directory up · the code behind every stage: appendix B

Three things, three places

Krita/
  dtp/                     the library (a uv project, its own git): atomic pure functions plus readers
                           and writers, `import dtp`; one main.py template per stage
  work/<name>/             subprojects (not in git): one picture, one route, one subproject; one stage
                           per step; one main.py per step
  diffusion-to-painting/   this handbook (mdBook, its own git)

The library knows no particular picture: no part names, no coordinates, no prompts. Everything a picture has to tell the tool lives in that subproject’s parts.py. The library also knows nothing about step directories, orchestrates no stages and has no command line: its functions take arrays, dicts, a parts table and the path of a single file, and return values. What wires them into a stage is the main.py inside each step.

A subproject, work/<name>/

work/pic/
  input/origin.png        the picture
  parts.py                this picture's parts table (copied from the template in the library)
  README.md               written by a person: what is wanted, and the verdict at each gate
  overrides.json          overruling the lines (optional, ch. 2 and 4)
  phase1-layers/          one phase per milestone: every layer's whole shape and its lines
    README.md             what this phase produces, plus the verdict (written by a person)
    step01-segment/       one step per stage, numbered globally; inside it a main.py (copied from
                          the template) plus the artifacts and README it writes
    step02-lineart/  step03-segment/  step04-complete/  step05-lines/
  phase2-draft/           the draft (a gate)
    step06-draft/
  phase3-paint/           the finished painting; the one-shot route's trace lives here too
    step07-paint/  step08-gauge/

One picture, one route, one subproject: taking a picture down the one-shot route creates pic-basic/, down the layered route pic-pro/, and a second attempt is pic-basic-2/. Two subprojects share only the picture and parts.py (copied), and each keeps its own account in its steps.

Phases and steps

Phases are the milestones a person wants to look at, and there are three: phase1-layers/ for every layer’s whole shape and lines (segment, lineart, segment again with the lines as walls, complete, per-layer lineart; the one-shot route has only segment), phase2-draft/ for the draft, which is the gate, and phase3-paint/ for the finished painting (paint and gauge; the one-shot route’s trace lives here too). A phase holds several stages, and a redone step stays in its own phase. A phase’s README.md records what it produces when it is first opened; the verdict is written by a person.

One stage is one step, one step has one main.py, the living chain is consecutively numbered, and a failure consumes no number. Opening a step means making its directory, copying that stage’s main.py template into it, editing the two blocks of constants at the top, and running it (appendix A). When a step is judged a failure it is mvd to stepNN-<stage>-legacy-wrong-1 and kept in its phase as a record (an experiment becomes -legacy-experiment-1), and the rerun uses the same number; grep renames it in the READMEs and main.py files that refer to it. A second pass that is not a failure (segmenting again once the lineart exists) keeps both numbers. Inside each step directory:

filewritten bycontents
main.pya person (copied from the template and edited)Two blocks of constants at the top: # ---- upstream (which steps it reads, as paths relative to the subproject root) and # ---- knobs; below them the body wires the library’s pure functions together and writes the artifacts and the README. It is the complete record of this step: what it read, what the parameters were, how it was computed
README.mdmain.py writes the numbers, a person adds ## VerdictIt opens with the table of numbers this step wrote (creation time, inputs, knobs, the table); it ends with ## Verdict, a person’s “pass or fail, and why” after looking, which a rerun preserves
the job descriptionmain.py (the three steps that paint)the Krita job’s arguments, which is also what a person replays the job from on a real desktop (appendix A)
artifactsmain.pythe plans (in 1x coordinates), the layer tree, what painting produced, the review images, and what ComfyUI produced – which is not asked for again once it is there. The names are fixed; they are all in appendix F

Steps pass files to each other through step directories and nothing else. The upstream is named at the top of main.py as the path of the step to read, and changing that one line is what changes the upstream. The knobs are constants at the top too, not environment variables – the only things the library takes from the environment are machine-level settings, where this machine keeps ComfyUI and the layering model; the multiplier and the seed are constants in each main.py.

The parts table, parts.py

An ordinary Python module of uppercase constants, in three tiers:

tierwhat a person writes thereused by
sub-partswhich raw regions make up each named sub-part, bottom to top by occlusionsegment; the one-shot route divides its parts by this tier alone
classesthe words to ask the segmentation model for, and which sub-parts each word’s class coversthe majority vote in segment
groupswhich sub-parts make up each layer, which layers share one flat colour, which layer is cut out of a mask, which lineart layer a layer’s lines go to, which layers grow strands or get hatching, which of the model’s layers a group may be completed from, which method paints it, and where the 1:1 close-ups are centredevery step of the layered route

Each tier is explained in the chapter of the stage that governs it; appendix G is the field-by-field reference – the exact names, the format of each one, and which step to rerun after changing it. A missing field is an error rather than a guess.

The intermediate form is a tree

The final form “painting” takes is a layer tree, written out as one file: a description of an editable .kra, where every layer has a kind (fill, gradient, strokes, mask, filter, group), a blending mode, an opacity and a shape. The painting job – there is exactly one, shared by all three routes – reads it, builds the tree and paints, and decides nothing else.

What used to pass between the two was a list of strokes. That model forced everything to become strokes: filling an area took hundreds of scanning strokes, a shadow could not be written as “a multiply layer at 65%” but only as the composited RGB filled flat, and “draw the whole highlight band and then cut it” could not be expressed at all. With a tree, it can be read, diffed and edited without opening Krita, and a 1x preview of it draws in twenty seconds; and when the artist opens it, the shadow really is a layer they can adjust.

The criterion changed with it: what is counted is how many numbers this tree takes to say what the picture is (ch. 7).

Gates

What numbers cannot measure is put in front of a person: whether a layer is whole, why a line is there, whether what was invented looks right. The gates are at these steps:

stepwhat to look atwhat to change
segmentthe three-column review image – the hand-written table, the majority vote, and what the vote changed – and the map of numbered regionswhich regions make up which sub-part, and the words; run it once more with the lineart as walls
lineartthe review image on top of the source: blue for a kept AI line, brown for a kept contour, red dashes for droppedoverrides.json
completethe completion review image – per group: the hidden area, its completed layer, the layer alone, the outline – and the restack differencewhich of the model’s layers that group is completed from
linesone review image per layer, in the same three colours; and the combined one, where kept lines are coloured by layer and the pale ones are inventedoverrides.json (its boxes are 1x full-image coordinates)
draftthe review images painting writes: one per layer, one per group, the 1x composite and the 1:1 close-ups; hide the upper layers to see the lines underneathany of the four above
paint / tracethe same, plus the layer tree’s description countwhich method paints that group

The file names are fixed and are listed in appendix F; the field to change in each case is in appendix G.

The images are to be looked at, not merely produced: every time the baseline changes, look at the 1:1 close-ups of at least the face, the neck, a hand and a foot, whose centres the parts table fixes. The verdict goes at the end of the step’s README.md and into the gate record in the subproject’s README.md.

What is delivered

Each painting step leaves five things behind: the .kra (the whole layer tree, with clip / blend / opacity / label / vector layers surviving the round trip), the composite, the painting journal (one line per stroke and per pixel write – the only evidence that this picture was painted, and the input for replaying it), a manifest of every node in the tree (path, type, blend, clip, bounds), and the review images, plus one image per stage. The names are in appendix F; what the tree looks like is in ch. 5.

The two routes

one-shot (ch. 9)layered (ch. 10)
stepsegment -> tracesegment -> lineart -> segment -> complete -> lines -> draft -> a person looks -> paint
the question it answerscan a diffusion image become a 4K painting where every pixel has a provenancecan an artist take it over
Layerseach part cut into bands by luminance, one vector shape plus two paint layers per band; several hundred nodes, and no layer called “eye”each group = shape / flat colour / shading / named bands / lines; dozens of nodes
linesnoneeach layer’s own lines, one stroke at a time, each with a source and open to being overruled; a line in a hidden area is invented, and accounted for
what is hiddenemptya layering model invents the whole shape, only the hidden part is taken, and exactly which pixels those are is written down
Gatesnonedraft
needs ComfyUISAM onlyall four of segment, lineart, complete and per-layer lineart need it
how closely it resembles the sourcecloser, and sharper insideslightly softer; the difference is what layering costs

How to choose: for a picture that resembles the source with no interest in the layers, take the one-shot route; for a file an artist can edit, take the layered one. The comparison only exists once both have been run – point the criteria step at the other subproject’s finished picture and it produces the crops from both at matching positions.

Experiments

Trying an idea without changing the library: open a step and write its main.py however you like (it need not come from a template), calling the library’s pure functions. The conventions for an experiment: copy the plans from a baseline step, change the plan for one group only, turn the Krita job off and look at the 1x simulation first (which takes seconds), and after painting point the criteria step at the baseline so the two are compared on the same rulers. When it works, it sinks into the library as a pure function with tests, and the main.py stays in the step as the record.

Ten minutes: from one picture to a finished .kra

✅ verified · the times are the order of magnitude at 4x (4096 wide) on a 16-core machine with 46 GB of RAM and 16 GB of VRAM, and vary with the picture

Every step is a directory with one main.py in it, copied from that stage’s template in the library repo, with the two blocks of constants at the top edited (the upstream steps’ paths, and the knobs), and then run. There are no other commands.

A new picture

uv run python dtp/tools/comfy.py                        # segmentation needs SAM
mkdir -p work/pic/input && mv pic.png work/pic/input/origin.png
cp dtp/templates/project/parts.py dtp/templates/project/README.md work/pic/     # the parts table (from the template) and the project README
mkdir -p work/pic/phase1-layers/step01-segment
cp dtp/templates/segment/main.py work/pic/phase1-layers/step01-segment/
uv run python work/pic/phase1-layers/step01-segment/main.py       # the first run stops after writing regions.png, the map of region numbers

Read the region numbers off the map that run wrote and fill them into parts.py – which regions make up which sub-part, ordered bottom to top by occlusion – then change the words to ask for, the classes and the groups to what this picture actually has (ch. 1 for what they mean, appendix G for the field names), and run the same main.py again under the same number, since the first run does not count as a step:

uv run python work/pic/phase1-layers/step01-segment/main.py       # 40 seconds; look at review-parts.png

one-shot

mkdir -p work/pic/phase3-paint/step02-trace && cp dtp/templates/trace/main.py work/pic/phase3-paint/step02-trace/
uv run python work/pic/phase3-paint/step02-trace/main.py          # 2 minutes reading the picture plus 5 minutes of Krita painting; the output is krita/final.kra, and the criteria table goes into the README

Look at composite-1x.png and crop-*-1to1.png in phase3-paint/step02-trace/krita/review/. That is all.

layered

For each step: mkdir the next numbered directory, copy the template, point the # ---- upstream lines at the earlier steps’ directories, and run it.

# step02-lineart     half a minute; the lines on the source, seen in lines-review.png; to overrule, write overrides.json and run it again
# step03-segment     WALLS = step02, ASSIGN_FROM = step01: cut the regions again with the lines as walls (40 s); look at review-parts.png
# step04-complete    6 minutes of See-through (free the VRAM first with fuser -k 8188/tcp) plus 10 seconds of merging; look at review-amodal.png and review-restack.png
# step05-lines       1-2 minutes; each layer's own lines, seen in lines-<group>-review.png / review-lines.png
# step06-draft       1-2 minutes; the gate: krita/review/ per layer, per group and the 1:1 close-ups -- a person looks before going on   (phase2-draft/)
# step07-paint       10 minutes (7 of them Krita); the finished krita/final.kra          (phase3-paint/)
# step08-gauge       the criteria table, the ruler on the lines and the per-group rulers; REF_FINAL = another subproject's final.png, for matching-position comparisons
mkdir -p work/pic/phase1-layers/step02-lineart && cp dtp/templates/lineart/main.py work/pic/phase1-layers/step02-lineart/
uv run python work/pic/phase1-layers/step02-lineart/main.py

What each step gives you, and how long it takes:

stepwhat it leaves behindlook atorder of
segmentthe sub-parts, the silhouette and one mask per wordthe three-column review image40 s plus 2 s per word
lineartthe lines on the source, one stroke eachthe review image on top of the source35 s
completeeach group’s whole layer, and the account of what was inventedthe completion review, and the restack6 min of model plus 10 s of merging
lineseach layer’s own lines, each stroke tagged with its layer and whether it was inventedone review image per layer, and the combined onea dozen seconds per layer
draftthe draft’s plan, its strokes, and what Krita paintedthe review images, per layer and per group1 min planning plus 1 min of Krita
paintthe painting plan, its strokes, each group’s mask, and what Krita paintedthe same review images2-3 min planning plus 7 min of Krita
gaugethe criteria, and the matching-position cropsthe table in the README15 s
tracethe plan, the strokes, and what Krita paintedthe review images2 min reading the picture plus 5 min of Krita

The file names are fixed and are all in appendix F.

Each step’s README.md opens with the table of numbers main.py wrote and ends with ## Verdict, written by a person (pass or fail, and why; a rerun does not overwrite it). When a step is judged a failure: mv stepNN-<stage> stepNN-<stage>-legacy-wrong-1 to move it aside, and the rerun uses the same number – the living chain is always consecutively numbered.

Running a long job

In the background, with absolute paths, logging to disk:

(uv run python /abs/work/pic/phase3-paint/step07-paint/main.py > /abs/work/pic/phase3-paint/step07-paint/run.log 2>&1; echo "exit $?" >> /abs/work/pic/phase3-paint/step07-paint/run.log) &
grep -aE "\[paint\]|\[stage\]|JOB (OK|FAIL)|Traceback" /abs/work/pic/phase3-paint/step07-paint/run.log | tail

A working directory cd-ed into in one command carries over to later calls, so a background chain uses absolute paths throughout. Before starting one, pgrep -a -f main.py shows whether another is already running – a process left by a crashed session will write into the same directory as yours and queue work in ComfyUI at the same time (clear the queue with POST /queue {"clear": true} and /interrupt). Kill Krita with pkill -x krita and ComfyUI with fuser -k 8188/tcp, and never pkill -f (see “Installing and self-checking”).

Segment: cutting the picture into named parts

✅ verified (lines as walls 🧪) · needs ComfyUI · step 1 of both routes · code: appendix B · the parts-table fields: appendix G

What this step is for

A flat illustration is one block of pixels, but what an artist sees is hair, a face, a dress, shoes. To turn it into a layered painting, the first thing to do is give the pixels to parts: which part each pixel belongs to, and what that part is called. Like a jigsaw, you separate, turn over and recognise the pieces before doing anything else; every later step – deciding the lines, completing the shapes, laying the flats, painting – assumes this division. Cut wrong here and everything after it is wrong: lines land on the wrong layer, flat colours are sampled from the wrong place, and completion invents one part onto another.

The difficulty is that a part’s boundary is not in the colour. One head of hair graduating from blonde to pink differs in colour by more than hair does from skin; two pieces of skin (a neck and an arm) share a colour and are two different parts. So this step cannot classify by colour: it needs boundaries, words, and a little human input.

After this you will be able to

  • say what each of subject, region, sub-part, class and group means and which sits above which.
  • write the parts table for a new picture: how to fill in the region numbers and how to pick the words.
  • judge from the review image whether the segmentation is right, and know which field to change when it is not.

Concepts

Five tiers

What segmentation produces has tiers. The subject is the pixels where there is something (as opposed to background). The subject is cut into regions, connected blocks enclosed by boundaries, which have numbers but no names. A person merges regions into sub-parts and names them: back hair, fringe, face, neck, torso, arms, skirt, hem. Sub-parts are merged by “words a model can recognise” into classes (hair, face, arms and neck, dress), which is what the segmentation model votes with. Sub-parts are merged by “the layers an artist wants” into groups, and one group is one layer in the finished painting.

The sub-part is segmentation’s unit; classes are for the vote and groups are for the layers; a sub-part belongs to exactly one group.

The boundary barrier

The wall that encloses a region is the barrier. In this handbook the barrier is a drop in luminance: a pixel much darker than the brightest pixel near it is on a wall. An illustration is drawn that way in the first place – lines as walls, the interiors bucket-filled – and even in coloured lineart the line is darker than what is either side of it. A gradient is not a wall: the colour changes slowly, so no pixel nearby is much brighter.

Left: gradient hair, a flat dress, one dark line between them. Middle: the barrier, which is only the dark line and the outline. Right: the regions the barrier encloses, with the gradient not cut apart.

Lines as walls

A drop in luminance cannot see a very pale line. Once there is lineart (ch. 2), the arbitrated lines can be drawn as two-pixel walls and added to the barrier, so the regions split along them. A lock of hair lying across a neck is one region with the neck; make the pale line between them a wall and it becomes two.

Two parts of near-identical colour separated by one pale line: the barrier alone makes them one region, and with the line as a wall they become two.

The hand-written table and the majority vote

Once there are regions, who names them? Two sources. The hand-written table: a person reads the region-number map and writes each number under a sub-part’s name; the one-shot route uses only this. The majority vote: the segmentation model produces a mask per word (“hair”, “face”, “dress”…), and each region goes to whichever word’s mask covers most of it. The hand-written table is accurate but cannot separate two things inside one region; the vote can separate them, but is helpless about anything the words do not catch.

How much of each region a word’s mask covers decides whether the region goes to it; more than half takes the region, and below eighty per cent it is split per pixel.

Growing from a seed

The vote’s unit is a region. When hair wins a region that actually contains a small piece of neck, and the word covers only part of that neck, giving the whole region to hair is wrong. Growing from a seed: if that small piece is clearly a different colour from the winner, take it as a seed and grow outward by colour, stopping where the colour stops matching or where it gets too far from the seed.

Sub-pixel edges

Segmentation gives every pixel a hard label, so the edge is a staircase of pixel cells. But the source’s edges are antialiased: a boundary pixel’s colour is a blend of the colours either side, and the blend ratio tells you where the edge passes through that pixel. Solve for that position and the edge is accurate to a fraction of a pixel, which is what the vector shapes downstream are built from.

Left: an antialiased edge. Middle: hard labels, where the edge is a staircase. Right: the coverage alpha it solves for and the alpha = 0.5 isoline, accurate to a fraction of a pixel.

How it works

The subject. A pixel \(p\) belongs to the subject if and only if its opacity is high enough:

\[ S = \{\,p : \alpha(p) \ge \alpha_{\min}\,\} \]

\(\alpha_{\min}\) defaults to 200 (out of 0-255). Why opacity rather than luminance: many illustrations are PNGs with unpremultiplied alpha, where the transparent area still carries RGB, so judging by luminance takes that colour for subject. The other way round, a source’s alpha is often full of holes – a gap a few pixels wide between two legs has single-digit opacity while its RGB is skin. Treating such a pixel as background gives double lines, the black ground showing through, and completion seeping inward; so a hole whose RGB resembles its neighbours (a difference under 30) is put back into the subject.

The boundary barrier. Write \(L(p)\) for the luminance and \(N_w(p)\) for the window of side \(w\) centred on \(p\):

\[ B(p) = \Big[\ \max_{q \in N_w(p)} L(q) - L(p) > \tau\ \Big] \]

A pixel with \(B(p)=1\) is on a wall. The regions are the connected components of \(S \wedge \neg B\); a fragment of fewer than \(n_{\min}\) pixels is not a region and, along with the pixels on the walls, joins its nearest region by distance transform. Why “local maximum minus self” rather than a gradient: a dark line is bright on both sides, so the pixels on the line have a large drop, both sides become wall, the line is enclosed in the middle, and no region leaks across it; whereas a smooth gradient’s drop across the window is only its slope times half the window, far below \(\tau\), and is not a wall. The defaults are \(w = 7\), \(\tau = 25\) and \(n_{\min} = 300\). How to set \(\tau\): too small and the regions shatter into a thousand pieces, too large and the walls are overrun and the biggest region swallows half the picture; look at the number of regions and the area of the largest, and take something in between.

Lines as walls. Rasterize the lines that survived ch. 2’s arbitration into a 2 px set \(R\), and the barrier becomes \(B^{\prime} = B \vee R\). The new regions are finer than the old ones and inherit their labels: each new region takes the mode of the old labels it covers. The small piece that was cut off (the neck) still inherits hair, and correcting that is left to the next step’s vote – voting on its own, the word now reaches it.

The majority vote. For a region \(r\) and a class \(c\), the fraction of it the class’s mask \(M_c\) covers is

\[ v(r, c) = \frac{|r \cap M_c|}{|r|} \]

Classes are ordered by priority, small and specific first, and the first class with \(v \ge \theta\) wins, with \(\theta = 0.5\). A region whose winner has \(v < s\) is not given away whole but split per pixel: what is inside the mask goes to the winner and the rest keeps its old label, with \(s = 0.8\). A word whose mask covers more than 40% of the subject has degenerated into “the whole person” and is discarded. A sub-part the hand-written table marks as not voting keeps its whole region.

Growing from a seed. Where the winner is class \(A\) and the pixels of class \(B\)’s mask enclose a patch \(K\) inside that region with \(|K| \ge n_{\text{seed}}\) and \(\|\mu_K - \mu_A\| > d_{\text{sep}}\) (\(\mu\) being the median colour), that patch is a seed. A pixel \(p\) in the region goes to \(B\) if and only if

\[ \begin{gathered} \|c(p) - \mu_K\| < \tau_c, \\ \|c(p) - \mu_K\| < \|c(p) - \mu_A\|, \\ d(p, K) \le D, \end{gathered} \]

and is connected to the seed. The three conditions are: its colour resembles the seed, it resembles the seed more than the winner, and it is not far from the seed. The defaults are \(n_{\text{seed}} = 300\), \(d_{\text{sep}} = 30\), \(\tau_c = 35\) and \(D = 15\). \(d_{\text{sep}}\) stops a mask that has spilled onto a same-coloured neighbour (the dress’s mask spilling onto skin encloses a patch the same colour as the winner, which is not evidence); \(D\) stops the colour from growing along to another same-coloured part dozens of pixels away.

Sub-pixel edges. A boundary pixel’s colour is a blend of the two local colours: \(c = \alpha\, c_A + (1 - \alpha)\, c_B\), where \(\alpha\) is how much of that pixel belongs to \(A\). Projecting it onto the line between the two colours solves for

\[ \alpha = \frac{\langle c - c_B,\ c_A - c_B \rangle}{\|c_A - c_B\|^2}, \]

clamped to \([0, 1]\). The \(\alpha = 0.5\) isoline is the edge, positioned to a fraction of a pixel. The isoline is traced into a polyline through a signed distance field and then simplified with RDP (tolerance \(\varepsilon = 0.3\) px). Each sub-part’s ring is expanded outward by 0.6 px: neighbouring parts have to overlap a little, or the black ground shows through at the seam (ch. 5 covers seams).

The method

  1. Find the subject. Take the subject by opacity and fill the alpha holes back in. The one-shot route uses the subject with its holes left alone, so that it stays line for line identical to the earliest version.
  2. Cut the regions. The drop in luminance is the barrier, which encloses the regions, and fragments join their nearest region. Once there is lineart, the lines can be used as walls to cut again.
  3. Name the regions. The first run stops after writing the region-number map; a person reads it and writes the hand-written table: which numbers make up which sub-part, ordered bottom to top. Then run it again.
  4. Let the model correct it. One mask per word, words merged into classes, a vote per region, and where the winner is not convincing enough the region is split per pixel.
  5. What the vote cannot fix, grow from a seed by colour.
  6. Each sub-part’s edge. Solve the coverage, take the isoline, simplify it, expand it. What comes out is one ring per sub-part plus a review image.

Doing it

mkdir -p work/pic/phase1-layers/step01-segment && cp dtp/templates/segment/main.py work/pic/phase1-layers/step01-segment/
uv run python work/pic/phase1-layers/step01-segment/main.py     # the first run stops after writing regions.png
uv run python work/pic/phase1-layers/step01-segment/main.py     # fill in ASSIGN in parts.py and run again: 40 seconds plus 2 seconds per word

The second pass with lines as walls is another step: copy the template again, and at the top point its two upstream constants at the lineart step, whose lines become the walls, and at the first segmentation, whose table it inherits.

Look at the three-column review image: the hand-written table on the left, the state after the vote in the middle, and the pixels that changed on the right; and look at at least four 1:1 close-ups – the face, the neck, a hand and a foot. Three things to judge by:

  • every piece has the right name, with no whole piece in the wrong place.
  • the edges follow the antialiasing, biting into no neighbour and leaving no gap around anything.
  • every difference between the middle and left columns is a correction rather than damage (you can say why each change in the right column happened).

The verdict goes at the end of this step’s README.

Common problems

symptomcausewhat to change
a whole piece has the wrong name and no word catches itin the hand-written table it shares a region with its neighbour, and the vote works region by regiononce there is lineart, cut again with the lines as walls; or find a more specific word so that growing from a seed has evidence to work with
a mask spills onto a same-coloured neighbourthe dress’s mask covers skinthe seed’s colour-difference threshold stops it; check that word’s mask area
a seed grows too far, onto another partthe colours are close and the pixels are connectedreduce the growth distance
double lines between parts, or the black ground showing throughholes in the source’s alpha were taken for backgrounddo not loosen the subject threshold; fill the holes back in by RGB instead
a word’s mask is the whole personthe word is too broad (skin, upper body)discard it and use a specific word (red bow rather than ribbon)
the model’s mask is a size larger than the partthe segmentation model’s edges are not accurateuse it for position and not for shape (the eye’s shape comes from colour in ch. 6)
a part has more pixels after the lines become wallsthat area was always its own; before, it was broken up and misassignedlook at the close-ups, not the totals

Knobs

Knobsdefaultwhen to change it
alpha_min200almost never; loosening it to 80 pulls semi-transparent edges into the subject
split_below0.8lower it when two things each take half a region and the winner takes the whole of it
grow_tol grow_dist35 / 15raise the first when a seed will not grow, lower the second when it grows too far
the lineart step whose lines become wallsnoneset it to redo the cut once there is lineart
the segment step whose table is inheritednoneset it alongside the walls, so the hand-written table need not be filled in again

The constant names are at the top of this stage’s main.py and are listed in appendix A.

Summary

  • Segmentation goes by boundaries, not colour: a drop in luminance is a wall, a gradient is not, and lineart can be a wall.
  • The machine cuts the regions and a person names them; the model’s words correct that division and never invent a part.
  • Two things inside one region cannot be separated by the vote; that takes lines as walls or growing from a seed.
  • Edges have to be solved to a fraction of a pixel, because every vector shape downstream rests on them.
  • Look at the review image and at the four close-ups; going by counts alone gets it backwards.

Further reading

Lineart: how one line gets decided

✅ verified · needs ComfyUI · step 2 of the layered route · code: appendix B

What this step is for

An artist tracing a picture first decides which lines are real: the outline certainly, how many lines a skirt fold gets, whether to draw the strands of hair at all. A diffusion image has no lines in it – it is areas of colour and gradients, so the lines have to be found in it, and each line has to be one stroke: a smooth curve rather than a string of short segments, painted in a single go.

There are two ways to find them: the part edges segmentation gives, and a model made for extracting lineart. Both get things wrong, and they get different things wrong. This chapter is about putting both into one pool, letting the source decide which survive, and fitting each survivor into a single stroke.

On the layered route the lines in this chapter are the lines on the source. They are not the lines the finished painting draws – once the layers exist, each one extracts its own (ch. 4). These lines do three things: they let a person decide the layers, they serve as walls for segmentation (ch. 1, “lines as walls”), and they are what ch. 4 is compared against.

After this you will be able to

  • say what each of the two sources gets wrong, and why the source of a line cannot decide whether it is right.
  • read the three colours on the review image and write an override.
  • know how a shaky polyline becomes one smooth stroke, and why smoothing comes before fitting.

Concepts

One stroke: a G1-continuous cubic bezier (several segments joined, with matching tangent directions at the joins), painted in a single go. Its width and colour are properties of that stroke.

Source: where a candidate came from. Source one is the segmentation’s contours, the edge of each sub-part (ch. 1’s sub-pixel rings); source two is raster lineart, an image of white lines on black that a model computes from the source, which is thinned and then walked into individual strokes.

Candidate: a line in the pool whose fate is not yet decided. Each carries its source, which line layer it belongs to, and its fitted curve.

Arbitration: scoring every candidate against the source, keeping one line where several overlap, and dropping those that do not score enough. The only judge is the source: there really is an edge under the line, and the line rides that edge.

Source one (brown): every edge is there, but shaky. Source two (blue): smooth, but it invented a line the source does not have. Both go into the pool.

The method, on one page

  1. Source one: each sub-part’s ring is judged arc by arc – is this arc mine, is there an edge under it – and the surviving arcs are fitted into curves (2.1 The segmentation’s contours).
  2. Source two: the raster lineart is thinned into a skeleton, its endpoints and junctions are identified, and the lines are walked, joined, smoothed and fitted (2.2 Extracting raster lineart and 2.3 Skeletonizing).
  3. Arbitration: sample the source along each line’s normal to see whether there is an edge, compute its support and its smoothness, drop what falls short, and where lines overlap let the first one claim its ground while the later one loses only the covered part (2.4 Arbitration).
  4. Overriding: a person looks at the review image and forces a source by box or by line id in the override table, and it is rerun.

Doing it

mkdir -p work/pic/phase1-layers/step02-lineart && cp dtp/templates/lineart/main.py work/pic/phase1-layers/step02-lineart/
uv run python work/pic/phase1-layers/step02-lineart/main.py     # half a minute (with SEG at the top pointing at the segment step)

Look at the review image: over a faded source, blue is a kept raster-lineart line, brown a kept contour, and red dashes a dropped candidate. Three things to judge by:

  • the outline is one line per leg and one per arm, not broken into pieces.
  • the cut between two same-coloured parts has not been drawn as a line.
  • there are no lines inside the hair or the eyes that the source does not have.

Overrides go into overrides.json at the subproject root (the format is below), and it is rerun, which takes a few tens of seconds.

Common problems

symptomcausewhat to change
a real edge was droppedthe contrast there is too faint and the support falls shortname the source by box in the override table
a line was drawn between two same-coloured partsthat edge came from segmentation, its two sides are the same colour, and it should have been droppedfind out why it survived (usually the raster lineart drew a line there) and override it to neither
an edge became a string of short segmentsit was not smoothed before fitting, or the tolerance is too smallraise the fitting tolerance; leave the smoothing sigma alone
the raster lineart’s lines all shattered into a dozen pixels eachskeletonizing took the stairs on a diagonal for junctionsidentify junctions by crossing number, which is what it does now; see 2.3
there are more lines inside the hair than the source hasthe model drew a faint highlight band as a linethe finished painting suppresses some of them by line weight (ch. 6); override the obvious ones
the eye has no linesin the raster lineart the eye’s interior is a solid block, and the lower lashes are one or two pixels at original sizethe eye’s lines come from the shapes ch. 6’s eye method produces; this step leaves them alone
the mouth has no linesin the source the mouth is a small dark patch with no black lineneither source has one, so the finished painting has none; accepted

The override table’s format:

[{"box": [x0, y0, x1, y1], "use": "ai"},  {"id": "ct-12", "use": "none"}]

The first form is by box: full-image coordinates at original size, forcing every candidate inside the box to the raster lineart (ai), the contour (contour) or neither (none). The second is by line id. This table applies to ch. 4 as well.

Knobs

Knobsdefaultwhen to change it
fit_err3.0the tolerance for fitting a contour (px); raise it when the contour arcs come out too broken
line_min_len12arcs shorter than this are dropped (px); raise it when there are many fragments
thr70the threshold for binarizing the raster lineart; lower it when the lineart is too faint to catch
boxoffreport only the candidates inside one box, for debugging an override

Summary

  • Lines have two sources and both get things wrong; right and wrong are decided by the source image, not by which source the line came from.
  • A line is one stroke: smooth first and then least-squares fit, rather than interpolating through every point.
  • Where lines overlap, trim rather than drop the whole thing.
  • The lines in this chapter are the lines on the source; each layer’s own lines are in ch. 4.

Further reading

  • Code: appendix B, under lineart – source one, source two, and the arbitration; the fitting and the thinning are in the geometry layer.
  • Worked example: ch. 19, “The layered route” (how many candidates, how many survived, the median number of curvature flips, and images from all three extractors).
  • References are in the subsections.

Source one: the segmentation’s contours

✅ verified

What this section is for

Segmentation (ch. 1) gives every sub-part a sub-pixel ring. The ring is complete: every stretch of the part’s edge is on it. But a ring is not a line. Along one ring, some arcs are its edge against the background (draw them), some are the seam where a part above presses onto it (that line belongs to the part above), and some are the cut between two same-coloured parts (the source has no line there, so do not draw one). This section cuts the ring into arcs and keeps only the ones worth drawing.

Concepts

Ownership: which part an arc of edge belongs to. Where two parts meet, the edge belongs to exactly one of them – the one above. On the lower one’s ring, that arc is a seam and not its line.

Is there an edge: whether the luminance either side of the ring differs enough. Where the source has no drop there should be no line, even if segmentation cut there.

The rings of the torso and the arm. Blue: its own, with a real edge. Grey: a seam under the part above, which owns that line. Red: between two same-coloured parts, no edge, not drawn.

How it works

Take a point \(p\) about every pixel along the ring, with the normal \(\mathbf{n}\) pointing out of the part, and sample once inside and once outside:

\[ \begin{gathered} p_{\text{in}} = p + d\,\mathbf{n}, \qquad p_{\text{out}} = p - d\,\mathbf{n}, \\ d = 2\ \text{px}. \end{gathered} \]

Ownership: look at \(p_{\text{out}}\)’s label. If it is background or a part further down, the arc is mine; if it is a part further up, the arc is a seam and belongs to that part.

Is there an edge: the luminance difference \(\Delta = |L(p_{\text{in}}) - L(p_{\text{out}})|\). \(\Delta \ge \tau_s\) is a strong edge and \(\Delta \ge \tau_w\) a weak one (\(\tau_s = 10\), \(\tau_w = 5\)). A weak edge does not count on its own: a run of weak points survives only if it contains at least one strong point. This is Canny’s hysteresis threshold, which keeps a real edge from breaking where its contrast dips without letting purely weak noise become a line. An arc whose outside is background counts as having an edge outright.

A gap of fewer than 6 points is ignored and bridged.

The method

  1. Take each sub-part’s ring and compute the normal and the two samples point by point.
  2. Decide ownership from the outer label and drop the seam arcs.
  3. Decide whether there is an edge from the luminance difference and the hysteresis threshold, and drop the arcs with none.
  4. Open arcs in the same line layer whose endpoints are within 8 px and whose directions differ by less than 35 degrees are joined into one.
  5. Smooth (sigma = 1.5) and then least-squares fit into cubic beziers (tolerance 3.0 px, looser than for raster lineart because a contour is shaky to begin with); break the stroke where a join turns by more than 70 degrees; drop anything shorter than 12 px.
  6. Width: radius 0.9 for an outline against the background, 0.7 otherwise. Colour: sampled on the line and darkened to 28%. Width and colour are decided again from the source in the finished painting (ch. 6).

The candidates come out marked with source “contour”, carrying the line layer they belong to, and go into arbitration.

Ch. 4’s per-layer lineart uses the same method, only with the ring of that layer’s completed shape and a label map containing nothing but itself: everything outside is background, so every layer keeps its whole outline.

Common problems

symptomcausewhat to change
a part’s outline breaks at a seamthe arc under the part above belongs to that part, which produces that line itselfexpected; in the finished painting the two layers’ lines meet
a line was drawn between two same-coloured partsa small local drop fooled the hysteresis thresholdoverride it to none, or raise line_min_contrast
a contour line sits one or two pixels off the part’s edgethat is the deviation a tolerance of 3.0 allowsthe fill shapes use the same fit, so nothing is misaligned (ch. 5)

Further reading

Source two: extracting raster lineart

✅ verified

What this section is for

A contour has only a part’s outer edge; the structural lines inside a part – the direction of the strands, the folds in a skirt, the eye socket – are not on it. Those come from a model made for extracting lineart: give it the source, and it gives back raster lineart, white lines on black. This section is about what that is, how to choose one, and how to swap it.

Concepts

Raster lineart: a greyscale image the size of the source, with the lines bright and everything else black. It is pixels, not curves; turning it into individual strokes goes through 2.3 Skeletonizing.

Single-pixel lines: a line whose skeleton is one pixel wide, so its endpoints and junctions can be counted. That is what skeletonizing needs; a thick line with grey levels grows whiskers when thinned, and a solid block thins into a tangle.

Extractor: the model or algorithm that produces raster lineart. This handbook uses one (Manga2Anime) and produces two more every time purely for comparison (AnimeLineArt and LineartStandard); there are others, none of which has been run.

How to choose one

The three linearts differ only in the extraction; after that they all go down the same road. The criteria are what skeletonizing needs:

what to look atwhy
single-pixel lines, or thick lines with grey levelsa thick line’s skeleton runs down its middle, and grey edges grow whiskers that break it into fragments unless they are pruned cleanly
is the eye drawn as an outline or as a solid blocka solid block thins into a tangle and the eye’s lines are lost entirely
are the strands of hair separablestrands that are not separable become one thick line, and after arbitration only the outline is left
does it hallucinate outside the subjectsome extractors draw texture in empty areas; pixels outside the subject are discarded, but a hallucination near the outline gets in

The method

  1. The source goes to ComfyUI, which produces all three extractors’ output at once, one image each (appendix F).
  2. Everything downstream reads only the manga one.
  3. Skeletonizing (2.3).

Swapping the source

This step always reads Manga2Anime; ch. 4’s per-layer lineart has a knob for which extractor each layer uses. To add a new extractor: add a pair of nodes to the ComfyUI graph (the extractor and a save), name its output after the extractor, following the pattern in appendix F, and nothing else changes.

Further reading

Manga2Anime

✅ verified · this is the one in use

What it is. A network that extracts lineart from manga and illustration, trained against artists’ lineart: its output is thin black-and-white lines with no grey levels. The ComfyUI node is Manga2Anime_LineArt_Preprocessor, with weights erika.pth.

What it produces. Thin single-pixel lines; the eye as an outline, with one line each for the upper lid and the iris; strands of hair separated one by one; one line per layer of a skirt fold or a frill; and no hallucination outside the subject. It also draws things the source has no black line for – faint highlight bands, soft transitions between colours – and arbitration scores those against the source and removes them.

Why it is used. It gets all four of how to choose right. More than seven in ten of the lines that survive arbitration come from it, and the rest are contours.

Where it breaks. It draws a highlight band in the hair as a line (the finished painting suppresses some of those by line weight); the dark mass inside the eye is not a line, yet it draws a ring around it; and the lower lashes are one or two pixels at original size, which it cannot catch.

Reference. MangaLineExtraction (the model and its weights), https://github.com/ljsabc/MangaLineExtraction_PyTorch.

AnimeLineArt

✅ verified (run, but not used)

What it is. A generative anime-lineart network whose output looks like a pencil drawing: the lines vary in width and carry grey levels. The ComfyUI node is AnimeLineArtPreprocessor, with weights netG.pth.

What it produces. Lines more than twice as thick as Manga2Anime’s, with grey levels; the inside of the eye as a solid block; dense strands smeared together; and hallucinated texture outside the subject, with a grid pattern appearing in the empty areas at either side of the picture.

Why it is not used. A thick line’s skeleton is unstable when thinned and its grey edges grow whiskers; the eye’s skeleton is a tangle; and hallucinated texture near the outline enters the candidate pool and has to be dropped one line at a time by arbitration. It is a good-looking lineart for a person and a poor input for skeletonizing. It is still produced every time and kept in ai/ for comparison.

Reference. Anime2Sketch (a generative lineart network of the same kind), https://github.com/Mukosame/Anime2Sketch.

LineartStandard

✅ verified (run, but not used)

What it is. Not a learned model: it takes the edge response of the luminance after a Gaussian blur and keeps what passes a threshold. It is edge detection. The ComfyUI node is LineartStandardPreprocessor, needs no model, and takes a smoothing sigma (6) and a strength threshold (8).

How it works. With \(L\) the luminance and \(G_\sigma\) a Gaussian kernel, the response is

\[ E = \max\big(0,\ L - G_\sigma * L\big), \]

and anything below the threshold is set to zero. \(L - G_\sigma * L\) is “how much brighter than its surroundings”: a thin line disappears in the blur, so there is a response only at large-scale transitions between light and dark. That is an edge, not a line.

What it produces. A thick, glowing outline (the response is strong where the two sides differ a lot in luminance), with the internal structure smeared into grey bands and no single-pixel lines at all.

Why it is not used. An edge detector does not know what a line is; a blur of sigma 6 removes every thin line and leaves only the large-scale outline; and after skeletonizing the candidates are few and thick. Its use is as a fallback that needs no model: on a machine with no weights it can at least produce the outline.

Reference. The node’s implementation is in https://github.com/Fannovel16/comfyui_controlnet_aux.

The other extractors

📐 designed · not one of them has been run

ComfyUI’s preprocessor collection holds other lineart and edge extractors. Adding one takes a pair of nodes (swapping the source). They are listed here so that you know what exists, not as recommendations; to try one, look at its output against the four criteria in how to choose and put the numbers in ch. 19.

Skeletonizing: raster lineart into single strokes

✅ verified

What this section is for

Raster lineart is pixels: a line is several pixels wide, and where two lines cross there is a tangle. Painting needs curves: where a stroke starts, where it ends, and how it bends in between. This section turns the white-on-black image into cubic beziers – pare each line down to a one-pixel skeleton, count its endpoints and junctions, walk the skeleton into polylines, and fit those shaky polylines into smooth curves.

Concepts

Skeleton: paring a thick line inward from both sides until only a one-pixel medial axis is left. The paring must not break the line, and must not shorten it at its endpoints.

Endpoints and junctions: every pixel of the skeleton looks at its eight neighbours. One neighbour is an endpoint, two is the middle of a line, three or more is a junction – except that counting neighbours does not hold on a diagonal, for the reason below.

Interpolation and approximation: an interpolating curve passes through every point, so it shakes when the points do; an approximating curve only promises to stay within a tolerance of each point, which absorbs the shake. A stroke wants approximation.

Left: a thick raster line. Middle: the skeleton, with endpoints in green and junctions in red (by crossing number). Right: counting neighbours instead, where every stair on the diagonal becomes a false junction.

How it works

Thinning. Zhang-Suen: each round has two passes, and each pass deletes the boundary pixels that meet its conditions. Write \(P_1\) for the current pixel, \(P_2 \dots P_9\) for its eight neighbours clockwise, \(B(P_1)\) for how many neighbours are foreground, and \(A(P_1)\) for how many 0-to-1 transitions there are going round. The first pass deletes a pixel that satisfies all of

\[ \begin{gathered} 2 \le B(P_1) \le 6, \quad A(P_1) = 1, \\ P_2 \cdot P_4 \cdot P_6 = 0, \quad P_4 \cdot P_6 \cdot P_8 = 0 \end{gathered} \]

and the second pass replaces the last two with \(P_2 P_4 P_8 = 0\) and \(P_2 P_6 P_8 = 0\). \(A = 1\) guarantees that deleting it cannot break the line, the range on \(B\) keeps endpoints and interior pixels, and the last two conditions make the two passes pare from opposite directions so that the skeleton lands on the medial axis. This repeats until no pixel can be deleted.

Pruning whiskers. The short spurs thinning leaves are pruned one pixel per round: remove every endpoint pixel with a single neighbour, three rounds of it, and then thin once more.

Endpoints and junctions, by crossing number. Go round the eight neighbours and count the 0-to-1 transitions:

\[ X(P) = \tfrac12 \sum_{i=1}^{8} |P_{i+1} - P_i|, \quad P_9 = P_1. \]

\(X = 1\) is an endpoint, \(X = 2\) the middle of a line, and \(X \ge 3\) a real junction. Why counting neighbours fails: a stair pixel on a diagonal has three neighbours (the one before, the one after, and the corner), so it is taken for a junction and the diagonal shatters into segments a dozen pixels long. The crossing number counts how many lines leave this pixel, and a stair pixel has only two 0-to-1 transitions going round.

Walking and joining. Start at an endpoint and walk the skeleton to the next endpoint or junction to get a polyline; a closed loop nothing reached starts anywhere. Two segments meeting at a junction whose end directions differ by less than 60 degrees are joined into one – an artist’s stroke passes through a crossing rather than stopping at it. Two segments whose endpoints are within 8 px and whose directions differ by less than 35 degrees are joined too (thinning occasionally breaks a line in two).

Assigning a line layer. A line goes to the line layer of whichever sub-part owns most of the pixels beneath it; anything falling outside the subject is discarded.

Fitting. The polyline’s points \(p_1 \dots p_n\) are first Gaussian-smoothed along arc length with sigma = 1.5, and then fitted into cubic beziers by Schneider’s least squares: the two endpoints and their tangent directions are fixed, every point on the segment is given a parameter \(t_i\) (initially by arc length), and the control points’ distances from the endpoints \(\alpha_1, \alpha_2\) are solved from

\[ \min_{\alpha_1, \alpha_2} \sum_i \big\| B(t_i) - p_i \big\|^2 \]

, a linear least-squares problem in two unknowns. Newton-Raphson then moves each \(t_i\) to the parameter on the curve nearest \(p_i\) and it is solved again; if the largest error still exceeds the tolerance \(\varepsilon\), the polyline is cut in two at the point of greatest error and each half recurses. The tolerance is 1.2 px. Adjacent segments share a tangent direction at their join, so the whole thing is one G1-continuous stroke.

Why smoothing comes first: the fit is controlled by the maximum error, so when the tolerance is below three standard deviations of the noise no segment can contain that noise and it splits all the way down, turning one edge into dozens of segments. Smoothing pushes the noise below the tolerance, and one edge becomes one or two segments.

Breaking at corners. A join where the tangents of two adjacent segments differ by more than 70 degrees is broken – an artist lifts the brush at a corner.

A ruler for smoothness. A curve’s curvature is

\[ \kappa(t) = \frac{x^{\prime} y^{\prime\prime} - y^{\prime} x^{\prime\prime}}{\big((x^{\prime})^{2} + (y^{\prime})^{2}\big)^{3/2}}, \]

Count how many times the curvature changes sign along the curve, divide by its length and multiply by 100, and that is “sign changes per 100 px”. A line a person drew has one or two; a shaky polyline has ten or twenty. It is the smoothness score in arbitration and the ruler for lines at acceptance.

The same shaky points: on the left, interpolation through every one of them; on the right, smoothing followed by a least-squares fit, three segments, with the joins circled.

The method

  1. Binarize the raster lineart at a threshold and keep only the pixels inside the subject.
  2. Thin it to one pixel wide, prune whiskers for three rounds, and thin once more.
  3. Identify endpoints and junctions by crossing number.
  4. Walk the lines, join them at junctions by direction, and bridge small gaps.
  5. Give each line to the line layer of whichever sub-part owns most of the pixels beneath it.
  6. Smooth, fit, break at corners; drop anything shorter than 12 px.
  7. The width is three tenths of the median line width along it (clamped to 0.35-0.75), and the colour is the darkest pixel beneath the line, darkened.

The candidates come out marked with source “raster lineart” and go into arbitration.

Common problems

symptomcausewhat to change
the lines all shatter into a dozen pixels eachjunctions were identified by neighbour count, and every stair on a diagonal became oneuse the crossing number, which is what it does now
one edge becomes dozens of segmentsit was not smoothed before fitting, or the tolerance is below the noiseleave the smoothing sigma at 1.5; do not take the tolerance below 1
one stroke through a crossing breaks into twothe angle threshold for joining at a junction is too strictloosen it beyond 60 degrees
thin lines are not caughtthe binarization threshold is too highlower thr

Further reading

Arbitration: scoring and trimming

✅ verified

What this section is for

Both sources’ candidates go into one pool, and one edge often has two or even three candidates, some shaky, some hallucinated. Which survive cannot be decided by source – “raster lineart wins” and “contours win” are each wrong half the time. The only judge is the source image: there really is an edge under the line, and the line rides that edge. This section is about scoring, about keeping one line where several overlap, and about how a person overrules it.

Concepts

support: sampling along the line, the fraction of samples where the source really has an edge and the line rides it. It answers “is this line real”.

flips: how many times the curvature changes sign per 100 px. It answers “is this line shaky”.

Trimming: where lines overlap, the later line is not dropped whole; only the part covered by an already-kept line is cut away, and what remains is refitted.

Three candidates: the one riding the edge has high support, while the one 8 px off the edge and the one on flat colour have support 0. Right: the luminance across the edge along the normal, and its gradient peak.

How it works

Scoring. Take a sample \(p_i\) every 2 px along the curve and a luminance profile of \(\pm 4\) px along the normal \(\mathbf{n}_i\); the largest difference between adjacent pixels on that profile is the gradient peak \(g_i\), and the peak’s distance from the line is \(o_i\).

\[ \text{support} = \frac{1}{N} \sum_{i=1}^{N} \big[\, g_i \ge T \ \wedge\ |o_i| \le 2 \,\big], \]

\[ \text{score} = \text{support} - 0.05 \cdot \text{flips}, \]

with \(T = 8\) (luminance per pixel). Neither condition can be dropped: with the gradient peak alone and no position, a line 8 px off the edge still counts as having one; with the position alone and no gradient, any line on flat colour counts. A raster-lineart line riding a real edge has support near 1; a contour, having been expanded a little, has 0.5 to 0.7; a shaky line has flips in the tens and support barely over the line. The cut between two same-coloured pieces of skin has low support and is not drawn, which is right.

Dropping. Anything with \(\text{support} < 0.5\) is dropped outright.

Trimming. First sort: good enough (support at least 0.6 and flips at most 4) comes first, then length in doubling bands (\(\lfloor \log_2 (L / 20) \rfloor\)), then score. Going down that order, each line measures what fraction of itself falls within \(r = 4\) px of the already-kept lines \(K\):

\[ f = \frac{|\{\, i : d(p_i, K) \le r \,\}|}{N}. \]

With \(f < 0.2\) the whole line is kept; otherwise only the uncovered runs are kept, each at least 12 px, refitted (tolerance 1.0) into sub-candidates. Why that order: a long, good-enough contour should not be “beaten” one by one by a string of 40 px fragments lying along it, and non-maximum suppression by score lets one small fragment drop a long line entirely, so the outline disappears.

One long line, two fragments and a line that runs off. After trimming: the long line claims its ground first; a fragment lying wholly within 4 px of it is dropped; and the one that runs off keeps only the part nothing covers.

Overriding. After scoring and trimming, the override table forces the outcome by box (full-image coordinates at original size, testing a candidate’s centre) or by id: keep the named source and drop the rest.

The method

  1. Compute support, flips and score for every candidate.
  2. Drop the ones whose support falls short.
  3. Sort, and trim one line at a time.
  4. Read the override table.
  5. Write the review image and every candidate’s scores.

How to read the review image

The review image is a faded source with the kept lines coloured by which source they came from: blue for raster lineart, brown for a contour, drawn at the radius they will be painted with; dropped candidates are red dashes. The scores file beside it holds each candidate’s support, flips, score and the reason it was dropped – and there are only two reasons: the source has no edge under it, or such-and-such a percentage of it was covered by an already-kept line.

The order to look in: first whether the outlines are whole, one line each; then whether any red dashes should have been kept (a real edge dropped, usually because the contrast is too faint); and last whether any blue line is something the source does not have (a highlight band in the hair, the inside of an eye).

Common problems

symptomcausewhat to change
a real edge was droppedthe contrast is faint and the gradient peak falls short of the thresholdname the source by box in the override table
a hallucinated line survivedit happens to ride a soft transitionoverride it to none
a long line disappears and only fragments remainthe trimming order is wrong (sorted by score)the current order is good-enough first, then length, so this cannot happen; if it does, it is a bug
trimming goes wrong after a change of resolution\(r = 4\) px is defined at original sizescore and trim at original size, never on the enlarged image

Further reading

  • Code: appendix B, under lineart – scoring, resolving overlaps, applying the overrules, the arbitration itself, and the review image.
  • Worked example: the lineart section of ch. 19, “The layered route”.

Complete: every part’s whole shape

🧪 in progress · step 3 of the layered route · code: appendix B · the parts-table fields: appendix G

This chapter is still to be rewritten for the new method. The default is now the See-through merge: a model trained on Live2D layer data produces each layer’s whole shape, and we take only the shape and pixels of what is hidden, with everything visible pinned to the source (the last section of appendix E). The ring-by-ring approach described below, which handed the work to diffusion, was deleted from the library in 1.0.0 and is kept here only for comparison; its pitfalls are in appendix E.

What this step does

“The kettle is occluded, but the kettle itself can be painted whole as a layer and then covered by another layer.” Every layer – one group of the parts table – needs its whole shape, including the part hidden behind the layers above, so that hiding the dress shows a whole leg and hiding the body shows the whole of the back hair. A person decides the layers before this step, writing the groups from ch. 1’s region map and ch. 2’s lineart.

The rule: what is hidden is handed entirely to diffusion to invent the most primitive shape, rather than “spreading outward a bit more”; and the acceptance test is that stacking the completed layers back in occlusion order differs in nothing from the original, while each layer on its own is a whole, correct object.

Inputs and outputs

inputfrom
the vote’s labels and the subject, plus one mask per wordsegment
which sub-parts make up each group, and what each group may be completed fromthe parts table
inpainting and SAMComfyUI
outputcontents
what the model was given and producedper group and per ring: what it was shown, the hole, what it painted, and the final completed image
the accountper group, the whole shape and the hole – the hole being exactly what was invented
the tallyper group: how many pixels were visible, how many were added, which rule did it, and the outlines
each layer’s pixelsthe source where visible, the completed image where invented
the review imagesthe completion review, and the restack

The file names are all in appendix F; the parts-table fields are in appendix G.

The method

For each group G, bottom to top in occlusion order:

  1. The occluded area is the pixels of every group above it (visible background is not an occluder, and G does not reach there).
  2. Have the inpainting model repaint the hole; everything visible is pinned to the source – VAEEncodeForInpaint’s mask covers the hole only, and when compositing, the visible area always uses the source’s pixels.
  3. Recover G from the completed image: GroundingDINO plus SAM cuts once with the words a person chose for G, subtracts the words to be taken away, intersects with the hole, and keeps only the pieces connected to what was already known; where SAM catches nothing it falls back to a colour rule (resembles G rather than the occluder).
  4. The whole shape is the visible area united with what was recovered; the layer’s pixels are the source where visible plus the completed image where invented; and solving alpha, tracing the rings and fitting them gives ch. 5 its Shape.
  5. The restack test: stack the groups back in occlusion order and compare pixel by pixel with the source inside the subject. The visible area is pinned, so the difference is zero by construction – what this measures is holes and misalignment in the masks, and any reading other than zero means one of those went wrong.

The shape of the hole decides what the model paints. This is the most expensive sentence in the chapter: to an inpainting model, the shape of the hole is the prompt. Erase the whole occluded area at once and a dress-shaped hole produces a dress, a person-shaped hole produces a person, and neither the prompt nor the context changes that; “show it only itself” (erasing every other part to background) behaves the same way. It only holds when the hidden part is small relative to the hole.

So it has to grow outward one ring at a time: the hole is a ring d_step px outside what is already known, and the model sees only “this object plus background” and an annular hole – an annulus has no silhouette to copy. Whatever the model paints of the object into that ring is taken in, and another ring is grown; when it stops painting the object into the ring (less than 5% of the ring is recovered) it stops, up to max_it rings. Each ring is one inpainting pass and one recovery.

What is under clothing is invented too, but only its shape is used. The pixels under the skirt always sit below the dress layer and are never visible in the finished painting; the prompt asks the model for plain close-fitting clothing rather than a nude (nsfw / nude / underwear are permanently in the negative prompt). Ch. 4’s per-layer lineart extracts lines from those invented pixels, and ch. 6’s painting copies them in the hidden area – the only places they ever surface, and both are accounted for.

A group with nothing above it is not completed, and neither is a group cut out of a mask (the eyes): it sits on the face, and its mask is already the whole shape.

Knobs

parameterdefaultmeaning
d_step48how many pixels each ring grows outward (at 1x)
max_it8how many rings at most; stopping at the limit means it did not converge, so look at the review image
seedthe project’s seedthe inpainting seed; a different seed invents a different shape
use_samtruewhether to recover with SAM or with the colour rule alone
shape_expand smooth eps1.2 / 0.8 / 0.30the whole shape’s ring expansion, sigma and RDP tolerance

What a person decides here

Under the current method there is one decision: which of the layering model’s layers each group may be completed from – a group nobody listed only gets its seam filled. Its field name and format are in appendix G.

The two below belong to the ring-by-ring method described in this chapter, which is no longer in the library (see the note at the top); they are kept for comparison.

the decisionhow to make it
what each occluded group is asked forone entry per group that will be occluded: the positive text describing only the object itself and saying plainly that there is nothing else; the extra negative text, which is the occluders’ names; the words that recover it from the completed image; and the words to subtract (skin is person minus hair and shoes). A group with nothing above it needs no entry
the style and the global negativethe style text, the global negative text (leave the nsfw / nude / naked / cleavage / underwear string alone), and one sentence per group

Neither of those two fields is in the parts table any more; appendix G lists the fields that are.

Changing these means rerunning complete under a new number, and rerunning lines / draft / paint after it.

Reviewing and overruling

The completion review: one row of four panels per group – the occluded area / the completed image / this layer alone / its outline. Look for: whether what was invented is this object at all (plain clothing under the skin rather than a nude or a black top; hair that is hair rather than a person-shaped silhouette); whether the shape grew too far; and whether SAM degenerated in the last few rings, which the tally records as the rule that ended up being used. The restack image: source | restacked | the difference in red and what nobody claimed in yellow – which must be 0.

What to change: it invented the wrong thing -> what that group is asked for, and what it is asked not to produce; it recovered too much or too little -> the words the group is recovered from (appendix G); the shape changes wildly with the seed -> try other seeds to see how stable it is, and instability means the model has no evidence.

Where it breaks

  • Erasing everything at once: the hole’s shape becomes the prompt, and it invents the occluder’s shape.
  • Showing it only itself: the same.
  • The model dresses or undresses the body: only the shape is taken, the negative prompt is there, and once the visible area is pinned nothing visible can be changed.
  • Subtracting into nothing: during recovery a word in minus degenerates into the whole person on the completed image, the subtraction empties the mask, and the occluder ends up in this layer. Every step of the recovery rule needs the “discard anything covering more than 90% of the subject” check, not just the first.
  • SAM degenerating in the last rings: when it catches nothing it falls back to the colour rule, and stopping at max_it is stopping, not converging.
  • The large area behind has no evidence: for back hair hidden by the whole body there is nothing in the source at all, and it rests entirely on the model’s prior that long hair hangs behind; a different seed gives a different shape. A real amodal-completion model, of the kind that produces shape without pixels, has not been tried.

Honesty

The invented pixels are invented. The account holds, per group, the hole and the whole shape, and beside it this layer’s pixels – the source where visible, the completed image elsewhere – so which pixels came from the model is never in doubt. A line ch. 4 extracts from those pixels carries its own invented fraction, in the same account.

On origin

How much each group gained, how many rings, which recovery rule, the restack difference, and the comparison images for erasing everything at once and for showing it only itself: ch. 19, “The layered route”.

Per-layer lineart: each layer’s own lines

🧪 in progress (it is in the library and running on the worked example) · it reuses ch. 2’s candidate pool and arbitration · needs ComfyUI · step 4 of the layered route · code: appendix B

What this step does

Once the layers exist – after ch. 3, each one is a whole object – each layer gets its own lines. Each is put alone on a ground and a lineart is extracted from it, with the same candidate pool and the same arbitration as ch. 2 and exactly one difference: the image being scored is that layer’s own image, which is the source’s pixels where visible and the completed image where hidden. A line belongs to the layer it was extracted from, with nothing to guess; the hidden part has lines too, drawn inside the group and visible only when the layers above are hidden; and because those were extracted from invented pixels, every one of them is accounted for.

Why ch. 2 is not enough: extracting the lines before the layers exist leaves nothing to assign them by but “how many sample points it covers”, so at an occlusion boundary two layers’ lines are mixed together; and the hidden part has no edge on the source to score against, leaving only an unscored ring of contour. This step puts the order right.

Inputs and outputs

inputfrom
each layer’s complete pixels, and the whole shapes with their invented areascomplete
the sub-parts and the silhouette, plus one mask per word, for the groups cut out of a masksegment
the lines on the sourcelineart (optional, used only for comparison)
which sub-parts make up each group, which group is cut out of a mask, and which lineart layer each layer’s lines belong tothe parts table
one lineart per layerComfyUI
output (stepNN-lines/)contents
the linesevery surviving line, one stroke per row, with two things ch. 2’s lines do not carry: which layer it belongs to, and what fraction of it falls in the invented area
one review image per layerthe same three colours, plus that layer’s scores
the combined review imagethe kept lines drawn over the source and coloured by layer, the pale ones being in the invented area
the tallythe line layers, the candidate / kept / invented counts per group, and the agreement with the lines on the source
what ComfyUI was given and producedthis layer alone on a flat ground, and the raster lineart it came back with (only the one extractor in use)

The file names are all in appendix F; the parts-table fields are in appendix G.

The method

For each group, in the order the parts table gives them, with a mask-cut group inserted after the group it was cut out of:

  1. This layer’s image: a completed group uses its completed pixels; a group with nothing above it and no completion (the topmost hair, the eyes cut out of a mask) uses the source multiplied by its visible mask.
  2. This layer and nothing else: on a flat ground, with nothing else at all. The ground’s brightness is chosen against the layer’s own colour – a bright layer gets dark grey and a dark layer light grey – so that the outline is always a real edge on this image and the extractor is certain to draw it, which is exactly the outline wanted. No glow and no other parts: lineart extraction needs no context, and given some it takes it for lines.
  3. Extract: ComfyUI produces this image’s lineart (manga by default, changed with --source=; ch. 2, “source two”).
  4. The candidate pool: skeletonizing (ch. 2, “Skeletonizing”) plus the ring of this layer’s whole shape (ch. 2, “source one”), treating this layer as the only sub-part there is.
  5. Arbitration: scoring, trimming and overrides.json exactly as in ch. 2, except that the support for a line is measured against this layer’s image, not the source.
  6. The account: every stroke carries group = this layer, part = LINE_LAYER_OF_GROUP[group], and made_up = the fraction of its samples falling in region_<G>, the invented part.

Against ch. 2. The lines in the visible part (made_up < 0.5) and the lines arbitrated on the source are compared both ways: the fraction of samples falling within 4 px of the other. Both being high means the visible part did not change, and the differences should be at the occlusion boundaries only – where ch. 2 guessed which layer a line belonged to and this step knows.

Knobs

parameterdefaultmeaning
sourcemangawhich extractor to use (ch. 2, “source two”)
all of ch. 2’s knobsthe samepassed straight through to the candidate pool (thr, fit_err, line_min_len, …)

What a person decides here

the decisionhow to make it
which lineart layer a layer’s lines go toone entry per group, the mask-cut ones included; a group nobody listed gets no lines

The field name, its format and the step to rerun are in appendix G.

overrides.json is shared with ch. 2 (its boxes are 1x full-image coordinates and apply to both steps).

Reviewing and overruling

One review image per layer: that layer alone on a ground, with blue for a kept AI line, brown for a kept contour and red dashes for dropped. Look for: whether the outline is a single line; whether the lines in the hidden part (the pale ones in the combined image) look like this object’s lines – they were extracted from invented pixels, so if the model invented the wrong shape the lines are wrong with it; and whether the eye layer has any lines at all. An agreement below ninety per cent in the tally calls for looking at which layer it is, and it is usually that layer’s visible mask disagreeing with ch. 2’s guess.

Where it breaks

  • The image given to the extractor must contain nothing but this layer: the glow reference is black in its depths, and once a person-shaped black silhouette got into the image the extractor drew the whole person’s outline into that layer’s lineart. Use a flat ground.
  • A line in the invented area is invented: it carries its own invented fraction, and ch. 3’s account says which pixels those were; in the finished painting they sit under the layers above and surface only when those are hidden.
  • The extractor always draws a ring around a layer’s outline: which is right, since that is the outline; but when a layer’s mask has a hole in it (the eyes cut out of the face) it draws a ring around the hole too – and a hole’s edge belongs to the layer above.
  • A layer too small (a few hundred pixels) is skipped and gets no lines.
  • Disagreeing with ch. 2: a low agreement does not necessarily mean this step is wrong; more often ch. 2 assigned a line to the wrong layer by “how much it covers”.

On origin

The candidate / kept / invented counts per layer and the agreement with ch. 2: ch. 19, “The layered route”.

Draft: the layers, the flat colours, each layer’s lines

✅ verified (the flat colours and the tree) · 🧪 in progress (each layer’s lines, ch. 4) · step 5 of the layered route, and the gate · code: appendix B · the probes behind it: appendix C

What this step does

Stack what the first four steps produced into a layer tree a person can use and paint one version for them to look at: every layer named, a whole object (ch. 3), and able to be hidden or edited on its own; each layer’s shape a vector with a flat fill of its local colour inside it – every pixel of that flat colour used to be a stroke too, not a fill; and each layer’s own lines drawn inside its group. Hide the dress and you see a whole leg and the lines on it; hide the body and you see the whole of the back hair. That is the draft: lines plus flat colour. A person looks at it and decides whether to go on to painting (ch. 6), which paints into each of its layers.

Inputs and outputs

inputfrom
the sub-parts and the silhouette, plus one mask per wordsegment
each group’s completed layer, and the account of what was inventedcomplete
each layer’s lineslines
which sub-parts make up each group, which groups share a flat colour, which group is cut out of a mask, the occlusion order, and where the close-ups are centredthe parts table
outputcontents
the draft’s planone entry per group – its name, its local colour, its outlines and what it fills – plus the names of the line layers, the silhouette, and the close-up boxes
the flat strokesthe long strokes that lay each local colour
the lineseach layer’s lines, copied over from the lines step unchanged
the 1x simulationwhat the plan looks like before Krita ever opens
what Krita producedthe .kra, the composite, the painting journal, the manifest, one image per stage, and the review images

The file names are fixed and are all in appendix F; the parts-table fields are in appendix G.

The tree

09_Post/
06_Line/                       (left empty; the lines are drawn inside each group)
04_Color/
  <group>/ …                   in GROUPS order, bottom to top; each group inheritAlpha -> silhouette
    Shape                      vector: this layer's whole shape (amodal), fitted cubic beziers,
                               filled with the flat colour -- this is what owns the edge
    <G>_Flat                   the flat colour: long flat strokes covering it, clip -> Shape
    <G>_Line                   this layer's own lines, not clipped (the outer half of a line has to
                               show), covered by the groups above it
  Subject_Silhouette           the vector silhouette, at the bottom of the group
01_BG/
  BG_Base                      the draft's pale grey ground, the one place it writes pixels

Painting (ch. 6) adds _Shade, the named bands, _Blend and _Strand inside each group, and the shape of the tree does not change. The one-shot route’s tree is a different thing (ch. 8).

Four rules. Vectors own the edges and strokes own the interiors (an edge is an analytic curve and resolution-independent; inside it, every pixel is a stroke). Layers may overlap (each group’s Shape is its whole shape, and its lines are drawn inside the group rather than on a top layer). One local colour per group (the value structure comes from the weight of the strokes and from named bands, not from cutting a pile of bands). And an upper Shape may not cover a lower layer’s visible pixels – Shapes are expanded so that neighbouring parts overlap and no ground shows through, but however smooth the expansion, an upper shape then extends a ring beyond the lower one, and covering a lower layer’s visible pixels is painting it out of existence. So the outline is pulled back to the zero isoline wherever it would trespass – the forbidden area being the union of the visible masks of every group below it – and expands as usual everywhere else, so the transition is continuous with no steps. That forbidden area accumulates bottom-up in the groups’ own order, and a completed group traces its outline from its whole shape rather than from what is visible of it.

Clipping is the pivot. Each group is setInheritAlpha(True)-ed to the silhouette, and the flat layer is clipped to the Shape: a vector layer can serve as the base and passes its fractional alpha through to the paint layers above it, so the edge stays analytic. A group must not be passthrough, and the flat layer has to be at the bottom of its group. The three measured rules and their readings are in ch. 13.

Seams. Parts are vectorized separately, so their boundary curves never meet exactly and the black ground shows through at the seam. Splitting alpha between neighbours by coverage (0.5 each) is the wrong road: Krita composites alpha-over rather than as a weighted sum, so two layers at 0.5 leave 25% of the background, which on a pure black ground is a string of black dots. The right way is the artist’s: every part is opaque in itself, its Shape is expanded by shape_expand (1.2 px; too small and a sliver of black ground shows between the outline and the fill), and the occlusion order lets the upper one cover the lower. The bulge that creates is cut off by clipping the parts group to the silhouette, and the background fills the whole canvas on the bottom layer (ch. 6).

Fitted rings. A Shape’s rings and its lines use the same fit (ch. 2): a fitted line sits at most 3 px@1x from the mask’s edge, and a Shape fitted any other way leaves the line and the fill’s edge out of register. Alpha is solved on “the source, with the completed image substituted only in the invented region”; solving it on the completed image throughout shifts the visible part’s edge by 1 px.

Flat colour

The local colour is the modal cell of the 16-cubed RGB histogram of the brighter half of that area’s pixels, taking the mean of the pixels in that cell. Two more intuitive rules both get it wrong:

rulewhat it gets wrong
the median colour of the brightest 40%a saturated red comes out as the pink of its highlight
the largest band by area from k-meansin a part with a gradient, one end of the gradient has the largest area and the whole part takes that colour

The modal cell gets both right. “Only the brighter half” is because the local colour is the colour of the lit side, and shadows and the dark end of a gradient do not count.

Clustering. Members of a group whose colours are far apart each get their own path: a chroma difference above 30 splits them (several ribbons of different colours in one group each get their own colour), while a member that is merely darker because it is in shadow does not split. Groups a person marked as sharing a colour take one between them (front and back hair are the same hair, and an artist picks one colour for both).

The flat colour is that vector shape, with no strokes at all. There used to be a filling pass here: scanning the mask along its principal axis at a fixed spacing and then patching the holes with a small brush, more than thirteen hundred strokes a picture. Those strokes drew nothing new – the Shape vector layer had already filled the same area with analytic bezier rings, whose boundary deviation of 0.0828 is the best edge in the library and independent of resolution. What forced it to be covered was the old criterion “strokes cover at least 95% of the Shape”, a ruler that measures whether it was filled rather than whether it was right; both were deleted together (ch. 7).

So <G>_Flat is now empty and therefore does not enter the layer tree (stages/tree.prune). The draft’s tree fell from four nodes per group to three.

Each layer’s lines

Ch. 4’s lines were arbitrated per layer and each carries a group, so this step only checks the group names (a mismatch is an error outright: lines and draft read different segment or complete steps). The lines are drawn into <G>_Line, not clipped to the Shape (the outer half of a line has to show), and covered by the groups above – a line on a leg under the skirt is invisible until the dress is hidden. Lines in the invented region (made_up >= 0.5) are counted separately in the README.

Knobs

what it controlsdefaultmeaning
the Shape’s outward expansion1.2how far outside the tidied region the vector outline may sit (px@1x)
fitting the outlines2.0 / 1.5the tolerance the arcs are fitted to, and the blur before fitting
the isoline0.8 / 0.30 / 1.0the blur before the isoline is taken, the simplification tolerance, and how far the silhouette is pushed out
the ground220,220,220 / same as the ground / one paintPath per arcthe draft’s background colour, what colour the silhouette is filled with, and how lines are drawn (the alternative tapers both ends)
the multiplier, and whether Krita runsthe project’s multiplier / onturn the job off and the step produces only the plan and the 1x simulation

The constant names are at the top of this stage’s main.py and are listed in appendix A.

What a person decides here

the decisionhow to make it
which sub-parts make up each groupa group is the union of some sub-parts; the order is bottom to top, which is the occlusion order, the order of the groups in the .kra, and who occludes whom during completion. Anything you want filled as its own colour has to be a separate sub-part, since the clustering works on members
which groups share one local colourfront hair and back hair are one colour, not two
which group is cut out of another with a maskits name, which mask, the group it goes after, and the sub-part the mask is intersected with
the sub-parts’ occlusion orderthe same order ch. 1 established; the one-shot route’s tree follows it too

The field names, their formats and the step to rerun after each change are in appendix G.

Reviewing and overruling

This is the gate, and everything to look at is in the review images the painting wrote (appendix F): each layer’s flat colour on grey, to see the coverage and where the holes are; each group rendered alone with nothing over it – “is the kettle whole”, and where the hidden lines can be seen; the 1x composite, to check the local colours (a saturated thing must not come out as its highlight, and a gradient thing has to take its dominant colour); the 1:1 close-ups, centred where the parts table says, at least the face, the neck, a hand and a foot; and the manifest, for the whole tree at a glance. Look for: whether the group order is right (the upper covering the lower), whether each group is a whole object, whether each line is on the layer it belongs to, and whether there is any layer you cannot name.

What to change: the order, and which sub-parts make up a group, or which group is cut out of a mask (appendix G); a wrong colour -> whether that area should be two sub-parts; insufficient coverage -> usually fine structure, so check whether the second pass filled it; a wrong line -> ch. 4’s overrides.json; a wrong invented shape -> ch. 3. The verdict goes at the end of the step’s README.

Where it breaks

  • An upper Shape covering a ring of the lower one: the expansion had no forbid.
  • Judging clipping by RGB alone: it concludes “not clipped” (ch. 13).
  • A passthrough group: clipping stops working, and a multiply inside the group no longer multiplies what is under the group either.
  • Splitting alpha at a seam: a string of black dots.
  • The median colour coming out as the highlight and k-means’ largest band pulling it off (the table above); and the brightest band is not necessarily Light (on a white part).
  • Fine things not getting filled: the big scanning strokes skip them, and the perpendicular second pass catches them.
  • Local colours disagreeing across groups: front and back hair sampled separately differ slightly, which is what the shared-colour decision is for.
  • Node explosion: one node per part per k-means band, with meaningless names – such a tree is “a great many layers no human ever wanted” (which is by design what ch. 8’s tree is).

On origin

The number of groups, each group’s local colour and clustering, the number of flat strokes, the coverage, the node count, the .kra’s size, and the images of each group rendered alone: ch. 19, “The layered route”.

Paint: how each part is painted

By part: hair 🧪 · skin 🧪 · eyes 🧪 · stockings ⬜ · clothing 📐 · shoes 📐 · metal and jewellery ⬜ · buildings ⬜ · trees ⬜ · sky and clouds ⬜ · water ⬜ · background ✅ · the copy-it baseline ✅ (not a way of painting) · step 6 of the layered route · one module per part, and the registry a method name resolves through: appendix B · where a person writes the choice: appendix G

What this step does

Paint the light and shade into each layer of the draft (ch. 5) by that part’s own method, then lay the background, finish and save. Three general rules: a stroke samples its colour inside its group and lands inside its group, and no stroke crosses a group boundary; the reference is the source cropped to that group’s whole shape (with the completed image where it is hidden, ch. 3); and how to paint is a question for the artist, not a decision to make for them – one method per part, written by a person into the picture’s own parts table.

Why part by part

When an artist paints a character, hair, skin, eyes, fabric and metal each get their own technique. Hair is divided into locks, one highlight band per lock with a dark seam between them. Skin is one hard-edged tier of shadow, but the face (the shadow under the fringe, the nose, the blush), the neck (a large shadow under the chin), the thighs (a cylinder’s gradient plus the hard shadow the skirt presses onto it) and the arms are each different. Eyes are a few named shapes. Fabric depends on its material, its folds being hard-edged grey shadows one after another. Metal works by a bright band and a dark one pressed right against each other. No single method paints every part – this project has been caught out three times over exactly that: cel banding applied to eyes gives a black blob; applied to hair the locks disappear; and the error-driven copy applied to everything produces a complete picture that a person looks at and says is not painting, it is an airbrush reproducing the source (ch. 19).

So this chapter has one page per part, each with the same skeleton: how an artist paints it (the value structure, the shape of the shadows, how hard the edges are, the highlights, the brushes) · what it looks like in the source · our method, with its status · which layers it creates in the .kra tree · how a person writes it into the parts table, and its knobs · the criteria · where it breaks · on origin. A part nobody has studied is a ⬜ placeholder: only “how an artist paints it” and “what has to be decided”, with an empty module in the code – choosing it passes the parts table and reports “not decided yet” when painting starts. All three parts that had been done (skin, eyes, hair) produced output a person judged a failure, so all three went back to 🧪 to be redone one at a time, hair first.

partpagemethodstatus
HairHairhair🧪
Skin: face, neck, arms, hands, thighs, calves, feetSkinshadow_tier🧪
Eyes and mouthEyes and moutheye🧪
StockingsStockingsstocking⬜
Clothing: fabric, folds, frills, ribbons, patternsClothingcloth📐
ShoesShoesshoe📐
Metal and jewelleryMetal and jewellerymetal⬜
BuildingsBuildingsbuilding⬜
TreesTreestree⬜
Sky and cloudsSky and cloudssky⬜
WaterWaterwater⬜
BackgroundBackground— (the background is not a group and has no entry)✅
the copy-it baselinebelow on this pagegeneric✅ it produces a picture, but it is not a way of painting

Inputs and outputs

inputfrom
the draft’s plan, its flat strokes and its linesdraft
the segmentation, and one mask per wordsegment
each group’s completed layer and the account of what was inventedcomplete
which method paints each group, which groups grow strands or get hatching, and where the close-ups are centredthe parts table
outputcontents
the painting planper group: the method, the bands (shadow / deep / light, each with its outlines and its colour), whether there is a blend pass or strands, and the simulated likeness before and after; plus the close-up boxes
the draft’s plan, rewritten in placethe eye method replaces the eye group’s shape with the eye area the source defines
the strokesone row per stroke; what kind it is decides which layer and which brush it lands on
the linesweighted against this layer’s own image; the background’s own strokes travel with the painting strokes, not the lines
each group’s visible maskat 1x, and mutually exclusive – this is what the per-group rulers measure inside
the 1x simulationwhat the plan looks like before Krita ever opens
what Krita producedthe finished painting; a manifest that has to agree with the tree; one checkpoint per group; and the review images – each layer and each group alone, plus the close-ups

The file names are fixed and are all in appendix F; the parts-table fields are in appendix G.

A person decides each layer’s method

One row per group in the parts table: the method’s name, taken from the list above, plus that method’s own knobs (each method’s page says what they are). One row is the default, and it is required – a group nobody listed falls back to it. The exact field name and format are in appendix G.

A misspelled method name fails the parts table, before anything runs; it used to blow up only at the moment of painting. Naming a placeholder is not a misspelling: it passes, and then reports “not decided yet” when painting starts, telling you which page to go and write.

Each group is dispatched once, and a method may produce nothing but strokes and its own entry in the painting plan, whose keys are exactly the ones the painting job understands; the job builds the layers from those keys and decides nothing itself. A new method is a new module with the same signature, one row in the registry, and its name in the parts table. No if on a group’s name is allowed in the library; every part-specific special case lives in the picture’s own parts table.

Three more decisions sit next to the method: which groups grow strands, which sub-parts the one-shot route hatches, and where the 1:1 close-ups are centred (in 8x coordinates, scaled for any other multiplier – at least the face, the neck, a hand and a foot). All four are in appendix G, with the step to rerun after changing each one; for the method itself, only paint has to run again.

The copy-it baseline

Error-driven: on a 1x simulated canvas, wherever it differs from the source by more than a threshold, a stroke starts there and follows an isochrome. Soft brushes (b) Airbrush Soft, at three scales) copy the large areas of light and shade, and then a small hard round brush (b) Basic-5, R=3, with a second pass at R=1.5 on groups dense with strands) copies the hard edges between colours back in – remove the hard brush and the group’s 1x likeness falls by 2 dB. The groups a person marked as growing strands get short tapered strands along the flow field, and the lines are coloured by weight.

It produces a complete picture, but it is not painting: there is not one named shadow shape, the hair is random strands plus airbrush, and the frills are mottled by tens of thousands of small dots. Every stroke is planned at 1x and painted four times larger, so a soft brush of radius 13 px becomes 50 px at 4x and the whole thing is necessarily blurry (its interior sharpness is half the source’s, ch. 19). It is kept as a ruler: each part’s method has to beat it on that part (the per-group rulers, ch. 7). Known fault: its pass table special-cases two groups by name, which violates “no part names in the library” – the baseline is left as it is, and that goes when each part is rewritten.

Lines: 色トレス (colour trace)

In the finished painting the lines are not black. Each line takes its colour and width from how dark what is beneath it is on its own layer’s image: the source’s pixels where visible, the completed image where invented (ch. 4’s group says which layer it is). An outline (one lying against the background) gets one weight, a thick line another, a thin one a third; a line not darker than its surroundings by line_drop is not drawn; and lines inside hair are judged more strictly, because strands are thin and pale in the source. “The darkest pixel” is looked for inside this layer only – half an outline lies over the background, and unconstrained it takes the background’s colour throughout (ch. 17, case 29). Filtering on darkness alone is a known fault of this approach: the pale lines of a skirt fold or a strand are all lost, and what survives is uniformly thick. Lines should be decided by the part’s own method (a hair line is the edge of a highlight band, a fabric line the edge of a fold), and that is in each page’s “what has to be decided”.

Brushes

One layer of the library holds the geometry of a stroke: wash to fill flat, soft to blend along an isochrome with an airbrush, hatch for short hatching, strands for tapered strands, detail for Hertzmann’s multi-scale greedy detail (used only by the one-shot route, since what it draws are dots rather than strokes), and contour_lines. The planner decides every stroke on a 1x simulated canvas, and what goes to disk is in 1x coordinates.

brushes.KIND maps a stroke kind to a Krita preset and is the only such table (the jobs each used to have a private copy, and one of them lost a row – ch. 17, case 35): kind -> (preset, width factor, layer, taper). There are twelve: soft, hatch, strand, detail, line, flat, shade, sharp, shadow, deep, light and blend. A stroke’s kind decides its preset, which layer it lands on, and whether it tapers. The width factor cannot be dropped – with the same setBrushSize(90), Airbrush paints 84 px, Bristles-5 40 px and Pencil 1 Sketch 18 px. The brush has to be round. Tapering is decided by the kind, and no single stroke may override it. Before a plan goes to disk, brushes.check() verifies that every kind has a row; and in the job, a stroke that finds no layer raises.

Painting, the tree, saving, delivery

One layer group per group: Shape (vector) -> <G>_Flat -> <G>_Shade -> the named bands (subgroups like <G>_Shadow, each a vector Shape plus a clipped Paint layer) -> <G>_Blend -> <G>_Strand -> <G>_Line; which layers are built follows which keys that group has in the painting plan. All of it is painted stroke by stroke with paintPath at 4x. The background is the bottom layer (Background). This tree is not hard-coded in Python inside the job, it is a file – readable, diffable and countable without opening Krita (appendix F).

Saving and checkpoints. A saved .kra has to open again with its layers’ blend, opacity and clip intact (measured round trips in ch. 13). The finished version saves a checkpoint .kra after each top-level group (stages/) – which does nothing for memory (ch. 18), and exists to show a person the intermediate states. The last thing it does: the darkest pixel in the whole image has to be (0,0,0), a gate against mistakes of the “the darkest pixel came from the background” kind (ch. 17).

Delivering to the artist. In the finished .kra, clip, blend, opacity, label and vector layers all survive the round trip. To change a local colour, change the Shape’s fill; to repaint an area’s texture, clear _Shade and paint over it, with the clipping still in force; to move a part, move its whole group – the occluded part is painted whole (ch. 3); and a band (Skin_Shadow) is a vector shape plus a flat layer, so changing its shape means dragging control points. Things that can also be put in, all verified to survive a round trip: one SelectionMask per part, the source as a FileLayer lying on top for comparison, and a repeated part as a CloneLayer. Things that cannot: a palette (saving one silently writes nothing) and layer styles (ch. 16). Vectors take almost no space, and a .kra‘s size is all in the paint layers’ tiles.

Knobs

parameter (paint)defaultmeaning
t_soft9the threshold at which a soft stroke starts; at 0.7 times this, a group’s tens of thousands of strokes are copying noise
line_drop10a line not darker than its surroundings by this much, on its own layer’s image, is not drawn
checkpointstruesave a .kra after each top-level group
jobtruefalse produces only the plan and the 1x simulation

Each method’s own knobs are on its page.

Reviewing and overruling

The review images painting writes: the 1x composite (where you also check for black rims around the silhouette), the 1:1 close-ups, one image per group and one per layer (appendix F); plus the description count and the number of bare pixel writes from the painting journal. The per-group rulers (ch. 7): interior sharpness, at the hard edges, 1x PSNR, and the band edges. The 1x simulation can only point a direction; the numbers have to come from real painting, because the simulation and Krita sometimes move in opposite directions. Passing the numbers is not enough, a person has to look: the baseline passed all five criteria with a higher likeness than the old route, and a person looked and still called it copying (ch. 19).

Where it breaks

  • Deciding how to paint on the artist’s behalf: cel banding on every part is posterize plus outlines, with the eyes a black blob; the error-driven copy on every part is a blurry photocopy.
  • Removing the hard brush: PSNR falls by 2 dB.
  • Painting too much: p90 rises above the source’s.
  • Measuring the wrong thing: rulers whose masks are not mutually exclusive (the eyes mixed into the skin), band edges measured with a median, sharpness measured on the simulated canvas – the cases in ch. 17.
  • Going by the numbers alone: the five criteria govern whether it looks like a person painted it, and cannot govern whether it is painting at all.

The pitfalls specific to a part are on its page.

Summary

  • There is no universal method: one page per part, one module per part, and a person decides each layer’s in the parts table.
  • The copy-it baseline produces a picture but is not painting, and is kept only as a ruler.
  • An unstudied part is a placeholder first: the skeleton, how an artist paints it, and what has to be decided, with an empty module in the code.
  • Lines, brushes, the tree, saving and delivery are common to all parts and are on this page.

On origin

Each group’s readings, the number of strokes in the glow, why the baseline’s output was judged a failure, and the matching-position comparison against the one-shot route: ch. 19, “The layered route” and “The two routes side by side”.

Hair

🧪 in progress · rebuilt once on the layer tree’s foundation, the first part to be done that way · the method name and where a person writes it: appendix G · code: appendix B

How an artist paints it

Hair is painted by the lock, not by the strand. Once the local colour is laid: one highlight band per lock (along its direction, tapered at one end), a dark seam between neighbouring locks (also a long thin shape along the direction), and a hard-edged tier of shadow for the large dark areas (back hair against the neck, the fringe pressed onto the forehead). At the tips, a few individual strands, each drawn in one tapered pass with a fine brush. Across the crown, an “angel ring”, a highlight band running across several locks. How hard the edges are depends on the style: in cel shading the highlights and seams are hard-edged shapes, and in painterly work they are softened with an airbrush – but even soft, the direction is lock by lock rather than random. Lines: a lock’s outline is a line, and inside a lock there is hardly any.

What it looks like in the source

Hair from a diffusion model is soft: a strand is a faint highlight streak rather than a line, and both the highlight bands and the seams between locks are airbrush transitions rather than hard cel edges – the bands are not the point, the direction is. In luminance, a lock’s boundary is either a ridge (brighter than either side) or a seam (darker), and top-hat and bottom-hat filters find them.

Our method 🧪

One cylinder per lock, two gradients. That is the whole of this page; everything else is detail.

Two entirely different things happen on a cylinder: what colour it is (yellow at the root, pink at the tip), and how the light falls off from its core to either side. An artist changing the hair colour changes the first and adjusting the light changes the second. So they are written as two layers:

layerwhat it isblend
Lengtha gradient along the lock’s direction, in absolute colour, sampled from the lit colour (the 85th percentile)normal
Rounda gradient across the lock, in multiply, equal to 1 at the core and falling off toward the edgesmultiply

The along-axis gradient takes the lit colour rather than the median because multiply cannot brighten: with the median, the across-axis factor would exceed 1, and folding that brightening into the absolute colour instead runs into the 255 ceiling (blonde hair already has R = 250). Taking the lit colour keeps multiply naturally at or below 1, and neither gradient has to strain.

Why not “quantize it into a few bands”. A lock used to have one local colour plus a few hard-edged tiers of shadow. The source’s hair carries a hue shift from yellow at the root to pink at the tip, and quantizing flattened it entirely – only six tenths of the source’s clearly pink pixels survived into the output. With gradients it is back to nine tenths. Bands are also more expensive: each one adds another outline, and cutting the same lock into 4 bands measured three times the numbers with a slightly worse residual.

The other five steps:

  1. The flow field: harmonic diffusion in double angles. The lineart’s tangents are hard seeds and the structure tensor is a soft one, and it solves for a direction field defined everywhere. Direction is a double angle (theta and theta+pi are the same direction), so the diffusion runs on 2theta.
  2. Splitting into locks: bright ridges (a top hat with a large normalized blur, and no closing – closing sticks neighbouring ridges into one) are the seeds, an anisotropic Dijkstra along the flow partitions the area (cheap along the direction, expensive across it), and neighbouring locks with similar directions are merged. grain sets how fine the split is.
  3. The 天使の輪 (angel ring): draw the whole bright band first and then cut it along the seams – the jaggedness is cut into it rather than drawn. A blur mask softens its edge.
  4. The seams: take the boundary per pair of neighbouring locks (the whole seam network is closed, with no degree-one endpoints, so skeletonizing it as a whole walks no path), dilate with 8-connectivity (with 4-connectivity a 45-degree boundary leaves only isolated points), and walk the longest geodesic with two BFS passes (Zhang-Suen thinning destroys an even-width band at 45 degrees). Width and darkness scale with the contrast.
  5. Stray hairs and 毛先 (tips): evenly spaced streamlines, dark where it is bright and bright where it is dark, with a hard cap on how many – too many and it is ボサボサ (frizzy).

No strokes exist merely to push the error down. Each group used to carry another three or four hundred error-driven soft strokes. They were deleted: two gradients per lock measure close to the floor any smooth model can reach (blurring the source itself to that degree does barely better), and what is left over is the fine texture of the strands – work for a few dozen strokes, not a few thousand dots.

Hair must not be blurred – 「ぼかしてしまうと毛束の集合体としての質感が壊れる」 (blur it and the texture of a mass of bundles is destroyed). So softening appears in exactly two places: the edge of the angel ring, and the tips. Between one lock and the next it has to be hard, and that hardness is precisely why hair reads as bundles.

Layers

Hair_X/
  Shape                  vector silhouette + flat colour (the backstop: there is always a gap
                         between one lock's ring and the next)
  Locks/                 one group per lock
    Lock_07/
      Length             gradient along the axis (absolute colour)
      Round              gradient across it (multiply, clipped to Length)
  X_Halo/                the 天使の輪 (angel ring) + a blur mask
  X_Seam                 the seams between locks (a hard brush, thin)
  X_Strand               stray hairs / 毛先 (tips)
  X_Line                 the lines

The group has six or seven direct children, so the criterion of at most 10 nodes per group still passes; the dozens of locks are folded into a single Locks folder that an artist can expand into one entry per lock and recolour a single lock in.

How a person writes it in, and its knobs

"Hair_Front": dict(method="hair", bands=40, sigma=4.0, delta=6.0, elong=3.0, sharp=True),
Knobsdefaultmeaning
grainmidhow fine the split is: coarse / mid / fine. Finer is not better – each extra lock adds another outline and two more layers for only a small improvement in the residual
ns_len ns_rnd5 / 9how many stops the along-axis and across-axis gradients get. The across-axis one needs a few more to catch the darkness at a lock’s edge (too wide a window smooths the seam away)
seams seam_maxtrue / 60the seam lines, and their cap
tips tip_frac tip_mix tip_underfalsetips: one soft stroke over the last fraction of the arc length at the tip, its colour mixed toward the layer below (毛先の透け, see-through tips)
strands0how many stray hairs (a hard cap)
halo halo_q halo_blurtrue / 88 / 1.2the angel ring: what top percentage of brightness to take, and how soft its edge is

Front and back hair are the same hair – a person marks them as sharing one flat colour – but may be painted differently (the back has larger dark areas, the front finer locks).

Criteria

The per-group rulers (ch. 7): interior sharpness p90 not exceeding the source’s, the reading at the hard edges, and 1x PSNR; the description count of both hair groups; and hue – the fraction of clearly pink pixels has to come close to the source’s. That last one is a criterion this rebuild forced into existence: with all five criteria passing, four tenths of the pink could still be lost. For a person to look at, one review image per layer (appendix F): the locks, for whether they line up with real ones; the seams, for whether they are thin lines rather than fat worms; and the strands, for the ratio of bright to dark stray hairs (mostly bright).

Where it breaks

  • Locks cut too coarsely: one lock spans several real ones, and averaging across it turns the whole thing one colour – the yellow core the source has disappears. This is the main remaining fault.
  • Taking the median for the along-axis gradient: the multiply factor exceeds 1, folding it into the absolute colour hits the 255 ceiling, and the residual worsens by three tenths (measured 12.0 -> 15.3).
  • Clipping lock groups to each other: clipping inherits the alpha accumulated below inside the same group, so with locks clipped to one another the first lock has nothing beneath it and disappears outright.
  • Seams drawn as fat worms: setting the radius from the width of the boundary band drives every one of them to the cap, and at 4x a 22 px brown worm crawls across the fringe. Set it from the contrast.
  • Choosing a strand’s colour by coin flip: half of them come out dark. Too many strands: ボサボサ (frizzy), and p90 rises above the source’s.
  • A lock’s edge becoming a line: the raster lineart draws a faint highlight band as a line, and the finished painting has more lines inside the hair than the source does (ch. 2).

What has to be decided

How to cut the locks is still the biggest open question: bright ridges are not all found where fine locks are packed together, so should a person draw the dividing lines? And then: how many stray hairs and where they start; whether a lock’s outline belongs to the line layer or to this page; and whether front and back hair need two different grain settings.

On origin

Ch. 18, “The layered route”: both hair groups’ stroke counts, PSNR and interior sharpness; and the table of hair variants in “Three part experiments”.

Skin: face, neck, arms, hands, legs, feet

🧪 in progress · the one tier of shadow works and the output was judged a failure; one method currently paints every location · the method name and where a person writes it: appendix G · code: appendix B

How an artist paints it

Anime skin is one hard-edged tier of shadow: the local colour fills it, and the shadow is one shape in one colour (a little darker than the local colour and toward red) with hard edges, softened with an airbrush in only a few places (the cheek, the top of the thigh). There is almost no highlight, at most a touch on the nose or a shoulder. The shape of the shadow depends on where it is:

  • The face: the shadow the fringe presses onto the forehead (its shape following the fringe’s edge), a thin shadow down one side of the nose, a small one under the lower lip, and the largest of them on the neck; the blush is a soft patch of pink sitting above the shadow; and the eyes and the mouth take no part in the banding (they have their own page).
  • The neck: a large shadow cast by the chin, hard along its upper edge (the chin’s shape) and graduating downward toward the collarbone; plus two thin shadows on the collarbone.
  • The arm: a cylinder – one shadow running along the inner side, a hard shadow pressed by the cuff, and one in the crook of the elbow.
  • The hand: a thin shadow down one side of each finger, something deeper between them, and almost nothing on the back of the hand.
  • The thigh: a cylinder’s gradient (bright outside, dark inside), with the hard shadow the skirt presses onto it mattering most, and a touch of brightness on the knee; with the legs together, a deep seam between them.
  • The calf: as the thigh, with the shadow at the back, plus one pressed by the top of a stocking or a shoe.
  • The foot: a touch of brightness on the instep, a shadow down one side of the toes, and a hard shadow pressed by the shoe.

What it looks like in the source

The luminance histogram is bimodal: one peak for the local colour and one for the shadow, with the airbrush transition between them and a dark tail below made of the lines and the mouth. The shadow’s colour is not far from the local colour, so splitting by luminance is steadier than splitting by colour; and the blush is a patch where the colour changes and the luminance does not.

Our method 🧪

The method bands only the visible pixels (in the completed image the skin under the clothing was painted as something else, and banding on that cuts out a top); subtract the eye and mouth masks (exclude), without which the darkest band is a layer of pupils, mouth and nostrils; look only at luminance in [local colour - line_drop, local colour + 10] after a sigma=1 blur (the lines belong to the line layer); find the valley between the two peaks with Otsu, and everything darker than the valley is shadow; open and close by 2 px to remove the remains of the lines and the fuzz from gradients; discard pixels more than col_tol from the band’s colour (the blush) and leave them to the residual; and vectorize into a Shape -> the <G>_Shadow subgroup (a vector Shape plus a clipped flat fill, kind shadow). The residual soft strokes (kind blend) land on the _Blend layer above the band: where the source has a gradient they blend it back in, and where the source has a hard edge the residual is too small to start a stroke and the edge stays; the hard brush stays on _Shade, below the band.

A band wins on layering, not on sharpness: the baseline’s hard brush already copies a shadow edge hard enough. What a band is worth is that the “shadow” an artist asks for becomes a named layer – a vector shape with a flat fill inside it, rather than a patch inside tens of thousands of airbrush strokes. A band with no residual loses: the lines, the mouth, the blush and the gradients all disappear. K-means (k=2, in RGB) does not work: the dark tail drags it away and the “shadow” it cuts out is the blended pixels of the outlines.

Why the output was judged a failure: there is only one band and the whole skin group is banded together, so the face, the neck and the thigh all depend on one Otsu threshold; the band’s edges are softer than the source’s (the band-edge reading is below the source’s, ch. 19); the residual and the hard brush together still amount to copying, leaving the neck and the collarbone a soft blur; and the skin group’s layer tree has 14 nodes, over the limit of 10 per group.

Layers

Skin/: Shape -> _Flat -> _Shade (the hard brush) -> _Shadow/ (a vector Shape plus a flat Paint) -> _Blend (the residual soft strokes) -> _Line.

How a person writes it in, and its knobs

"Skin": dict(method="tiers", exclude=["mask_eyes.png", "mask_mouth.png"]),
Knobsdefaultmeaning
exclude[]the mask files taking no part in the banding, from segment’s ai/: the eyes and the mouth
line_drop60band only pixels whose luminance is at least the local colour minus this (anything darker is a line)
col_tol40discard pixels further than this from the band’s colour (the blush) and leave them to the residual
residualtrueadd the residual soft strokes and the hard brush above the band

Criteria

The per-group rulers (ch. 7): the band’s edge (the gradient p90 on the band Shape’s boundary, compared with the source at the same positions – this is where a band wins or loses), interior sharpness, and 1x PSNR. A person looks at the shadow layer on its own to see whether its shape is right (does the chin’s shadow look like a chin), and the four close-ups (face, neck, hand, foot) are looked at every time.

Where it breaks

  • Banding on the completed image: whatever the model painted under the clothing gets cut out as a band.
  • Not subtracting the eyes and the mouth: the darkest band is the pupils plus the mouth plus the nostrils.
  • One threshold for the whole body: the face’s shadow and the thigh’s differ in luminance, and one Otsu valley is wrong at both ends.
  • A band with no residual: the blush and the gradients disappear. A residual with no band: back to copying.
  • Too deep a tree: 14 nodes in one group, over the limit of 10 per group (ch. 7).

What has to be decided

  • Whether the choice should be made per sub-part (Skin_Face / Skin_Arm / Skin_Leg_L and so on, each with its own threshold and its own shadows), or one method per group with knobs for the location.
  • Whether each location’s shadow is separated out of the source (Otsu) or drawn by rule (the chin’s shadow being the chin’s shape translated downward).
  • Which layer the blush belongs to, and whether thin shadows like the nose’s and the collarbone’s belong to the lines or to a band.
  • How to get the tree back under 10 nodes per group (merge _Shade and _Blend? drop the band’s Paint layer?).

On origin

Ch. 18, “The layered route”: the skin group’s stroke count, the band’s pixel count and threshold, the band edges, and interior sharpness; plus the three skin variants in “Three part experiments”.

Eyes and mouth

🧪 in progress · the eye works and the output was judged a failure; the mouth is not painted at all · the method name and where a person writes it: appendix G · code: appendix B

How an artist paints it

An eye is a part to be drawn rather than shaded: one shape for the sclera, one for the iris (darker above and brighter below, either a gradient or two bands), one dark shape for the pupil, the upper lid with its lashes as the thickest and blackest line there is (often merging with the dark mass of the socket), a thin line for the lower lid or none at all, and one to three white highlights whose positions are fixed and are a signature of the style. Each is its own layer, every edge is hard, and the only gradient is inside the iris. The mouth: a short dark line when closed, or a small dark patch with a touch of red for the tongue when open, plus a small skin shadow under the lower lip, and no black line.

What it looks like in the source

The eye mask SAM gives supplies only the position and its edge is not accurate (it cuts off a corner of the iris, and a highlight lands right on its boundary); but what is inside the eye separates by colour: the iris is the most saturated patch, the dark mass is the darkest (the pupil, the dark rim, the upper lashes and the top of the socket merge into one block in the source and are not drawn as separate lines), the highlights are the brightest small points inside the core, and the sclera is what is bright and neutral outside the core. The lower lashes are a one- or two-pixel line at 1x, which the lineart cannot catch; and the mouth is a short dark patch that neither line source has.

Our method 🧪

The eye’s shape is not taken from SAM’s mask but from the source’s colours. Expand the eye mask by a few pixels; the median colour of pixels with saturation above sat_min is the iris colour, and anything within col_tol of it is iris; anything with luminance below dark_lum is the dark mass; the connected component containing the iris, hole-filled, is the core; the bright pixels (above hl_lum) that appear after closing the core by close px and lie in the middle third of it horizontally are highlights (those at either end are sclera), at most 2 per eye; and pixels within 4 px outside the core that are bright (above white_lum) and neutral (saturation below white_sat) are sclera – loosen that threshold and the skin above the socket is taken for sclera. The Eye group’s Shape is replaced by the core united with the sclera.

The tree: Eye_Shadow (the iris, a Shape plus a flat fill) -> Eye_Deep (the dark mass) -> Eye_Light (the highlights: a small Shape plus one dab of Basic-5, with no new stroke kind) -> Eye_Blend (the residual soft strokes, which avoid the highlights expanded by hl_margin px – the residual layer sits above the bands and copies the source once more after they are painted, and the few pixels of a highlight are too small to be copied back; too small an expansion fails too, since an airbrush at R=2 has a 9 px radius at 4x).

The four shapes without the residual are a cel eye: every edge is harder than the source’s, the gradient inside the iris is gone, and PSNR falls – cel banding is wrong for an eye, and the numbers prove it. The gradient needs the residual and the hard edges need the bands, and both are required. Why the output was judged a failure: the socket’s line followed SAM’s mask and came out as a thick brown ring; the lashes are not painted; and the mouth is not painted (it is currently just a mask subtracted during the skin’s banding).

Layers

Eye/: Shape (the eye area) -> _Flat (the sclera’s colour) -> _Shadow/ (the iris) -> _Deep/ (the dark mass) -> _Light/ (the highlights) -> _Blend (the residual) -> _Line.

How a person writes it in, and its knobs

"Eye": dict(method="eye", dark_lum=70.0, residual=True),
Knobsdefaultmeaning
grow6how many px to expand the eye mask by before looking
sat_min100pixels above this saturation define the iris colour
col_tol70how close to the iris colour still counts as iris
dark_lum70below this luminance it is the dark mass
hl_lum white_lum white_sat185 / 200 / 15the thresholds for the highlights and the sclera
hl_margin5how many px to expand the highlights by for the residual to avoid
residualtrueadd the residual soft strokes above the bands (the iris’s gradient)

Criteria

The number of highlights equals the source’s (counted by a person); each of the four shapes is clean when its layer is rendered alone; the eye has to be measured mutually exclusively in the per-group rulers (mixing it into the skin is measuring the wrong thing, ch. 17); and the crop-face-1to1.png close-up is looked at every time.

Where it breaks

  • Using SAM’s mask as the eye’s shape: it cuts off a corner of the iris and draws the socket as a ring of line.
  • The residual copying a highlight away: a shape too small to copy back has to be avoided by the residual.
  • Loosening the sclera threshold: the skin above the socket is taken for sclera.
  • The four cel shapes with no residual: the iris’s gradient disappears.

What has to be decided

  • Whether the upper lid and lashes line belongs to this page (as one shape) or to the line layer, and whether to draw the lower lashes at all.
  • Whether the iris is one band or two (darker above, brighter below), and whether the pupil is its own layer or part of the dark mass.
  • The socket’s line must not follow the mask: use the eye area’s edge, or draw none.
  • The mouth – a dark patch plus a short line – belongs either to the face on the skin page or to this one.

On origin

The two eye variants in ch. 19’s “Three part experiments” (with and without the residual, and the number of highlights), and the Eye group’s stroke count in “The layered route”.

Stockings

⬜ placeholder · a skeleton only: ask for this method and it reports “not decided yet” · the worked example’s picture has none · the method name and where a person writes it: appendix G

How an artist paints it

The stocking’s colour is laid over the leg’s local colour, and skin showing through is done with opacity rather than by mixing colours: the stocking layer is semi-transparent and the skin beneath it, shadows and all from the skin page, comes through. From the top of the thigh to the knee there is a cylinder’s gradient (where the stocking is stretched it is thin and more transparent), a brighter edge down one side of the outline (the fabric folds over at the edge and catches the light), and a brighter reflection on the knee; the top of the stocking is a hard edge, and the skin above it takes a band of shadow where it is squeezed. Black and white stockings work in opposite directions: on black, the bright areas are where the skin shows through; on white, the dark ones are. Patterns (a mesh, stripes) are hard-edged and deform with the leg’s surface.

What it looks like in the source

A diffusion model paints the skin showing through as a gradient along the leg, with both the bright edge and the knee’s reflection present, and the top of the stocking is usually a clear hard edge. In segmentation it tends to stick to the skin (the same leg, close in colour), and a word may not be able to pull it out on its own.

Our method ⬜

No code. Choosing method="stocking" passes the parts table and reports “the method for stockings is not decided yet” when painting starts.

Layers (as envisaged)

Stocking/: Shape -> _Flat (the stocking’s colour at an opacity below 1, letting the skin group through) -> _Shadow/ (the shadow at the top of the stocking) -> _Light/ (the bright edge and the knee) -> _Line. It depends on the group’s opacity, and would be the first part whose method uses a layer’s opacity at all.

How a person writes it in

"Stocking": dict(method="stocking"),      # not decided yet; painting reports it

Criteria

The per-group rulers; whether the gradient of skin showing through runs the right way (for a person to look at); and whether the hard edge at the top belongs to the lines.

What has to be decided

  • Whether segmentation gives it a group of its own or makes it a sub-part of the skin plus a mask, and whether a SAM word can pull it out.
  • Whether the skin-through gradient is banded, or is one airbrush layer with a gradient of opacity.
  • Whether the hard edge at the top belongs to the lines or to a Shape, and whether the squeezed skin’s shadow belongs to the skin page or to this one.
  • How a pattern follows the surface.

On origin

The worked example’s picture has no stockings.

Clothing: fabric, folds, frills, ribbons, patterns

📐 designed · a skeleton only: ask for this method and it reports “not decided yet” · in the worked example the dress and the ribbons both ran the baseline, and a person judged it a failure · the method name and where a person writes it: appendix G

How an artist paints it

The material decides how hard the edges are first: cotton and gauze have soft-edged shadows and no highlight; satin and leather have hard-edged shadows and one bright band. Then:

  • Fabric: large areas of local colour with one band of shadow (pressed by the body: the armpit, the waist, the inside of a skirt), whose shape follows the body.
  • Folds: hard-edged grey shadows one after another along the direction of the tension, each tapered at one end where the tension starts, and each optionally with a thin bright line beside it. In the lineart a fold is a few lines, and the shadow lies against one side of them.
  • Frills: layer upon layer, with a shadow under each one cast by the layer above, hard-edged and wavy along its border, bright at the crests and dark in the troughs.
  • Ribbons: length by length, with a shadow and a highlight at each turn, the darkest point at the knot, and one band of shadow on each loop of a bow.
  • Patterns (checks, stripes, prints): hard-edged blocks of colour laid above the local colour and below the shadow, deforming with the fabric’s surface; the shadow sits over the pattern and never the other way round.

What it looks like in the source

Folds are hard-edged grey shadows one after another, and frills are layers each with a shadow beneath – all of them shapes with clear outlines. The baseline copies a fold as a soft grey band and leans on the lineart for the structure; the dress is usually the group furthest from the source on the “at the hard edges” ruler; and a check pattern is hard-edged, which a soft brush cannot copy.

Our method 📐

No code. Designed but not built: the skin’s one-band shadow method ought to apply to folds directly (one row of method="shadow_tier" on a group would try it; what stopped it is that the material and the frills have to be settled first), and a ribbon’s checks would be cut by colour into one vector Shape per square and filled. Choosing method="cloth" passes the parts table and reports “the method for clothing is not decided yet” when painting starts.

Layers (as envisaged)

Dress/: Shape -> _Flat -> _Pattern (the pattern, as hard-edged Shapes) -> _Shadow/ (the large shadows) -> _Fold (the folds, one hard-edged stroke each) -> _Frill (the shadow under each frill layer) -> _Line.

How a person writes it in

"Dress":  dict(method="cloth"),     # not decided yet; painting reports it. Trying method="shadow_tier" first is fine too
"Ribbon": dict(method="cloth"),

Where the material is written – a new field in parts.py, or a knob on the method itself – is one of the things to be decided.

Criteria

Of the per-group rulers, at the hard edges (the output’s gradient at the positions of the source’s top 5% of gradient) matters most for fabric; the number of frill layers is counted by a person; and the pattern’s edges are looked at with that layer rendered alone.

Where it breaks

  • Copying a fold with a soft brush: a grey band, with the structure resting entirely on the lines.
  • One band for the whole dress: a fold’s shadow and a shadow pressed by the body differ in luminance.
  • A pattern taken for shadow: Otsu cuts the dark squares of a check into the shadow band.

What has to be decided

  • Where the material comes from (written by a person), and which brush a soft-edged material uses.
  • Whether a fold’s shadow is banded (as the skin does it) or one stroke per fold along the lineart’s fold lines.
  • Whether frills are treated layer by layer or as a whole, and how the layers are cut (does segmentation give each one).
  • Whether a pattern is cut into blocks of colour and filled, or treated as part of the local colour.

On origin

Ch. 18, “The layered route”: the stroke counts and readings of the Dress and Ribbon groups under the baseline, and the gap on “at the hard edges”.

Shoes

📐 designed · a skeleton only: ask for this method and it reports “not decided yet” · in the worked example the shoes run the baseline · the method name and where a person writes it: appendix G

How an artist paints it

One band of shadow on the upper (the instep’s curve: bright at the toe and dark toward the ankle) plus a dark sole (the darkest thing there, hard-edged); a leather shoe has a hard-edged highlight (a bright band at the highest point of the instep) and a cloth one does not; the laces are lines, and there is a shadow at the opening where the ankle presses. A shoe is small and has few things in it: one band of shadow, one sole, one highlight, a few lines.

What it looks like in the source

The laces are lines, the sole one dark band, and the upper a soft spread of light and shade; at 1x a shoe is only tens of pixels wide and the laces’ lineart often breaks.

Our method 📐

No code, and nothing has been studied on its own. Choosing method="shoe" passes the parts table and reports “the method for shoes is not decided yet” when painting starts.

Layers (as envisaged)

Shoe/: Shape -> _Flat -> _Shadow/ (the upper’s shadow and the sole) -> _Light/ (a leather shoe’s highlight) -> _Line.

How a person writes it in

"Shoe": dict(method="shoe"),       # not decided yet; painting reports it

Criteria

The per-group rulers, and a person looking at the crop-shoe-1to1.png close-up.

What has to be decided

  • Whether the upper’s shadow is banded (one band, as the skin does it).
  • Whether the highlight is a light band or a single stroke.
  • Whether the dark sole is a Shape or one thick line.
  • The laces: what to do when the lineart cannot catch them.

On origin

Ch. 18, “The layered route”: the Shoe group’s stroke count and readings under the baseline.

Metal and jewellery

⬜ placeholder · a skeleton only: ask for this method and it reports “not decided yet” · the worked example’s picture has none · the method name and where a person writes it: appendix G

How an artist paints it

Metal works by contrast: a hard-edged highlight and a hard-edged dark reflection pressed right against each other, with the local colour only in between; on a curved surface the highlight is a band and on a flat one a patch; and there is a brighter reflection around the edge. A gem is several hard-edged light and dark patches (its facets) plus one highlight point. Jewellery is small and often just a few strokes: one bright band, one dark one, one point.

What it looks like in the source

A diffusion model paints metal with strong contrast and hard edges, distinguishable from fabric; but a small piece of jewellery is only a few pixels at 1x, and segmentation tends to merge it into the part beside it.

Our method ⬜

No code. Choosing method="metal" passes the parts table and reports “the method for metal and jewellery is not decided yet” when painting starts.

Layers (as envisaged)

Metal/: Shape -> _Flat -> _Deep/ (the dark reflection) -> _Light/ (the highlight band and the highlight point) -> _Line.

How a person writes it in

"Brooch": dict(method="metal"),     # not decided yet; painting reports it

Criteria

The per-group rulers, and whether the highlight and the dark reflection really are pressed against each other (for a person to look at).

What has to be decided

  • Whether these hard edges belong to a Shape or to strokes.
  • Whether a piece of jewellery only a few pixels across is banded at all, or treated whole as one light point.
  • Whether a reflection’s colour is sampled from its surroundings or fixed.
  • Segmentation: whether jewellery needs a group of its own, and which SAM word.

On origin

The worked example’s picture has no metal or jewellery.

Buildings

⬜ placeholder · a skeleton only: ask for this method and it reports “not decided yet” · the worked example’s picture has none · the method name and where a person writes it: appendix G

How an artist paints it

Perspective is settled first and the straight lines are ruled; each face gets one local colour with one band each for the lit and unlit sides (the line where two faces meet is a hard edge); windows and doors are hard-edged dark blocks with a bright line on the frame; and distant buildings are washed out, given less detail and softer edges (aerial perspective). A building has far more lines than a figure, and all of them are straight lines or regular curves.

What it looks like in the source

A diffusion model’s perspective is often inconsistent (the lines do not meet at a point) and its windows are irregularly spaced, while the light and shade on each face is clear. Lineart extractors handle long straight lines well and dense short ones, like window bars, poorly.

Our method ⬜

No code. Choosing method="building" passes the parts table and reports “the method for buildings is not decided yet” when painting starts.

Layers (as envisaged)

One group per building: Shape -> _Flat (one fill per face) -> _Shadow/ (the unlit faces) -> _Window (the dark blocks) -> _Line (the straight lines). Distant buildings are merged into one group.

How a person writes it in

"House": dict(method="building"),   # not decided yet; painting reports it

Criteria

Whether the straight lines are straight (flips per 100 px on the line ruler should be near 0), and how hard the edges between faces are.

What has to be decided

  • How the straight lines are extracted (how well a lineart source handles them, and whether a “straight line” candidate source is needed).
  • How the faces are divided (does segmentation give planes or whole buildings, and does a person have to supply the vanishing points).
  • Whether the distance wash belongs to the background page or to this one.
  • Whether a window is a part segmentation produces or a dark block inside the method.

On origin

The worked example’s picture has no buildings.

Trees

⬜ placeholder · a skeleton only: ask for this method and it reports “not decided yet” · the worked example’s picture has none · the method name and where a person writes it: appendix G

How an artist paints it

Leaves are painted by the clump, not the leaf: one local colour per clump, a bright patch on top and a shadow beneath (two bands, hard-edged or broken), and a few individual leaves at its border (a few tapered shapes); between clumps the darker interior shows. A branch gets one band of shadow plus one bright line, hard-edged; and a distant tree is a silhouette in one colour.

What it looks like in the source

A diffusion model’s tree is clumps of green with light and shade and broken leaf borders; segmentation gives the whole tree, so the clumps have to be cut out separately (connected components of luminance).

Our method ⬜

No code. Choosing method="tree" passes the parts table and reports “the method for trees is not decided yet” when painting starts.

Layers (as envisaged)

Tree/: Shape -> _Flat -> _Shadow/ (the shadow beneath each clump) -> _Light/ (the bright patch on top of each) -> _Leaf (the broken leaves at the border, a few strokes) -> _Trunk (the branches) -> _Line (of which there is hardly any).

How a person writes it in

"Tree": dict(method="tree"),        # not decided yet; painting reports it

Criteria

The per-group rulers; the number of clumps, counted by a person; and whether the borders are broken.

What has to be decided

  • How the clumps are cut (whether connected components of luminance suffice, and whether segmentation gives clumps).
  • Whether a clump’s border is a hard-edged Shape or broken strokes.
  • How distance is washed out.
  • Whether the trunk and the leaves are one group or two.

On origin

The worked example’s picture has no trees.

Sky and clouds

⬜ placeholder · a skeleton only: ask for this method and it reports “not decided yet” · the worked example’s picture has none · the method name and where a person writes it: appendix G

How an artist paints it

The sky is one large gradient laid in a single pass with a big brush (darker above and lighter below, or brighter toward the sun); a cloud gets a soft airbrushed shadow beneath and a hard-edged bright band on top where the light falls, with a broken border and a few places the light comes through; and distant clouds are washed out and flattened.

What it looks like in the source

The gradient is clean and the clouds’ shapes are clear though their borders are broken. As for the background glow: this worked example’s background is pure glow with no sky – in a picture that has a sky, the sky replaces the glow as the bottom layer.

Our method ⬜

No code. Choosing method="sky" passes the parts table and reports “the method for sky and clouds is not decided yet” when painting starts.

Layers (as envisaged)

Sky/: _Gradient (the single-pass gradient, with no Shape) -> Cloud/ (Shape -> _Flat -> _Shadow/ -> _Light/). The sky is the bottom layer and replaces the glow.

How a person writes it in

"Sky": dict(method="sky"),          # not decided yet; painting reports it

Criteria

Whether the gradient is smooth (the 8-level colour count), and how hard the clouds’ bright edges are.

What has to be decided

  • How the gradient’s direction and end colours are decided (fitted from the source).
  • Whether clouds belong to the background group or one of their own, and whether segmentation gives them.
  • Which brush the soft shadow and the hard highlight each use.
  • Which of this and the background page’s glow replaces the other.

On origin

The worked example’s picture has no sky.

Water

⬜ placeholder · a skeleton only: ask for this method and it reports “not decided yet” · the worked example’s picture has none · the method name and where a person writes it: appendix G

How an artist paints it

Water is a darkened reflection plus ripples: first the reflection of the sky or the bank (its colour darkened by one band, stretched vertically, its edges blurred), then a few horizontal hard-edged bright bands (the lit side of the ripples, wider nearer and denser further away), plus a shadow at the bank; shallow transparent water shows what is under it through opacity. Spray is a white hard-edged shape.

What it looks like in the source

A diffusion model sometimes paints a reflection and sometimes does not; the bright bands are clear and horizontal; and like the sky it is a large gradient.

Our method ⬜

No code. Choosing method="water" passes the parts table and reports “the method for water is not decided yet” when painting starts.

Layers (as envisaged)

Water/: Shape -> _Flat (the reflection’s base gradient) -> _Reflection (the reflection, at an opacity below 1) -> _Light/ (the bright bands) -> _Line (of which there is hardly any).

How a person writes it in

"Water": dict(method="water"),      # not decided yet; painting reports it

Criteria

The bright bands’ direction (which should be horizontal), and the 8-level colour count.

What has to be decided

  • Where the reflection comes from: copy it when the source has one and draw none when it does not, rather than inventing one by flipping what is above.
  • Whether the bright bands are a light band or strokes.
  • How transparency is expressed (opacity, or mixing colours).

On origin

The worked example’s picture has no water.

Background

✅ verified · a person never chooses this one: the background is not a group, and both routes lay it · code: appendix B

How an artist paints it

Lay the whole background first and paint the figure over it – the background is never cut to the figure’s shape. A flat or blurred-out background takes a few passes of the largest soft brush; a background with sky, buildings or trees is each its own part (their own pages).

Note the measure in “a few passes”. No artist makes ten thousand marks for something with no edges and no detail – there was never that much to say about it. What this page is really about is how to write “a few passes” as numbers.

What it looks like in the source

The worked example’s background is a ring of light around the subject, darkening outward, with no pixels at all where the subject covers it.

Our method ✅

A low-resolution colour field, not twelve thousand strokes.

First make the reference: cut the subject out to black and extrapolate back into it from the pixels outside (a normalized convolution at sigma = 24). Without that extrapolation the area is black, and the moment the silhouette and a part’s outline differ even slightly, a black rim shows. Then reduce the reference to one thirty-second of the source’s width (FIELD_DIV) and save it as a small PNG; when painting, it is scaled back to full size with bicubic interpolation, fills the canvas and goes on the bottom layer – the subject’s area is covered by the part groups above, so the subject need not be cut out and no unfilled gap is left around the silhouette.

The resolution was measured rather than guessed:

The methoddescription numbersthe background area against the source
three flat passes plus one soft pass (the old way)on the order of twelve thousand strokesthe baseline
a field at 1/641/111 of the old way’slevel with the old way
a field at 1/32 (what it does now)1/28 of the old way’sbetter than the old way
a field at 1/86/10 of the old way’sa little better still

(The exact readings are in ch. 19.) 1/32 is the point at which it is both closer than the brush version and explicable; going finer costs ten times the numbers for a little more, which is re-encoding the source in dots (ch. 7).

It writes pixels, and it says why. BG_Base filling black is three numbers describing the whole canvas; BG_Field is a small image, and the criterion accounts for how many pixels that image itself has, not how much it covers once stretched. Both pixel writes carry a why in the painting journal. The old criterion “at most 2 pixel writes” is precisely what this page overturned: it forced something blurred out to be laid with a brush (ch. 7).

Layers

01_BG/: BG_Base (filling black) -> BG_Field (the colour field). Below every part group.

Knobs

FIELD_DIV (32 by default): what fraction of the source’s width the field is. The extrapolation at sigma = 24 is fixed in the method itself. A background of sky, buildings or trees is a different part (its own page) rather than a different knob.

Criteria

No black rim around the silhouette in composite-1x.png; zero bare pixel writes in the painting journal; a darkest pixel of (0,0,0) across the image; and the background’s description count should be among the smallest in the picture.

Where it breaks

  • Not extrapolating the background: the slightest difference between the silhouette and an outline shows a black rim.
  • Too fine a field: it saves no numbers and starts copying the source. Too coarse: the gradation in the ring of light around the subject disappears.
  • Laying a gradient with a brush: error-driven soft strokes alone leave black patches (once the error falls below the threshold no stroke starts, and the ground shows between strokes); adding flat passes covers them, but that is spending twelve thousand strokes on something one blur does.

On origin

Ch. 18, “The layered route”: the field’s resolution, its description count, and the readings over the background.

Criteria: the numbers, the review images, honesty

✅ verified · the last step of both routes · measuring one finished picture on its own, without a step: appendix A · code: appendix B

What this step does

Three kinds of acceptance, and all three have to pass. The numbers: measure everything measurable, with the thresholds anchored to the source. The review images: what numbers cannot measure (whether a layer is whole, why a line is there) goes in front of a person. Honesty: how many numbers it took to say what this painting is, with the journal to prove it.

Inputs and outputs

From the paint or trace step: the finished composite, the painting journal, the manifest, the Krita log and the layer tree (for the description count). From paint as well: each group’s mask, the painting plan and the lines. From segment: the sub-part ids, so that “inside the hair” can be measured separately. It writes every reading to one file, the table into the README, and the matching-position comparisons into their own directory. The file names are in appendix F.

The numbers

Every whole-picture measure is taken after normalizing to 2508 px wide (the same output reads differently at different widths, and comparing across resolutions gives conclusions pointing the wrong way). The thresholds are anchored to the source, not to a reference painting: a real painting’s edges are far sharper than a diffusion image’s, and demanding that a study be sharper than what it studies is a gate nothing can ever pass.

Criteriathresholdwhat it measures
description numbers over source numbersat most 5% (the target is 2%; above 20% it is judged to be re-encoding the source in dots)how many numbers it took to say what this painting is
bare pixel writes= 0writing pixels is allowed, but every write has to say why
gradient p99at least 85% of the source’show sharp the outlines are
8-level quantized colour countat least 55% of the source’sthe value structure
the 8 px stair fingerprintbelow the source’swhether there is an upscaling trace (the periodic signal a 1x pixel grid leaves when enlarged)
low-frequency likenessat least 30 dB, with both sides reduced to 256 wide firstit should resemble the source at the low-frequency scale only
interior sharpnessanchored to the source, crossing it in neither directionthe gradient p90 more than 30 px from the outline – p99 is propped up by the outlines and cannot see a blurry interior; the hair’s interior is listed separately
the darkest pixel(0,0,0)mistakes of the “the darkest pixel came from the background” kind
the restack differenceabout 0 inside the subjectstacking the group layers back in occlusion order and comparing pixel by pixel with the source (ch. 3)
linesflips per 100 px at most 3; one stroke per edge; a fitting deviation of at most 2 px@1x; and lines in the invented region listed separatelywhether a line is one stroke; reported per line layer, with each layer’s lines recording which layer they came from
per-group interior sharpness and at the hard edgesanchored to the sourcethe gradient p90 at least min(30, req/3) px inside the group’s edge; at the hard edges is the output’s gradient p50 over the pixels in the source’s top 5% of gradient (on a large flat group, p90 measures the flat area and the shadow edge drowns in it)
per-group 1x PSNR0.6 dB between two paintings is noisethe masked PSNR inside the group after reducing the output to 1x (likeness cannot be measured per group: at 256 wide a small group is only tens of pixels)
per-group 8-level colour countanchored to the sourcehow many colours this group has after 8-level quantization. A method’s faults only show when the whole picture’s colour count is split by group: quantize something carrying a hue shift into a few flat colours and the whole picture’s count barely moves (the other groups hold it up) while this group collapses – measured, the source’s hair had 123, one flat colour per lock 11, and one cylinder per lock 65, while the same outputs’ gradient, stair, likeness and sharpness all read normally
the band edgesanchored to the sourcethe gradient p90 over a 2 px band either side of a band’s Shape ring – this measures “is the shadow edge hard” directly
Layersat most 10 nodes per group, and at most 5 + 10 x the number of groups overallwhether the tree is the one a person wants (ch. 5): every node is a Shape, a Flat, a Shade, a named band, a Blend, a Strand or a Line, counted from the layer tree without opening Krita. The one-shot route’s tree fails this ruler by design – it answers a different question (ch. 8)

The per-group rulers use the masks the paint step wrote: they are mutually exclusive, and a group cut out of a mask (the eyes) is subtracted from the group it sits in (the skin) – without that, the skin’s “at the hard edges” measures nothing but eye sockets and pupils.

Noise: painting the same plan twice differs by 0.6 dB, 3 points of colour count, 0.2 of p99 and 0.2 of p90 – Krita’s brush engine is stochastic, and a difference smaller than that is noise and carries no conclusion. Never compare across multipliers: the same code reads differently at 8x and at 4x, and two columns of a table have to be at the same multiplier.

Knobs

what it points atmeaning
a paint or trace stepwhich step to measure
another paint step in the same subprojectfor comparison: the band-edge table gains a column
another subproject’s finished picturethe matching-position comparisons are produced: the source, it, and this one

The constant names are at the top of this stage’s main.py and are listed in appendix A.

review images

The review images are part of what is delivered – another model’s output has to be reviewed too.

imagewhat to look at
the segmentation reviewwhat segmentation changed (three columns: the hand-written table, the vote, and what moved)
the completion reviewper group: the occluded area / the completed image / the layer alone / its outline; plus the restack difference
the lineart review, on the source and then one per layerwhy each line is there and why each dropped one is not; and each layer’s own lines, the pale ones being in the invented region
the 1x composite, and the 1:1 close-upsthe whole picture, and the close-ups centred where the parts table says – at least the face, the neck, a hand and a foot
one image per layer, one per groupeach layer alone on grey, and each group rendered alone with nothing over it – “is the kettle whole”
the comparison cropsthe matching-position comparison against the other route

The file names are all in appendix F.

Where to overrule: for lines, overrides.json (shared by ch. 2 and ch. 4); for segmentation, the tables and words in parts.py, or redoing it once with the lineart as walls; for completion, which of the model’s layers that group uses; for a method, the choice a person wrote for that group (appendix G). After changing one, rerun only that step under a new number, and the ones downstream of it.

The images are to be looked at, not merely produced. Every mistake that pointed the wrong way – judging by the source of a line, ribbons mixed into hair, completion removing a top, cel banding, the neck given to the hair – was caught on a review image, and some of them sat there through several versions with nobody looking.

Honesty

The criterion asks: how many numbers did it take to say what this painting is?

The old threshold was “at most 2 pixel writes”. It once caught real cheating – an output with every criterion green whose journal was nothing but set_pixels and not one stroke, which had scaled the 1x pixels up in blocks and pushed them into the layers. What deserved to survive of that gate was its spirit, not its letter. It confused two things into one: “do not copy the source” and “use nothing but brushes”. Gradients, colour fields, erasing and filters all go through set_pixels and hit the gate on first use; so a background had to be laid with twelve thousand strokes over something blurred out.

The new way of counting: recursively count how many numbers this layer tree holds, divided by the source’s pixel count times three.

saying one thingcosts how many numbers
filling the whole canvas with one colour3
a linear gradient covering 8Kabout 19, even though it setPixelData-ed 25 million pixels
one vector fillcontrol points x 2 + 3
one strokepoints x 2 + 5
a 32x48 colour field4,608
pasting the 1x source in1024 x 1536 x 3 = 4,718,592 – straight through the gate

A raster node counts how many numbers the stored image has (size x 3), not how many pixels it covers once stretched. That one rule does two things at once: pasting the source blows the gate immediately, while “describing a blurred-out background with a small colour field” is legitimate – because it really did write down only that many numbers.

Writing pixels is not the sin; being unable to say why is. So every set_pixels carries a why, and one without is a bare write, of which the threshold is zero.

With the criterion changed, several things that could not be done before follow naturally: a shadow really can be “a multiply layer at 65%” rather than the composited RGB filled flat; “draw the whole highlight band and then cut it” can be expressed with a transparency mask; a background can be a colour field; and a band’s edge can be softened with one blur mask instead of tens of thousands of soft strokes.

The numbers have to come from output produced by this code. Numbers from a run before the commit do not count; a failed job has to fail at its entry point; and the criteria check that the file being measured is the one this run wrote (ch. 17, case 35).

Every number has to answer “over what area, measured how”: gauge prints the normalized width and the close-up windows; a probe’s docstring states its criterion, how to read it and what it is anchored to; and anything unmeasured is marked 📐.

Where it breaks

The whole of ch. 17. Three iron rules: settle whether you are measuring one layer or the composite before you start; comparing across resolutions is not a comparison at all; and the thresholds are anchored to the source.

On origin

The full criteria table for both routes: ch. 19, “The two routes side by side”.

One-shot (trace)

✅ verified · segment -> trace, with the criteria in the same step · needs only ComfyUI’s SAM, in the segment step · the one and only step of the one-shot route · code: appendix B

in one line

Solve the vector edges and the value bands out of the source, use strokes only for the difference, and paint it in one go. No draft, no gate, no completion, no lines; vectors own the edges and strokes own the interiors. It answers the question “can a diffusion image become a 4K painting where every pixel has a provenance” – it can, and it looks right; it does not answer “can an artist take it over”.

The two steps and what each produces

input/origin.png ─► segment   (ch. 1)  ASSIGN cuts out the sub-parts; the SAM majority vote is unused
              ─► trace     reading the picture (plain python, independent of the scale):
                             KMeans cuts each sub-part into 2-5 bands by colour (below), one
                               sub-pixel alpha ring per band (ch. 1)
                             stacked bottom to top into a 1x "bands only" picture (preview_cel.png);
                               the joint label (sub-part, band) is a cell
                             strokes are planned from that picture, covering only its difference
                               from the source: the background - two airbrush passes per cell to
                               soften the steps - Hertzmann's greedy detail strokes, capped at
                               max_aspect - optionally hatching on HATCH_GROUPS' sub-parts and
                               strands on STRAND_GROUPS, both off by default
                             -> plan.json + strokes.jsonl (1x coordinates) + preview.png (1x sim)
                           painting (headless Krita): build the tree -> paintPath / paintLine stroke
                             by stroke -> final.kra / final.png / layers.json / review/
                           the criteria (ch. 7) -> gauge.json + the README's table
              (the criteria run at the end of trace's main.py; a separate gauge step whose PAINT
               points at it works too) the node rule fails by design

Value bands

This route has no “local colour plus painting”: each sub-part is cut by colour into 2 to 5 bands with k-means (read/tiers.levels, with k chosen by pick_k; the band with the largest area is the Flat, not the brightest – a white part’s own colour is the brightest, so “the brightest is the Light” is wrong on a white part), one vector shape per band and one group per band. The edges are analytic and the interiors hard, which is why the one-shot route’s interior sharpness is high (ch. 7); the cost is that the bands are named by luminance and the layers are not what a person wants.

What the layer tree looks like

01_BG/   BG_Base (filled black, three numbers)   BG_Glow (the background's strokes)
04_Color/
  Subject_Silhouette                     the vector silhouette, at the bottom of the group
  <sub-part>/                            inheritAlpha -> the silhouette; in ORDER, bottom to top
    <sub-part>_<band>/                   one group per band, clipped to the alpha accumulated below
      Shape                              vector: this band's outline, filled with this band's
                                         colour -- this is what owns the edge
      Soft                               paint layer, clip -> Shape: airbrush softening the steps
      Marks                              paint layer, clip -> Shape: hatching / detail / strands
06_Line/  Line (empty: this route draws no lines)
09_Post/  Post_Grade

A stroke has to be clipped to the band’s shape rather than the sub-part’s outline: clipped only to the part, one stroke can run across an eye and the skin and wipe out the band structure the vector layers had just built (the gradient p99 falls to a fraction of what the vector stage had, and the eye becomes a round blob). The cost is several hundred nodes: sub-parts x bands x 4, plus one per sub-part, plus 10 of skeleton. The bands are named by luminance as Flat / Shadow1… / Light1…, and no layer is called “eye” – the two darkest bands are usually the two eyes, while the mouth, the shadow behind an ear and the cheeks are mixed into the middle one. The difference from ch. 5’s tree: here it is one group per sub-part with one subgroup per value band; there it is one group per semantic group, holding named layers.

Knobs

parameterdefaultmeaning
scalethe project’s multiplier (4)8 is more than twice as slow and more than twice the memory
expand smooth eps0.6 / 0.8 / 0.30each band shape’s expansion, sigma and RDP tolerance
detail_t34the detail brush’s threshold; larger means fewer strokes
max_aspect3the longest a detail stroke may be, in multiples of its own width; None lets it grow to 14 radii
strands hatchfalsehair strands on the parts table’s strand groups, hatching on its hatch groups (appendix G)
jobtruefalse reads the picture only, producing the plan and the 1x simulation

No long thin marks by default. Three passes drew them: the hair strands, the skin hatching, and the tail of the detail brush, whose strokes grow along the flow field for up to fourteen radii – a streak a dozen times its own width. On a source with no visible brushwork they comb the picture, and they are the first thing a person notices. All three are off by default; a picture that wants them turns them back on. What it costs is sharpness inside a part, and the hair pays most: the strands were the only high-frequency content it had, so without them it reads well under its own source. The gradient, the colour count and the stair fingerprint stay where they were. The measured account is in the version history, appendix E.

What a person decides in the parts table for this route: which regions make up each sub-part and in what occlusion order (ch. 1), which sub-parts are hatched, which grow strands, and where the close-ups are centred (appendix G). expand 0.6, smooth 0.8 and eps 0.30 are each band shape’s expansion, sigma and RDP tolerance (ch. 5’s Shape expansion is 1.2, because too small a value shows a sliver of black ground between the outline and the fill; this route has no lines, so it can be smaller).

limitations

When to use it, what it costs and the route it belongs to, step by step, are in ch. 9; the other route is ch. 10. Its own inherent limits: the layers are not what a person wants (the bands are cut by luminance and have no names, and the node rule fails by design); there is no completion (hide the dress and the legs are cut in two); the strokes are dots rather than lines (the detail brush scatters short dots, and there is no lineart); the hand-written table cannot separate two things inside one region (the majority-vote version can, but this route does not use it); and the hand-written table of regions has to be redone for every new picture.

On origin

Ch. 18, “The one-shot route”: the numbers, the tree, the close-ups and the evidence images.

The one-shot route

✅ verified · segment -> trace, two steps · a route is not code, it is the order the steps run in (each step’s main.py names at the top which steps it reads) · one picture, one route, one subproject · the two stages themselves are ch. 1 and ch. 8

in one line

Solve the vector edges and the value bands out of the source, use strokes only for the difference, and paint it in one go. No draft, no gate, no completion, no lines. It answers the question “can a diffusion image become a 4K painting where every pixel has a provenance” – it can, and it looks right; it does not answer “can an artist take it over”.

The chain

the one-shot route

  the picture ──► step01-segment   the hand-written table cuts the subject into named
                     (ch. 1)         sub-parts and gives each one a sub-pixel outline;
        │                            the majority vote runs, but nothing downstream reads it
        ▼
                 step02-trace      reading: each sub-part is cut into bands by value, one
                     (ch. 8)         sub-pixel outline per band, and strokes are planned to
        │                            cover only what the bands miss
        │                          painting: the layer tree is built, then painted stroke by
        ▼                            stroke, and the criteria are measured in the same step
                 the finished .kra

Two steps, and a person’s only real work is at the first one.

The phases

phasestepsthe milestone
phase1-layers/step01-segmentthe subject is cut into named sub-parts
phase3-paint/step02-tracethe finished painting, and the criteria on it

There is no phase2-draft/ at all on this route. Phase 2 is the draft, and the draft exists to be a gate; this route has no gate, so the directory is never created. The numbering is global to the subproject and does not restart per phase, which is why the second step is step02 and not phase3’s first.

Phase 1: the layers

step01-segment

The question it answers: which pixel belongs to which named part.

upstreamthe picture, and this picture’s parts table
producesthe subject cut into named sub-parts, one sub-pixel outline per sub-part, and a numbered map of the raw regions
a person doesfills the region numbers into the parts table – see below
look atthe three-column review image (the hand-written table, the majority vote, and what the vote changed) and the region map
the gateeach sub-part is the thing it is named after, all the way round its edge
if it failsfix the table and rerun the same step
costsneeds ComfyUI, for one mask per word; well under a minute
the stage itselfch. 1 · the field names: appendix G · the file names: appendix F

This route uses only the hand-written half of the step. The majority vote still runs and still shows up in the review image, but nothing downstream reads it: the parts the tracing works from come straight from the table a person wrote. That is why the table has to be right here and cannot be repaired later – there is no completion and no draft to catch it.

Filling in the table: the first run does not count

The first run of a new picture stops as soon as it has written the region map, because the table is still empty. Read the region numbers off that map into the parts table – which regions make up which sub-part, ordered bottom to top by occlusion – and run the same step again under the same number. The stopped run is not a step and consumes no number.

The phase verdict

The phase’s README.md says in one line what the phase produces, and a person writes its ## Verdict when the phase closes. Here that verdict is short: this route’s phase 1 is the segmentation and nothing else.

Phase 3: the painting

step02-trace

The question it answers: can this picture be painted at 4x with every pixel having a provenance.

upstreamthe segmentation step
produceseach sub-part cut into value bands, one sub-pixel outline per band, a stroke plan for what the bands miss, the layer tree, and then what Krita paints from it: the .kra, the composite, the painting journal and the review images
a person doesnothing, until it is time to look
look atthe 1:1 close-ups – at least the face, the neck, a hand and a foot
the gateno pixel is written bare: every write carries its reason
if it failsthere is nothing upstream to fix but the table, so a failure here sends you back to step01
costsreading the picture runs at 1x and takes about two minutes; Krita takes about seven
the stage itselfch. 8 · the file names: appendix F

The knobs that matter are three: the multiplier (4x by default – 1x planning is the same either way, only the painting scales), how much detail earns a stroke (raise it and fewer, larger differences get painted), and how long a mark may be (max_aspect, 3 by default; the hair strands and the skin hatching are off by default for the same reason, ch. 8). All of them are constants at the top of this step’s main.py.

Measuring: the criteria

The criteria run inside this step by default, so this chain really is two steps long and not three. A separate stepNN-gauge is worth opening only when you want something the trace step cannot do on its own: the same rulers pointed at another painting, or the matching-position comparison against the other route’s finished picture. That comparison only exists once both routes have been run.

One criterion fails here by design: the description count. The claim of this route is “value bands plus strokes for the difference”, and hundreds of band groups is what that costs. The criteria count it and report it; they do not gate it. What is gated is that no pixel was written bare.

The phase verdict

phase3-paint/’s verdict covers the painting and the criteria together, because on this route they are one step.

Redoing a step

A step that a person judges a failure is moved aside – renamed with a legacy-wrong suffix, kept in its own phase as a record – and the rerun uses the same number. Failures consume no numbers, so the living chain stays consecutively numbered. Grep the subproject for the old name and point the references at the new one; each downstream main.py names the steps it reads at the top, and changing that constant is what changes the upstream.

A step kept as a record of an experiment rather than a failure gets a legacy-experiment suffix instead.

When to take this route

Take it when you want a picture that resembles the source and can be enlarged, and you have no interest in the layers; when you want speed; or when you want a yardstick – this route is where the ceiling on interior sharpness and likeness sits, and the other route is measured against it. Do not take it when the deliverable is a file an artist will open and edit.

What it costs

Reading the picture runs at 1x and does not depend on the multiplier, on the order of two minutes; the Krita half spends a minute or two building the tree (several hundred nodes, each vector layer needing addShapesFromSvg) and a few minutes painting (tens of thousands of strokes, a hundred thousand calls). Memory is its weak point: three layers per band group, several hundred paint layers each filled by an airbrush, plus every group’s projection – at 8x that reaches a dozen gigabytes and more (ch. 18).

limitations

The layers are not what a person wants; with no completion, hiding the dress leaves the legs cut in two; the strokes are dots rather than lines; and the hand-written table cannot separate two things that share a region.

On origin

The numbers, the tree, the close-ups and the matching-position comparison against the other route: ch. 19.

The layered route

🧪 in progress (every completion and every per-layer lineart has to be reviewed) · segment -> lineart -> segment -> complete -> lines -> draft -> paint -> gauge, eight steps across three phases · a route is not code, it is the order the steps run in · one picture, one route, one subproject

in one line

Follow the artist’s stages. Lineart and parts come first, a person decides the layers by looking at them, diffusion completes each layer’s whole shape, each layer then gets its own lines, and a draft is laid out for a person to review; painting only starts once that passes. The layers are made for a person.

The chain

the layered route

  the picture ──► step01-segment    boundary barriers + the majority vote + growing from a
                     (ch. 1)          seed -> the subject cut into named sub-parts
        │
        ▼        step02-lineart     the lines on the source: two sources into one candidate
                     (ch. 2)          pool, scored against the source, trimmed, fitted
        │
        ▼        step03-segment     the kept lines become walls and the regions are cut
                     (ch. 1)          again; the table is inherited, not refilled
        │
   ═══ a person decides the layers: look at the region map and the lineart, then write the
       groups into the parts table ═══
        │
        ▼        step04-complete    the layering model gives each layer its whole shape; only
                     (ch. 3)          the hidden part is taken, the visible part is pinned to
        │                             the source, and stacking back is the acceptance test
        ▼        step05-lines       lineart extracted from each layer alone and arbitrated
                     (ch. 4)          against that layer's own image; lines that fall in the
        │                             invented area are accounted for as invented
        ▼        step06-draft       each layer's shape + its flat colour + its own lines ->
                     (ch. 5)          the layer tree -> Krita paints the draft
        │
   ═══ the gate: are the layers right - is each layer a whole object - are the lines right -
       does the invented part look right ═══
        │
        ▼        step07-paint       each group is painted by the method a person chose for it,
                     (ch. 6)          the lines take their weight from their own layer's image
        │
        ▼        step08-gauge       the criteria, a ruler per group, a ruler on each layer's
                     (ch. 7)          lines, and the comparison against the other route

On the numbering. The chain is numbered 01 to 08 with the lines-as-walls pass included. Skip that pass – it is optional – and every number after it drops by one; that shorter numbering is what the step templates assume in their defaults, so check the upstream constants at the top of each main.py rather than trusting the number in a heading.

The phases

phasestepsthe milestone
phase1-layers/segment · lineart · segment · complete · linesevery layer’s whole shape and its own lines
phase2-draft/draftthe draft, which is the gate
phase3-paint/paint · gaugethe finished painting and the criteria

A redone step stays in its own phase, and each phase’s README records that phase’s verdict.

Phase 1: the layers

step01-segment

The question it answers: which pixel belongs to which named part.

upstreamthe picture, and this picture’s parts table
producesthe subject cut into named sub-parts, one sub-pixel outline per sub-part, a numbered map of the raw regions, and one mask per word
a person doesfills the region numbers into the parts table; the first run stops after writing the region map and does not count as a step
look atthe three-column review image (the hand-written table, the majority vote, and what the vote changed)
the gateeach sub-part is the thing it is named after
if it failsfix the table’s regions, words or classes, and rerun the same step
costsneeds ComfyUI; well under a minute
the stage itselfch. 1 · the field names: appendix G

Unlike the one-shot route, this route uses the majority vote: the hand-written table is the prior, and the vote is what separates two things that share one region.

step02-lineart

The question it answers: where are the lines in this picture, and why is each one there.

upstreamthe segmentation
producesone stroke per kept line, each carrying which source proposed it, plus the review image that shows what was kept and what was dropped
a person doesoverrules individual lines when the arbitration got one wrong
look atthe review image on top of the source: blue for a kept AI line, brown for a kept contour, red dashes for dropped
the gatea line is there because the source has an edge there
if it failswrite the overruling into overrides.json and rerun this step
costsneeds ComfyUI, once per extractor; about half a minute
the stage itselfch. 2

These lines are not the finished lines. On this route the step has exactly two jobs: it supplies the walls for the next segmentation, and it is something to compare the per-layer lineart against. Every line that ends up in the painting is re-extracted later, per layer, by step05. That is why this step is rarely accepted on its own – it is accepted by what it enables.

step03-segment, with the lines as walls

The question it answers: does a real drawn edge fall inside one of my regions.

upstreamthe first segmentation, whose table it inherits; the lineart, whose kept lines become walls
producesthe same kinds of things as step01, from regions that the lines have cut apart
a person doesnothing new – the table is inherited, not refilled
look atthe same review image, plus close-ups wherever you expected the wall to change something
the gatethe wall moved what it should and left the rest alone
if it failsfix the lines upstream, not this step
coststhe same as step01
the stage itselfch. 1

This is not a redo, and it keeps its own number. step01 is not wrong; the second pass answers a question that could not be asked before the lines existed. Both steps stay in the chain and both stay numbered. From here on, “the segmentation” means this one.

Read the counts carefully. More hair pixels inside the neck close-up after the walls went in is not a regression – it is one real lock of hair finally being whole. Look at the close-up before you believe the count (ch. 17).

A person decides the layers

This is the first of the two human bars in the chain, and no amount of machinery replaces it. Look at the region map and at the lineart together, and write into the parts table which sub-parts make up each group – one group is one layer in the finished file, and the order they are written in is the occlusion order, which is the order they will stack in Krita. A layer that has to be split out of another one by an AI mask is declared here too.

Everything downstream is shaped by this decision: what gets completed, what gets its own lines, what gets painted by which method. Getting it wrong is cheap to fix now and expensive to fix after the draft.

step04-complete

The question it answers: what does the hidden part of each layer look like.

upstreamthe segmentation with the walls; the layering model’s output, which can be reused from an earlier run rather than regenerated
producesone whole layer per group, and an account of exactly which pixels were invented
a person doeschooses which of the model’s layers each group may draw its whole shape from
look atthe completion review image – per group: the occluded area, the completed shape, the layer alone, the outline – and the restack image: source, restacked, difference
the gatenothing shows through: there is no pixel that no group claims
if it failschange which of the model’s layers that group uses, and rerun this step
costsrunning the model fresh takes about six minutes and a dozen gigabytes of VRAM, so free the VRAM first by stopping ComfyUI
the stage itselfch. 3 · the field names: appendix G

Only the hidden part of the model’s answer is taken; everything visible is pinned to the source’s own pixels, because the model repaints what it can see and loses fingers, hair strands and lace when it does.

The gate used to be “the restack equals the source pixel for pixel”. It is not any more, and the reason matters: once each part’s method is allowed to redraw its own outline, the restack cannot equal the source. How far it differs is an account, not a gate. What is still absolute is that nothing shows through.

What is hidden is invented. The account of which pixels those are travels downstream with the layer, and everything built on top of them inherits the label.

step05-lines

The question it answers: what are this layer’s own lines, including the ones only it can see.

upstreamthe segmentation with the walls; the completed layers and their invented-pixel account; the lines on the source, for comparison only
producesone stroke per kept line, each tagged with the layer it belongs to and whether it was extracted from invented pixels
a person doesoverrules individual lines, the same way as in step02
look atone review image per layer, in the same three colours, plus the combined image where the kept lines are coloured by layer and the pale ones are the invented ones
the gatea line inside a layer is there because that layer’s own image has an edge there
if it failsoverrule it and rerun this step
costsone extraction per layer; one to two minutes
the stage itselfch. 4

Each layer is put alone on a flat ground and its lines are pulled from that picture, not from the source – which is the only way to get the lines that the layers above were covering up. A line extracted from pixels that were invented is invented too, and is marked as such; short invented lines with no support anywhere in the source are dropped by default.

The phase verdict

Phase 1 closes when the per-layer lines are through. Its README records, in order, what passed and what was moved aside – including the steps that were retired, because a retired step is part of how the surviving one got there.

Phase 2: the draft – the gate

step06-draft

The question it answers: are these layers something a person would accept.

upstreamthe segmentation with the walls; the completed layers; the per-layer lines
produceseach layer’s shape, a flat fill in its own local colour, its own lines, the layer tree, and the file Krita paints from it, with review images per layer and per group
a person doeslooks – this is the gate
look ateach layer on its own; each group on its own; the 1:1 close-ups; hide the upper layers to see the lines underneath
the gatesee below
if it failsback to whichever of the four upstream steps is actually at fault
costsone to two minutes, Krita included
the stage itselfch. 5

The gate

Four questions, and they are asked by looking, not by counting:

  1. Are the layers right? Is each group the thing a person would call by that name?
  2. Is each layer a whole object? Hide everything above it – is the kettle complete, or does it stop where something used to cover it?
  3. Are the lines right? Is each one there for a reason you can see in that layer’s own image?
  4. Does the invented part look right? It is invented; the question is whether it is plausible, not whether it is correct.

Where the answer is no, the fix is upstream – the groups, the completion’s choice of model layers, or an overruled line – and then the affected steps are rerun in order. Nothing goes on to painting until this passes. The verdict goes at the end of this step’s README.md under ## Verdict, which a rerun preserves, and into the gate record in the subproject’s README.

The images are to be looked at, not merely produced. Every time the baseline changes, go through the 1:1 close-ups of at least the face, the neck, a hand and a foot. Counting alone will lie to you (ch. 17).

Phase 3: the painting

step07-paint

The question it answers: what does this picture look like painted, part by part.

upstreamthe draft; the segmentation with the walls; the completed layers
produceseach group painted by the method a person chose for it, the lines given their weight from their own layer’s image, the background field, the layer tree, and what Krita paints from it
a person doeschooses each layer’s method in the parts table, before the step runs
look atthe same review images as the draft, plus the description count of the tree
the gateno pixel is written bare, and the tree stays within its node budget
if it failschange the method for the group that failed – not the ones that passed
costsabout ten minutes, seven of them Krita
the stage itselfch. 6, one page per body part · the field names: appendix G

There is no general way to paint. A person picks, for each layer, which method paints it; parts nobody has studied yet fall back to a baseline that copies the source’s light and shade. That baseline produces a picture, and it is not painting – it is kept as a ruler to beat, and a run that leans on it everywhere has been judged a failure before (ch. 19).

step08-gauge

The question it answers: how far is this from the source, and from the other route.

upstreamthe painting step, and the segmentation for the masks
producesthe criteria on the finished picture, a ruler per group, a ruler on each layer’s lines, and – when pointed at another subproject’s finished picture – matching-position comparisons
a person doesdecides what the numbers mean; the numbers do not decide
look atthe comparison crops, side by side at matching positions
the gatethe criteria of ch. 7, on a picture normalized to a fixed width and anchored to the source
if it failsthe fix is in the painting, not in the ruler
costsseconds
the stage itselfch. 7

This is also where the two routes finally meet: point this step at the other subproject’s finished picture and it produces the same crops from both, at the same coordinates. That comparison does not exist until both routes have been run on the same picture.

The phase verdict

A painting step that a person judges a failure is moved aside, and so is the gauge step that measured it – not because the rulers were wrong, but because their subject was. The readings are kept as the baseline a new method has to beat.

Redoing a step, and the second pass that is not a redo

A failed step is renamed with a legacy-wrong suffix and kept in its own phase; the rerun uses the same number. An experiment that was never part of the living chain gets legacy-experiment instead. Grep the subproject for the old name and fix the references; each downstream main.py names at the top which steps it reads.

Two things that catch people out:

  • A legacy directory can still be an upstream. The completion step’s expensive part is running the layering model; a retired experiment that already ran it is exactly where to read that output from, instead of spending six minutes and a dozen gigabytes of VRAM again. Retired does not mean dead.
  • A second pass is not always a redo. The second segmentation is not a rerun of the first – it answers a question the first could not – so both keep their numbers. Only a step that was wrong gives its number back.

When to take this route

Take it when the deliverable is a file an artist opens and edits: a layer per thing, each layer whole, each line accounted for, every shadow a layer that can be adjusted. It costs you a person’s attention at two bars – deciding the layers, and the draft – and it costs some likeness. Do not take it when all you want is a picture that resembles the source.

What it costs

The first run needs ComfyUI for the lineart and one mask per word; completion runs the layering model once (a few minutes, a dozen gigabytes of VRAM); per-layer lineart runs one extraction per layer; the finished painting has more than twice as many strokes as one-shot (the shapes are larger after completion), and the Krita half is on the order of ten minutes. Memory is not a problem: a dozen semantic layers, each layer’s strokes landing only inside its own area, a few gigabytes at most.

limitations

Its likeness is slightly below one-shot’s and its interiors are softer. One-shot piles dots onto the picture without regard to layers, so of course it resembles the source; this route paints each group on its own, and the boundaries between groups and the lineart are all repainted, so the difference is structural. Losing a vector shape per value band is what layering costs.

The way forward is a method of its own for each part (ch. 6), chosen by a person, not a return to the one-shot tree. What is hidden is invented (ch. 3), and a line extracted from invented pixels is invented too (ch. 4); both keep an account of exactly which pixels those are. A different picture means rewriting the parts table: the words, the groups, the prompts, the methods and the close-up coordinates all depend on the picture. And the layers are decided by a person while the lineart is only a wall and a reference – making the lines take part in the layering mechanically has not been done yet, and the lines-as-walls pass is the first step towards it.

On origin

The numbers, the tree, the close-ups and the matching-position comparison against the other route: ch. 19.

The environment and the harness

✅ verified · installing, self-checking and the common reasons it will not start are in “Installing and self-checking”; this chapter is the mechanism and the reference.

Why it has to be Krita 6, and why it has to be the RPM

Node.paintLine and paintPath are new in 6.0 and are the foundation of all of this. 5.x leaves injected tablet events, every one of which has to be debugged on its own (coordinate calibration unknowable, strokes landing only on the first layer, saveAs segfaulting, throughput a few hundred strokes a minute). Flatpak’s sandboxed Python has no numpy, and it moves the paths to ~/.var/app/org.kde.krita/.

The harness: a separate HOME

The harness script (appendix A) creates krita-bot/. It is idempotent and copies no library code:

krita-bot/
  run_job.sh                                   the launcher (below)
  .config/kritarc                              undoStackLimit=3, EnableAutosave=false, AutoSaveInterval=0, [python] enable_autopaint=true
  .local/share/krita/pykrita/autopaint/        the extension: createActions does QTimer.singleShot(3000, run) -- it has to wait for the
                                               main window and the resource system, because paintLine takes its brush preset from the
                                               window; run() reads $KRITA_JOB, execs it, prints JOB OK / JOB FAIL, then os._exit(0)

os._exit(0) rather than exiting normally: a normal exit goes through Krita’s shutdown and sometimes hangs. Do not add other memory-related keys to kritarc; measured, they break painting outright. And undoStackLimit does nothing for memory, since what grows is the tiles (ch. 18).

run_job.sh:

export HOME=<harness>  XDG_RUNTIME_DIR=/tmp/xdg-kritabot  LANG=C.UTF-8  PYTHONIOENCODING=utf-8
export DTP_SRC="${DTP_SRC:-<pkg>/core/src:<pkg>/craft/src:...}"   # one src per package, colon-separated
export PYTHONPATH=$DTP_SRC${PYTHONPATH:+:$PYTHONPATH}      # what a job imports is the library's own source
cd "$HOME"; rm -f *autosave*.kra           # a leftover autosave pops up the recovery dialog and hangs every later run
xvfb-run -a -s "-screen 0 1600x1200x24" krita --nosplash     # --gui runs it on a real desktop (tools/gui.py)

harness.run_job(module, args, ...): writes the job’s arguments as JSON into DTP_JOB, strips PYTHONHOME, PYTHONPATH, PYTHONSAFEPATH, VIRTUAL_ENV, UV_PROJECT_ENVIRONMENT and .venv/bin before starting Krita, and raises JobFailed on seeing JOB FAIL – a failed job has to fail its caller, or the criteria run on the previous round’s output (ch. 17, case 35).

A job’s skeleton

A job is imported only inside Krita, and the shell the autopaint extension execs which reads DTP_JOB, does import dtp.jobs.<module> and calls job(**args).

from dtp.krita.session import *            # only at module level (a job is exec'd and has no __file__)

def job(plan_dir, out_dir, scale=4, name="x"):
    s = Session(W, H, name, journal=True, out_dir=out_dir)   # document, view and output directory
    ...                                                       # build the tree (from ops/layers.py's building blocks) and paint
    s.stage("label")                                           # stages/NN-label.png
    s.final()                                                 # final.png + final.kra + layers.json

A job is passed its output directory explicitly; a standalone probe script uses DTP_OUT (the Krita process boundary). Apart from the machine-level settings and that boundary, the library reads no environment variables.

Self-checking

The self-check script (appendix A) runs 31 checks inside Krita, each of which really runs and reads the pixels back (they are listed in “Installing and self-checking”). After changing the session wrapper, all of them have to pass.

ComfyUI: the foundation for the other models

Two jobs, one model each:

jobmodelnodefirst runhow far to trust it
lineart extractionManga2Anime(comfyui_controlnet_aux)Manga2Anime_LineArt_Preprocessora dozen seconds or sodiscriminative, and steady
text-prompted segmentationGroundingDINO SwinT + SAM ViT-B(comfyui_segment_anything)GroundingDinoSAMSegmentabout 2 s per worddepends on the word

The driver (appendix B) is a thin HTTP client: POST /prompt plus polling /history; LoadImage accepts only a filename under ComfyUI/input/, so the file is copied there first; It produces one lineart image per extractor and one mask per word (appendix F). Of the three linearts, manga is the cleanest (anime is soft with uneven line widths, and std, being a gradient method, catches bright edges too and is noisy). The completion model (See-through) does not go through ComfyUI and runs as its own process (ch. 3).

Environment: ComfyUI itself is not modified, only custom_nodes and models are added; its location is comfy_dir in dtp.toml; and the ComfyUI script (appendix A) starts it on 127.0.0.1:8188, logging to work/comfy.log. The two patches for transformers 5 are in “Installing and self-checking”.

Pitfalls: kill the service by port with fuser -k 8188/tcp and Krita with pkill -x krita – pkill -f "<a pattern that matches your own command line>" kills the shell that issued the command. A working directory cd-ed into in one command carries over to later calls, so a background chain uses absolute paths throughout. And a process left by an earlier session queues work into the same queue, which is cleared with POST /queue {"clear": true} and /interrupt.

Painting

✅ verified · all of it measured on Krita 6.0.2.1 with PyQt6 and 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"

It uses the current brush preset and the current foreground colour (view.setCurrentBrushPreset, setForeGroundColor, setBrushSize, setPaintingOpacity).

  • It paints on the node you pass it, and the active node is irrelevant. After setActiveNode(L2), n1.paintLine(...) changes 5438 px of n1 and nothing at all of L2.
  • Pressure really does drive the brush engine: a pressure curve tapered at both ends gives a width profile of 14, 29, 29, 25 px. The joins between segments are invisible, so tapering can be done segment by segment with paintLine.
  • paintLine takes QPoint (integers) and paintPath takes QPointF (floats). Session.line() uses several paintPath calls to get both floating-point precision and a varying width.
  • fillStyle defaults to "None"; pass "ForegroundColor" to fill. The fill is binary and not antialiased (a boundary-band deviation of 0.498).
  • A group’s paintAbility() is "UNPAINTABLE".

What the session wrapper gives you

methodwhat it does
stroke(pts, pressures, node)paintLine segment by segment, with pressure interpolated linearly
path(pts, node, smooth=True)one paintPath, smoothed with quadTo
line(pts, width, node, segments=3)three paintPath calls, changing the brush size for each. More segments is not better (5 segments read aliased 2.6%, 12 segments 14.5%)
dab(x, y, pressure, node)one dab
brush(color, size, opacity, flow, preset)the fast path with no event loop, skipping values that have not changed
set_preset(name, size, opacity, flow)matched by substring, printing a warning when not found; runs the event loop
set_color(r, g, b)runs the event loop

Choosing between stroke() and path() depends on the geometry: on long smooth curves path() is 23 to 47% cleaner; on short segments it is the other way round (stroke() is best at K90 0.032 and path() worst at 0.0437), and with few points quadTo changes the shape.

Throughput

8192x12288, a blank single-layer document, measured by the throughput probe (appendix C):

operationThroughput
paintLine(~300px)450 to 850 strokes a second, regardless of size
paintPath (8 points)310 to 390 strokes a second
changing colour with no event loop, plus paintLine605 strokes a second
changing colour with an event loop round, plus paintLine71 strokes a second

On a real layer tree of several hundred clipped nodes: path() runs at 297 strokes a second and stroke() segment by segment at 130. About 233 KB of undo stack per call that cannot be cleared – the budget is memory (ch. 18). (That figure was itself a measurement of the wrong thing; see ch. 17, case 26.)

Filling an area with a smooth edge at 8K

Four approaches on the same task (the fill probe, judged by the absolute difference between coverage and the analytic ground truth inside the boundary band, where perfect is about 0.02 to 0.08 and binary about 0.5):

approachboundary-band deviationtime
a vector layer with addShapesFromSvg0.08281.5s
select_mask with an antialiased mask, plus 42 strokes0.33056.4s
select_mask with a binary mask, plus 42 strokes0.36681.4s
paintPolygon(fill)0.49810.8s
unconstrained, painted over with a big brush0.4361—

Only a vector layer comes close to analytic. select_mask cannot keep fractional coverage, and paintPolygon fills binarily.

Erasing

“Erasing with a brush” cannot be done: view.setEraserMode(True), setCurrentBlendingMode("erase") and node.setBlendingMode("erase") all do nothing for paintLine (0 px erased). The only thing that works is clearing alpha (s.erase(box=, mask=)).

This was judged wrongly once: a measurement showed erase removing 2198 px. That was an artefact of reading stale pixels – reading once it had settled, all three read a flat 0.

Asynchronous readings

Image operations are asynchronous. waitForDone() plus refreshProjection() plus a round of the event loop does not guarantee that pixelData() has been updated. Judging whether something took effect means reading in a loop until two consecutive readings agree (s.settle() / s.stable()). Concluding from a single read can give the opposite answer.

Layers

✅ verified

Where the layer tree comes from

Nobody writes this Python by hand. A tree is a file (appendix F), and the painting job builds it from that. There used to be one job per route each building its own tree (seven hundred-odd lines, three nearly identical copies of the build-and-paint code), so a change had to be made in three places, and what the tree looked like could not be seen without opening Krita.

Node kinds, and the API in this chapter each maps to:

IR kindwhat it becomeswhich section
groupcreateGroupLayer;passthrough → setPassThroughModeBuilding, and the two group pitfalls
fillcreateVectorLayer plus addShapesFromSvg; a full-canvas solid colour whose shape is null uses a paint layer plus ground insteadVector layers
strokespaintPath for a whole stroke, or paintLine segment by segment when it tapersch. 12
gradientcompute the RGBA yourself and setPixelDatach. 15
maskcreateTransparencyMask plus setSelection, attached to the parent grouptransparency mask
filtercreateFilterMask(name, f, selection), attached to the parent group likewise, and at most one per layerch. 14
rastersetPixelDataProperties and reading
clip: truesetInheritAlpha(True)clip(inherit alpha)
opacity(0..1)setOpacity(int(v * 255))Properties

Two things only measurement can tell you: a full-canvas solid fill has to be a paint layer – built as a vector layer, setPixelData never reaches its projection, the output is five dB worse and nothing raises at all; and stacking more than one FilterMask on the same layer is O(n^2) (appendix C), so wrap another group around it instead.

The declarative Session.build(spec) is still there and is what the probes and the self-check use; production trees all go through bake.

Building

n = doc.createNode(name, "paintlayer"); parent.addChildNode(n, above)
g = doc.createGroupLayer(name)              # this one is required; createNode(..., "grouplayer") gives you no setPassThroughMode
v = doc.createVectorLayer(name)
doc.createCloneLayer / createFilterLayer / createFillLayer / createTransformMask / createSelectionMask / createColorizeMask
  • addChildNode(child, None) puts it at the top of that parent’s stack; above=X inserts it above X; and childNodes() returns them bottom to top
  • these layers can be paintLined directly, with no need to write pixels and diff them
  • four levels of nesting measure fine, but a group composites separately and each level stores another set of tiles, so do not go beyond three or four
  • reparenting (removeChildNode plus addChildNode) works

Session.build(spec) builds a tree declaratively from leaf_spec() and group_spec(), with list order running bottom to top; afterwards it reads childNodes() back and compares against the spec, raising on a duplicate name. Addressing is by s.find("04_Color/Skin/Skin_Shadow"), s.find("Skin_Shadow"), by_role or by_label.

Properties

setBlendingMode, setOpacity, setColorLabel, setVisible, setLocked, setAlphaLocked and setInheritAlpha all work (the last two measured: with alpha locked, another stroke on an empty area changes no pixel count; inherit alpha is under “clipping” below).

setBlendingMode validates nothing at all: pass "xyzzy" and it reads back unchanged while compositing as normal. A matching read-back proves nothing about the mode being real. Measured on base (128,64,192) plus top (100,200,80), these 39 were confirmed to change the composite:

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 and hard overlay have a space in them. linear_light, hard_mix, difference, lighter color, copy, dissolve and alphadarken could not be confirmed (they composite the same as normal).

Clipping (inherit alpha): three measured rules

Clipping is called inherit alpha in Krita (setInheritAlpha(True)). It is the pivot of ch. 5’s tree: the flat colour is clipped to the vector Shape and the parts group to the silhouette. Three probes back this (appendix C).

One: it clips to the accumulated alpha of the layers below it in the same group. Inside a group, a red base block at x in [100,200) and a green bar above it at x in [20,380): off, it spans (20,379); on, it spans (100,199). Add a blue block to the base and it becomes (100,339) – the accumulation, not the layer immediately below. Make the group passthrough and the clipping stops working. Hence the flat colour has to be at the bottom of its group, and the group must not be passthrough.

Two: a vector layer can be the base, and a group can be clipped too. A paint layer clipped to a vector layer (probed at 8K, with 39 strokes deliberately painted past the edge): the boundary-band deviation is exactly what the vector layer alone gives (0.0828) and nothing spills over – inherit alpha accepts a vector layer as its base and passes the vector’s fractional alpha straight through to the paint layer above. For comparison, clipping to a binary raster layer reads 0.3791, 4.6 times worse, and the whole difference is whether the group’s base is a curve or a pixel grid. setInheritAlpha on a group itself: a parts group painting past its edge covers 10,270,050 px, and clipped to the silhouette 7,147,891, exactly what the silhouette alone covers – it clips the whole group’s composited alpha, clipping alpha without changing content.

Three: the criterion has to look at alpha too. The mechanism is disableAlphaChannel(): compositing writes only RGB and not A, so RGB has been written even where alpha is 0. Looking at RGB alone concludes “not clipped”. Building the base and top under the root node (where a fully opaque Background makes the accumulated alpha 255 everywhere) records it as “clipping does not work” – the first case in ch. 17.

The two group pitfalls

Passthrough breaks clipping (rule one above).

When it is not passthrough, a multiply inside the group does not multiply against what is below the group. Put a multiply vignette in 09_Post and the pure black background is raised to (6,6,6). A group composites its siblings together first and then lays the whole thing down – so the vignette is multiplying against a transparent sibling and gives a patch of grey. A single-layer probe cannot find this (multiply over pure black on its own gives (0,0,0)). It has to be measured inside the real group structure. The fix is passthrough=True on that group, which has no clipped layer in it.

Reading

methodwhat it reads
node.pixelData(x,y,w,h)that layer’s own pixels, in BGRA
node.projectionPixelData(x,y,w,h)that node composited with its children
doc.projection / s.composite()the whole document’s composite
  • a group’s pixelData is an empty array (cannot reshape array of size 0); use projectionPixelData
  • a vector layer has no readable pixelData; look at the composite
  • on a large canvas with many layers, always pass a box: one 2000x3000 layer is 24 MB, and a hundred of them several gigabytes

Vector layers

vl = doc.createVectorLayer("v"); parent.addChildNode(vl, None)
vl.addShapesFromSvg('<svg xmlns="http://www.w3.org/2000/svg"><path d="…" fill="#…"/></svg>')

The vector probe passes 5 of 5: it reaches the composite (its span matches the SVG exactly); bezier C and Q render; coordinates are 1:1 pixels, with (7800,11800) on 8192x12288 offset by (+0,+0); the .kra round trip is faithful; and a single shape spanning 7700 px is fine.

vl.toSvg() exports back out. node.save() segfaults on a vector layer and kills the process outright – and it is not needed, since vectors reach the composite anyway.

It can still be edited afterwards: shapes(), setZIndex, setTransformation, createGroupShape and setAntialiased all work. But the geometry is in points, not pixels (at 300 DPI, 1 px = 0.24 pt), so asking shapeAtPosition in pixel coordinates returns None. See ch. 15.

15 vector layers with 5000 control points take about 12 s; run the event loop a few rounds after addShapesFromSvg.

Node-level transforms

move, rotateNode, scaleNode, shearNode and cropNode all work inside a group, and the clipping relationships follow along. The non-destructive version is a TransformMask – read its factory toXML() skeleton and edit that, because a hand-written transform_params is rejected. See ch. 15.

transparency mask

It works only when attached under the target layer; attached to the rootNode it is an orphan. After mask.setSelection(sel) it clips exactly. It is editable, which suits handing over to an artist better than inherit alpha does.

.kra

doc.saveAs("x.kra") works (5.x segfaults). The round trip is faithful in layer count, inheritAlpha, blend, opacity, label and vector layers. doc.exportImage(path, InfoObject()) writes a PNG. Call doc.setBatchmode(True) before saving.

Selections, filters, erasing, transforms

✅ verified

Selections

from krita import Selection
sel = Selection()
sel.select(x, y, w, h, 255)                     # a rectangle
sel.setPixelData(mask.tobytes(), 0, 0, w, h)    # an arbitrary shape, built with 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)
  • A selection confines paintLine: a rectangle at x in [50,200) lands the stroke in [50,199]; a circle plus feather(15) feathers 15 outward. All of it exact.
  • It does not confine filter.apply(): run a gaussian blur with half the canvas selected and the whole canvas is processed. But the Selection overloads of createFilterMask and createFilterLayer are confined by the selection, feather with it, and are non-destructive (ch. 15).
  • It cannot keep fractional coverage: feed it a 0-to-255 antialiased mask and what is painted improves only from aliased 100% to 25.5% (boundary-band deviation 0.367 to 0.331, against a vector layer’s 0.083). Do not look to selections for a smooth edge.
  • Building one on 8192x12288 takes about 1.1 s, which is the cost of a full-canvas setPixelData.
  • There is exactly one way to clear a selection: doc.setSelection(None). setSelection(Selection()) and sel.clear() mean “select nothing”, after which every stroke silently paints 0 px and nothing raises. The symptom is “suddenly no brush works at all”.
  • sel.selectAll() takes two arguments, (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)        # it returns None on success too, so check the pixels yourself

52 of them: asc-cdl, autocontrast, blur, burn, colorbalance, colortoalpha, colortransfer, crosschannel, desaturate, dodge, edge detection, emboss (and five more), 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.

Its extent is governed by the box alone, with a knife-cut edge – measuring the blur-strength profile’s 0.9-to-0.1 transition band gives 7.2 px, exactly the criterion’s own floor (what an ideal step reads after the same 9-wide smoothing).

Correction: this used to say that a local filter needing feathering could only be run over the whole canvas and blended back by mask with numpy. That was a conclusion about apply(). doc.createFilterMask(name, f, sel) takes a Selection, where sel.feather(40) gives a 59.9 px transition band and feather(120) a 177.7 px one, and it is non-destructive. See ch. 15.

Erasing

The only thing that works is clearing alpha, s.erase(box=, mask=). All three ways of “erasing with a brush” do nothing (ch. 12).

Transforms

Node level, all of which change the bounds: scaleNode(QPointF(cx,cy), w, h, "Bicubic") (QPointF; a QPoint is a TypeError), rotateNode(rad), shearNode, cropNode and move.

Document level: scaleImage(w,h,xres,yres,strategy), rotateImage, shearImage, crop, resizeImage and flatten. The strategies from filterStrategies() are Bell, Bicubic, BSpline, Hermite, Mitchell, Bilinear, Lanczos3 and NearestNeighbor.

scaleImage’s strategy is not validated: pass "xyzzy" and the size changes anyway, by an unknown interpolation. Session.w and h do not follow automatically afterwards; read doc.width() back.

What node-level transforms measure against clipping inside a group, and their non-destructive counterpart the TransformMask, are in ch. 15.

Resources

Krita.instance().resources(type): preset 144, brush 84, gradient 19, pattern 112, palette 13, workspace 9. Any other string returns nothing. createFillLayer(name, "gradient"|"pattern", InfoObject(), sel) lays one as a layer; "simplenoise" segfaults.

Krita.instance().actions() has 688 entries, and any GUI menu item can be .trigger()-ed – the back door to anything the API does not cover, with no return value.

There is no “lay one” API for a gradient or a pattern on a Node; the only way to their rasterized result is Scratchpad.fillGradient and fillPattern (ch. 15).

Other things

  • Wrap around: view.canvas().setWrapAroundMode(True) measurably affects painting, with a stroke past the edge wrapping to the other side.
  • Animation: it can read and set timeline parameters and import a sequence of frames, but there is no API for creating a keyframe.
  • Channel order: ManagedColor.setComponents, Node.setPixelData and pixelData are all BGRA, the first in 0-to-1 floats. Anything the canvas exposes is RGB.

Non-destructive, generative and offscreen

✅ verified · Krita 6.0.2.1 with PyQt6 and Xvfb, six probes (appendix C)

The four chapters before this are about writing pixels into layers. This one is about three other kinds of capability, whose common property is that they write no pixels: non-destructive effect layers, content Krita computes itself, and a canvas outside the document.

Ch. 13 used to say that filters are not confined by a selection – which was a conclusion about apply() alone. The first section here is what rewrites it.

A non-destructive local filter

filter.apply(node, x, y, w, h) is destructive, its extent governed by the box alone, with a knife-cut edge. But createFilterMask and createFilterLayer take a Selection directly:

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)          # attached UNDER the target layer, not to the rootNode

The criterion is the width of the blur-strength profile’s 0.9-to-0.1 transition band (an ideal step reads 7.2 px after smoothing, which is the floor):

approachtransition bandtimedestructive
filter.apply(node, box)7.2 px (the floor, a knife cut)4.4syes
createFilterMask(f, sel.feather(40))59.9 px4.1sno (the source layer is byte-for-byte untouched)
the same, with feather(120)177.7 px7.6sno
createFilterLayer(f, sel.feather(40))59.9 px3.9sno
createFilterMask(f, node)none – the whole canvas is processed9.3sno
  • The transition band is about 1.5 times the feather (59.9/40 = 1.50, 177.7/120 = 1.48).
  • The createFilterMask(name, filter, node) overload builds but confines nothing, whether the source node is visible or sitting at the root. For a local effect, use the Selection overload.
  • Round trip: both a filtermask and a filterlayer survive in a .kra, with an identical transition band after reopening.
  • Measuring it requires s.projection(group). A mask is non-destructive and node.pixelData() does not change by a byte, so measuring the source layer gives the opposite conclusion, that it did nothing at all.

ColorizeMask: lineart plus a few colour points, giving flats

Krita’s own lazy-brush. It runs headless, but two steps of the call sequence do not appear at all in api.kde.org’s method list and exist only in the source:

m = doc.createColorizeMask("CM")
lineart.addChildNode(m, None)
m.setEditKeyStrokes(True)
m.initializeKeyStrokeColors([ManagedColor, ...], -1)   # <- without this step, everything below returns False
cc = m.keyStrokesColors()                              # <- use the objects it returns to set with
m.setLimitToDeviceBounds(True)                         # otherwise the background colour spreads over the whole 8K
m.setKeyStrokePixelData(buf, cc[i], x, y, w, h)        # buf is one byte per pixel, and it returns a bool
m.setEditKeyStrokes(False)                             # <- leave it on and the output is a preview blended 50% with white
m.updateMask(True); m.setShowOutput(True)

setKeyStrokePixelData writes only into an existing keystroke of the same colour and returns False when it finds none – it is honest rather than silent. The colour comparison is exact KoColor equality, so use the objects keyStrokesColors() returns rather than making “the same” colour yourself.

On 8192x12288, one analytic closed curve cut into three by two horizontal lines, with a line width of 6 px:

Criteriaresult
each of the three regions’ area over the analytic area0.98 / 0.98 / 0.98, the missing 2% being the line width
unclassified pixels0.3%
time and memory12.3s,RSS +1100 MB
boundary-band deviation0.4981, a binary hard edge
.kra round tripa colorizemask survives

It assigns regions very accurately and its edges are very poor. 0.498 is in the same class as paintPolygon and an order of magnitude from a vector layer’s 0.083 (ch. 12’s comparison of four approaches). So its place is cutting flats, not producing a finished edge.

The criterion lies here too: computing the coverage by hard classification (“which colour point is it nearest”) gives a boundary-band deviation of exactly 0.5 – which measures that my classifier is binary, not that colorize’s edge is. Projecting each pixel onto the segment from the background colour to the region’s colour as a continuous coverage gives the credible 0.4981.

Presets: changing a brush programmatically

from krita import Preset
p = Preset(res["b) Airbrush Soft"])      # it takes a Resource
xml = p.toXML()                          # between 1.7k and 22.7k characters
p.fromXML(xml2)
presetxmlwhat setBrushSize(90) actually paintsfactorfromXML(toXML())
b) Basic-1168992 px1.02lossless
f) Bristles-5 Flat1651640 px0.44lossless
b) Airbrush Soft955282 px0.91lossless
c) Pencil 1 Sketch (mypaint)2265814 px0.16lossy: 14 becomes 442 px, a factor of 31, and nothing raises
  • Changing diameter inside brush_definition works: Airbrush Soft from 90 to 200, after which setCurrentBrushPreset carries view.brushSize() to 200 automatically and it paints 184 px.
  • But on a preset with a predefined tip (Bristles-5 Flat), changing diameter does nothing at all and raises nothing.
  • Zeroing hfade and vfade leaves both presets’ factors exactly where they were (0.91 and 0.44). The factor comes from the tip’s shape itself, so ch. 6’s table of width factors cannot be got rid of.
  • A change through fromXML takes effect globally within the process (calling resources("preset") again returns the changed one) but writes nothing to disk (paintoppresets/ gains and changes nothing), and fromXML(the original xml) restores it.

Scratchpad: a canvas outside the document

The hope was to use it to get round the undo stack (ch. 18: about 233 KB per paint* that cannot be cleared). That road does not exist – Scratchpad has no programmatic painting API at all, only fill* and loadScratchpadImage. paintEngine, paintEvent and paintingActive come from QWidget; do not be fooled by them.

It has another value though: it is the only way to the rasterized result of the 19 gradients and 112 patterns – there is no “lay a gradient” API on a Node.

from krita import Scratchpad
sp = Scratchpad(view, QColor(0, 0, 0), None)
sp.show(); sp.resize(1200, 800)          # the order cannot be reversed; see below
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
  • It constructs headless without crashing.
  • resize() and setGeometry() silently do nothing: it starts at 640x480 and stays there. It has to be show()n before resize() to become 1180x780 (still a border short).
  • The colour fillForeground fills with matches setForeGroundColor exactly.
  • fillGradient and fillPattern really produce something (32 colours for a gradient; a mean texture gradient of 54.9 for a pattern).
  • copyScratchpadImageData() to numpy to setPixelData is lossless (a maximum per-channel difference of 0).
  • Memory: 200 fillForeground calls cost 2.9 KB each, and 200 paintLine calls on the same canvas 53.5 KB each.

Shapes: a vector layer can still be edited after it is built

A shape’s geometry is in points, not pixels. At 300 DPI, 1 px = 0.24 pt. Asking shapeAtPosition in pixel coordinates returns None, which is easily read as “this feature does not exist”.

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))   # see below
operationresult
shapes() / shapesInRect / shapeAtPositionfine in pt; in px it returns None or nothing
setTransformation(T0 * translate)the boundingBox translates exactly, and the raster deviates 0.0861 from the analytically translated ground truth
setTransformation(T0)restored exactly (the deviation returns to 0.0891)
setPosition(P0 + delta)equivalent to the above
setZIndexworks, and the stacking really changes in the composite
createGroupShape(name, shapes)works, turning 2 shapes into 1 groupshape
setAntialiased(False / True)boundary-band deviation 0.2624 against 0.0891 – the switch really does change the edge
  • setTransformation replaces rather than composes. Feeding it QTransform().translate(...) directly wipes out the shape’s original placement; and since QTransform.translate translates in local coordinates first, translating in the parent’s coordinates has to be written T0 * QTransform().translate(...).
  • A group’s projection leaves a ghost where the shape used to be: the opaque pixels in the same large box grow from 2,584,626 to 2,858,295. So do not judge a translation by the centroid, which the ghost drags along. Judge it by boundingBox() (read from the object, exact) or by the deviation from an analytically translated ground truth.

Node-level transforms and the TransformMask

Node-level transforms all work inside a group, and the clipping relationships follow. An 800x800 Flat plus a Marks clipped to it, counting opaque pixels in the composite:

operationopaque px in the compositeFlat bounds
(original)640,000800×800
move(+400, +300)640,000moved to (1800,1700)
rotateNode(0.3)641,5161002×1002
scaleNode(QPointF(cx,cy), 1200, 1200, "Bicubic")921,2211199×1200
cropNode(x, y, 400, 400)2,98343×138

A TransformMask is the non-destructive version of these, and follows the same pattern as a preset – read the factory skeleton and edit it, never write one by hand:

tm = doc.createTransformMask("TM")
target.addChildNode(tm, None)
xml = tm.toXML()                       # the 926-character factory skeleton
xml = edit transformedCenter in the xml  # a hand-written transform_params makes fromXML return False
tm.fromXML(xml)                        # -> True
tm.finalAffineTransform()

After changing transformedCenter, the centroid translates exactly by (+500, +300), not one byte of the source layer’s pixels moves, and the transformmask survives a .kra round trip.

What else a delivered .kra can carry

thingBuildingeffect on the composite.kra round trip
createSelectionMask + setSelection✅a per-channel difference of 0 (it changes nothing and simply travels with the file)✅
createFileLayer(name, path, method)✅ all three of None, ToImagePPI and ToImageSize workrenders normally✅
createCloneLayer(name, source)✅a per-channel difference of 0 from its source, and it follows when the source is edited✅
Palette + Swatch + addEntry✅ it can be added in memory—❌ save() returns False and writes nothing, and there is no document-level palette API
setLayerStyleFromAsl❌0see ch. 16

The first three are pure gain: the artist taking over gets a ready-made selection per part, a reference layer of the source, and clones of repeated parts.

A Swatch’s methods are color, id, isValid, name, setColor, setId, setName, setSpotColor and spotColor.

Unusable, crashing, or silently doing nothing

✅ verified

This chapter holds one of the most valuable tables in the book. A great many of Krita’s setters do not validate their arguments, do not raise, and silently do nothing, and a few kill the process outright in the C++ layer with no Python traceback.

It crashes

thingbehaviour
paint* with no viewsegfault, with no traceback
QT_QPA_PLATFORM=offscreenKrita 6 segfaults on startup; Xvfb is required
node.save() on a vector layersegfault, killing the process. Take the pixels from the composite
createFillLayer(..., "simplenoise", ...)segfault ("gradient" and "pattern" are fine)
the layers’ tiles eat all the memorythe OOM killer takes the process without a word. It is the number of layers times the area painted on each (once wrongly recorded as “233 KB of undo stack per paint*”, ch. 18)

Six probes (FilterMask, ColorizeMask, Preset XML, Scratchpad, delivery, Shape) added nothing new that crashes – everything that failed did so by honestly returning False or by silently doing nothing.

It silently does nothing

thingbehaviourhow it was found
doc.setSelection(Selection()) / sel.clear()“select nothing”, after which every stroke paints 0 px“suddenly no brush works”
setBlendingMode("any string at all")reads back unchanged and composites as normalmeasure the composite
scaleImage(..., strategy="xyzzy")the size changes anyway, by an unknown interpolationvalidate against filterStrategies() first
setPaintingOpacity(255)saturates to 1.0measure the peak alpha
f) Bristles-5 Flat + opacityignored entirelyas above
a mypaint preset painting a 1 px dableaves nothing at allcount the pixels
setBrushSize(n)paints between 0.2n and 0.93nmeasure the width
a transparency mask attached to the rootNodean orphan node with no effectattach it under the target layer
group passthrough=True + clipclipping stops workinglook at alpha
a selection against filter.apply()does not confine itthe whole canvas changed
an autosave left by a crashevery later run hangsrm -f *autosave*.kra
an environment variable holding a relative pathstops working after a cdrealpath it first
Document.setBatchmode(True) against the undo stackdoes nothing (291 against 297 strokes a second, and RSS grows regardless)—
kritarc’s undoStackLimit against paint*does nothingRSS grows to gigabytes regardless
filter.apply()’s return valueNone on success toocheck the pixels yourself
pixelData() reading stale dataa single read can give the opposite conclusions.settle() until it is stable
createFilterMask(name, f, **node**)confines nothing and processes the whole canvasmeasure the profile: there is no transition band
Scratchpad.resize() / setGeometry()does nothing, and it stays 640x480show() before resize()
changing diameter on a preset with a predefined tipdoes nothingmeasure the brush width
Preset.fromXML(toXML()) on a mypaint presetlossy: 14 px becomes 442 pxmeasure the brush width after the round trip
Shape.setTransformationreplaces rather than composes, wiping out the original placementwrite T0 * QTransform().translate(...)
a shape’s geometry is in pointsasking shapeAtPosition in px returns NonePT = 72 / doc.resolution()
a group’s projection leaves a ghost after a shape movesthe centroid of a large box is dragged offjudge a translation by boundingBox() or against an analytic ground truth
forgetting setEditKeyStrokes(False) on a ColorizeMaskthe output is the editing preview, blended 50% with whitemeasure the patches’ colours

It does not exist

thinginstead
Krita.instance().createInfoObject()from krita import InfoObject
Node.setFilter()a FilterLayer has one
an API for creating a keyframeone document per frame, exported as a PNG sequence
an API for clearing the undo stackmake fewer calls (ch. 18)
setPassThroughMode on createNode(..., "grouplayer")use createGroupLayer
node.setLayerStyleFromAslNothing can be fed in: a hand-written PSD descriptor XML returns False (the XML is valid; the number of styles parsed out is not 1), layerStyleToAsl() returns an empty string when no style is set so there is no skeleton to be had, and there is not one .asl on this machine in asl/, layerstyles/ or any of the four bundles
painting on a ScratchpadThere is none. Only fill* and loadScratchpadImage; paintEngine, paintEvent and paintingActive come from QWidget
Palette.save()returns False and writes nothing, and with no document-level palette API it cannot get into a .kra
a hand-written transform_paramsTransformMask.fromXML returns False. Read the factory toXML() skeleton, edit that, and it returns True
documentation for ColorizeMask.initializeKeyStrokeColorsit does not appear in api.kde.org’s method list and exists only in the source. Without it, setKeyStrokePixelData returns False every time

Judged wrongly

These were recorded as unusable and later turned out to be measurement mistakes:

recorded astruth
setInheritAlpha does nothing (the earliest edition)the base and top were built under the root node, where a fully opaque Background makes the accumulated alpha 255 everywhere. Inside a group it works
a transparency mask has no effect (the earliest edition)it was attached in the wrong place
the erase blending mode removed 2198 pxstale pixels were read. Once settled it reads 0
filters are not confined by a selection (the old conclusion)that was a conclusion about apply(). createFilterMask(name, f, **Selection**) is confined by the selection and feathers with it, with a transition band about 1.5 times the feather (ch. 15)
ColorizeMask “computes nothing”initializeKeyStrokeColors was never called and no keystroke was ever created; with it, all three regions’ areas match at 0.98
a shape “does not move all the way”the measuring box was smaller than the shape and what moved outside it was cut off; measured against an analytically translated ground truth, the deviation is 0.0861

What they have in common: the criterion was looking at the wrong thing. Ch. 16.

Measuring the wrong thing: three iron rules and a case collection

✅ verified – every one of these really happened

A great many of Krita’s APIs do not validate their arguments, do not raise, and silently do nothing, so every conclusion in this book rests on measurement. But measurement lies too, and lies harder than the API does: it raises nothing and simply hands you a plausible conclusion pointing the wrong way. This project was caught out more than thirty times across seven editions, and all of them are below. Three iron rules first, then the cases.

Three iron rules

One: settle whether you are measuring one layer or the composite

Krita’s clipping (inherit alpha) works by writing only RGB and not A when compositing. So:

  • read a clipped layer’s pixelData and the RGB has been written even where alpha is 0 – looking at RGB alone concludes “not clipped”
  • read an accumulated composite and dozens of layers’ boundaries lie on top of each other – whether any one layer is dirty cannot be seen at all
  • the modelling is painted on the Shade layer, so measuring the Flat layer always reads “1 colour”

The rule: measure a layer effect with s.projection(group), always; judging clipping requires looking at alpha too; and judging one layer’s edge quality means measuring that layer and nothing else.

Two: comparing across resolutions is not a comparison

The gradient p99 is a per-pixel difference and changes with resolution. The same 8K output:

normalized before measuring togradient p99
8192 px wide33.2
2508 px wide42.2
1024 px wide56.2

Comparing the reading at 8192 (33.2) with a reference painting’s at its native 2508 (59.4 to 74.6) concludes “twice as bad”; normalized to 2508 it is 42.2 and the gap is half that – both the size and the direction changed.

The rule: normalize every whole-picture measure to the same physical width first. gauge.py normalizes to 2508 by default, the reference paintings’ native width.

Three: anchor the thresholds to the source, not to a reference painting

The reference paintings (two human illustrations at 2508 px) show what a person’s work looks like. But they are different pictures: they have a full set of dark lineart, while the worked example’s source (ch. 19) is 色トレス (colour trace) and has no separate lineart at all. Both normalized to 2508 px:

gradient p99
the reference paintings59.4–74.6
the source itself (the worked example)41.6

Demanding that a study be sharper than what it studies is a gate nothing can ever pass. The rule: write the threshold as a percentage of the source (85% at present), and print the reference paintings’ range for context without making it a gate.

Two further warnings beyond the three rules

A probe’s geometry has to match the real use. Measured on long smooth curves, path() came out 23 to 47% cleaner than stroke() and the lineart was changed on that basis; on the short segments actually used, the conclusion reverses entirely (stroke() is best at K90 0.032 and path() worst at 0.0437). With few points, paintPath’s quadTo smoothing changes the shape.

Change one thing at a time, each with its own criterion. Three things were done at once to fix a jagged black outline (extracting lineart, removing antialiasing, smoothing the SDF), and the result was nine times worse than the problem they were fixing (PSNR 38.53 -> 24.99), and it took six rounds to work out which of them was doing the damage.

The cases

“Measuring the wrong thing” is the most expensive class of mistake in this project: it raises nothing and simply hands you a plausible conclusion pointing the wrong way. Over five editions it happened at least 28 times. Each is recorded as symptom, truth and rule, so that the next time the symptom appears it can be matched.

The wrong layer

1. Measuring an accumulated composite and calling it one layer. Symptom: “the lineart layer is dirty at K90 = 0.104”. Truth: what was measured was 008-lineart.png, an accumulated composite; Line_Main on its own reads 0.032 and passed all along. -> Measure one layer by measuring that layer alone.

2. Measuring the Flat layer when the modelling is on the Shade layer. Symptom: all three methods read “1 colour, sigma 0 throughout”. Truth: the modelling is painted on the clipped Shade layer. -> Measure the group’s projection.

3. Measuring on a transparent layer. Symptom: 94% multi-scale energy. Truth: the transparent area has RGB 0, so the filter mixes in a black edge; the real figure is 7%. -> Use the projection, or fill the uncovered area with the mean.

4. Judging clipping by RGB alone. Symptom: “inherit alpha does nothing”. Truth: clipping writes only RGB and not A, so RGB is written even where alpha is 0. -> Look at alpha too.

5. Testing clipping under the root node. Symptom: as above. Truth: there is a fully opaque Background under the root, so the accumulated alpha is 255 everywhere and clipping nothing is the correct behaviour. -> Test inside a group.

6. Testing multiply on a single layer. Symptom: a vignette raises pure black to (6,6,6), yet multiply over black on a single layer gives (0,0,0). Truth: a non-passthrough group composites its siblings first and then lays the whole group down, so the vignette is multiplying against a transparent sibling. -> Measure inside the real group structure.

11b. Measuring the source layer for a non-destructive effect. A FilterMask, FilterLayer or TransformMask changes not one byte of the source layer’s pixels, and node.pixelData() is byte-identical before and after. Measuring it requires s.projection(group), or the conclusion is “it did nothing at all”. -> If the effect lives in the composite, measure the composite.

The wrong thing compared

7. Comparing whole picture with whole picture. Symptom: “local contrast is 51 times worse”. Truth: the reference’s subject contains several materials, and our body criterion counted the background in. -> Compare over comparable semantic areas, where on like-for-like areas ours is actually higher.

8. Comparing p99 across resolutions. Symptom: an 8K output at p99 33.2 against a reference painting’s 59.4 to 74.6, “twice as bad”. Truth: the same output at 2508 reads 42.2. -> Normalize to the same physical width first.

9. Anchoring to a reference painting. Symptom: p99 never reaches 59. Truth: the source itself is only 41.6, and the reference has a full set of lineart while the source has none. -> Write the threshold as a percentage of the source.

10. Comparing an 8x output with an upscaled source. Truth: that is treating interpolation as ground truth. -> Downsample to 1x and compare there.

11. Drawing a separate 1x ground truth. Symptom: the anchor reads 0.1245 rather than 0. Truth: PIL has a systematic offset of about an eighth of a pixel between supersampling factors. -> Derive the 1x ground truth by downsampling the 8x one.

The wrong measure

12. empty points the wrong way on an upscaling target. Nearest-neighbour passes at 39.93 and Lanczos fails at 51.58. -> Pair it with edge_ratio.

13. Judging an edge by the whole area’s gradient p99. Symptom: three of four fill approaches read p99 = 0.0. Truth: the edge is 1% of the area and p99 lands in the pure interior. -> Measure inside a boundary band.

14. Judging “is the edge a curve” by boundary-band deviation. Symptom: all four of v4’s and all twelve of v5’s read 0.47 plus or minus 0.02. Truth: the band is 1 px wide and cannot recover an 8x sub-pixel position from a 1x binary mask; what it measures is whether the edge is where it was. -> Pair K90 with shape deviation.

15. Using the gradient p90 as a measure of detail density. Lowering the grain makes p90 fall instead of rise. -> It is dominated by uniform noise.

16. Judging vectorization by K90 alone. It rewards rounding everything off. -> Pair it with shape deviation.

17. Reading aliased 0.0% as “sharp”. Symptom: strands painted with c) Pencil 1 Sketch come out entirely blurry. Truth: at W = 3.25 it is the softest brush there is, and its aliased 0 is because it is too soft to have any steps. -> Look at W.

17b. A criterion coarser than what it measures. Computing the coverage of a ColorizeMask’s output by hard classification (“which colour point is it nearest”) gives a boundary-band deviation of exactly 0.5 – what that measures is that my classifier is binary. Projecting each pixel onto the segment from the background colour to the region’s colour, as a continuous coverage, reads a credible 0.4981. -> A criterion’s resolution cannot be below the difference you want to tell apart.

17c. A criterion with no floor. filter.apply’s transition band measures 7.2 px, which looks like “a little feathering”. In fact an ideal step through the same 9-wide smoothing is 7.2 px – that is the criterion’s own reading. -> Compute both the perfect end and the worst end first, and then see where the thing you are measuring falls.

The wrong probe geometry

18. A long-curve probe for a short-segment use. path() is 23 to 47% cleaner than stroke() on long curves and the reverse on short segments. -> Match the probe’s geometry to the real use.

19. Measuring the edge of a long straight line to ask about the shape of a dab. f) Bristles-5 Flat is the cleanest in the field at aliased 0.0 and K90 0.065, but it is a flat brush and every short dab is a rectangle. -> Measure the thing you are asking about; probe_markshape was written for exactly this.

The tool itself wrong

20. A half-pixel coordinate convention. matplotlib’s contour returns index space while PIL and Krita use pixel space, half a pixel apart. A shape deviation of 0.302 was read as “vectorizing is worse than coverage()”. -> Verify every coordinate convention once.

21. and where or was meant in the framing function. The non-overlap test was written with and, so it could only pick blocks of pure black background, and the criterion read 14.5 against a real 43.5. -> Print the framing coordinates and the mean brightness.

22. Reading stale pixels. Erase appeared to remove 2198 px. Truth: it is asynchronous, and once settled the reading is 0. -> s.settle().

23. Judging a translation by the centroid. A shape moved 300 px and its centroid moved only 214.5 px. Two causes at once: the measuring box was smaller than the shape, so the part that moved out was cut off; and the group’s projection leaves a ghost where the shape used to be (the opaque pixels in the same large box grew from 2.58 million to 2.86 million). -> Judge a translation by the object’s own boundingBox(), or measure the deviation against an analytically translated ground truth.

24. Measuring per-column variance on vertical stripes. The blur-strength profile took “each column’s standard deviation down the rows”, and on vertical stripes every column is constant, so it reads 0 everywhere and all four approaches read as “none of them did anything”. -> A probe’s texture has to run perpendicular to the direction the criterion sums along.

25. Zero padding in a convolution. The profile was smoothed with np.convolve(..., mode="same"), whose zero padding pulls both ends down; pulling the right end down reads as “blurrier”, the profile’s tail invents a rise in strength, and the transition band measures 1203 px rather than 7. -> Replicate the boundary before convolving.

Added in the sixth edition

26. Dividing a memory increase by a stroke count to get “undo stack per stroke”. 12,000 strokes added 2.8 GB, which divides to 233 KB a stroke, and that went into the budget. Truth: the same 12,000 strokes add 0.06 GB when they land in fixed areas and 4.21 GB when scattered over 8K – what grows is the layers’ tiles and the groups’ projections, and it has nothing to do with the stroke count. -> Vary a different variable and measure again, and only then do you know whose growth it is (ch. 18).

27. A probe holding an old handle while measuring memory after a close. Save, close, reopen, and RSS does not come back. Truth: the probe’s own variables still referenced a node of the old document. -> Clear your own references before measuring a release.

28. Judging “did the stroke take effect” by counting opaque pixels. After reopening, a stroke is painted, the opaque count does not rise, and it is judged to have done nothing. Truth: that layer was already full. -> Compare content hashes.

29. “The darkest pixel under the line” coming from the background. A line’s colour is set by the darkest pixel beneath it, and every outline came out black. Truth: half an outline lies over the black background, so of course the darkest pixel is background. -> Look inside the subject only (ch. 2).

30. Treating the source’s alpha as ground truth. A 12 px “gap” between two legs has an alpha of only 1 to 3, segmentation took it for background, and the lines doubled, the black ground showed through and the completion seeped inward. Truth: its RGB is skin, and an unpremultiplied PNG’s alpha is simply broken there. -> Check alpha and RGB against each other (ch. 1).

31. Solving alpha on the completed image. The amodal shape’s edge is 1 px off throughout. Truth: solving alpha on the completed image everywhere lets the model’s repaint drag the visible part’s edge along too. -> Substitute the completed image only in the invented region and use the source elsewhere (ch. 3).

32. Judging “is the interior blurry” by p99. v6’s gradient p99 of 48 is higher than the source’s, and yet it looks blurrier than v5 to the eye. Truth: p99 is propped up by the outlines. -> Measure p90 more than 30 px from the outline (ch. 7).

33. Comparing colour counts across multipliers. 4x reads 82 to 85% of the 8-level colour count and 8x reads 69%, which was read as “4x paints better”. Truth: normalizing to 2508 px shrinks 8x by 3.3 times and 4x by only 1.6, so less of the soft brush’s transition is wiped away. -> Compare at the same multiplier, or compare only the ratios against the source anchor.

34. ground(r, g, b)’s third argument recorded as a box. In the painting journal, set_pixels and ground shared one piece of recording code that unpacked the positional arguments as (arr, node, box) – ground(0, 0, 0)’s b = 0 is falsy and harmless, but ground(220, 220, 220)’s b = 220 became the “box”, list(220) raised, and that fill was never recorded. One pixel write went uncounted, and the honesty criterion (ch. 7) looked better for it. -> Unpack arguments per operation in the logging layer, and a test case that silently reads 0 does not count as passing. It also made something else clear: ground calls set_pixels internally and both layers are wrapped by the journal, so one fill records two entries – the “2 pixel writes” quoted throughout this book is that one fill. The threshold of at most 2 is correct, but what it means is “once”.

35. Criteria running on the previous round’s output. The one-shot step reported all five criteria passing. Truth: the Krita job had died with a KeyError right after building the tree, the entry script swallowed the failure and exited 0, the output directory still held the previous run’s final.png, and that is what gauge.py measured. The main line was broken that way for two editions. -> A failed job has to fail its entry point; the criteria have to verify that the file being measured was written by this run (its timestamp no earlier than this [run]); and a table shared by the planner and the painting job (the brush kinds) lives in one place and is checked before anything goes to disk.

Added in the ninth edition

36. Taking “the review images exist” for “someone looked”. The draft is the gate, and review-parts.png with its three columns and one layer-group-*.png per group were all produced; the neck sat in them as a patch of yellow, having been given to the front hair, through three editions. -> A gate is a person looking, not a file existing; and every time the baseline changes, look at the four close-ups of the face, the neck, a hand and a foot (ch. 1).

37. A close-up of the neck measuring hair. A skin experiment used “the edge of the shadow on the neck” as its criterion, and yet the pixels in the close-up window belonged to the front hair: half because segmentation had judged the neck to be front hair (case 36), and half because the front hair’s Shape, expanded by 1.2 px and then smoothed, covered another 1,502 px along the face and the neck. Neither version’s skin was ever painted there. -> Before measuring, find out which group actually paints those pixels in the output (masks_paint.npz plus the evenodd rasterization of the Shapes); and an upper Shape may not cover a lower layer’s visible pixels (contour.trace(forbid=), ch. 5).

38. A table of knobs that was a design rather than a measurement. Appendix A listed a --k=v knob for every stage; v0.1.0’s cli.run collected trailing arguments with nargs="*", argparse rejected --k=v as an unknown option, and the whole chain ran on defaults with not one of the table’s knobs ever reaching it. -> A knob needs a test that makes it visible when it is passed (printing the value in force), or it might as well not exist.

39. Skin’s “at the hard edges” measuring the eyes. The per-group rulers measure by the group masks in masks_paint.npz, Skin’s mask contains the eyes (Eye being a group cut out of Skin_Face and laid over it), and Skin’s “top 5% of the source’s gradient” was nothing but eye sockets and pupils. -> The rulers’ masks have to be mutually exclusive, with every pixel belonging only to the group that paints it (groups.exclusive_masks).

40. Measuring a band edge with a median. Taking a 2 px band either side of a band’s Shape ring and measuring the median gradient reads 4.3 on the source – for what is plainly a hard edge. Truth: the band is 4 px@1x, about 10 px@2508, of which the edge is only 2 or 3, so the median lands on flat pixels. -> Use p90 (which reads 30.0 on the source). A criterion cannot be coarser than what it measures; this is the third time that sentence has appeared in this book.

41. Measuring “interior sharpness” on the 1x simulated canvas. Skin experiment B’s simulated canvas had only the flat colour and the band with no lines, its interior p90 read 0.00 and “at the hard edges” 0.00, and it was nearly written up as “the band flattened the skin”. Truth: no lines are painted on the simulated canvas at all, and the source’s top 5% of gradient is precisely the lines. -> A simulated value may only be compared between variants, every README says “1x simulated” in so many words, and the book uses only numbers gauge measured from a complete final.png.

in one line

Ask three questions before every measurement: which layer am I measuring? what am I comparing it with? and can this measure tell apart the two things I want to tell apart? The third is the one most easily skipped.

Budget and time

✅ verified · the four probes these numbers come from: appendix C

The conclusions first

  • Memory is the number of layers times the area painted on each, not the number of calls. A dozen semantic layers, each with strokes landing only inside its own area, caps out at a few gigabytes however many strokes there are; what explodes is three layers per value band per part, which is the one-shot route.
  • Time is the number of calls: about 300 a second inside a tree (490 strokes a second at 4x), with a whole stroke being one paintPath call; only a tapered stroke goes segment by segment through paintLine.
  • 4x by default: the Krita half is more than twice as slow at 8x (one-shot painting 217 s at 8x against 167 s at 4x; the layered route’s draft, measured on the old route before completion, 374 s against 295 s; building the tree 209 s against 97 s), while after normalizing to 2508 px the criteria read within noise and scaling effects of each other.

Throughput

On a blank single-layer 8K document: paintLine runs at 450 to 850 strokes a second, and sizes of 8, 32, 120 and 400 make no difference – a large brush costs the same as a small one; paintPath runs at 310 to 390. Changing colour or brush size needs no round of the event loop (paint 24 patches in a row without running it and exactly 24 colours read back); running the event loop each time gives 71 strokes a second, a tax of 8.5x. A real layer tree of 307 clipped nodes costs only 7% more, and one paintPath call is 2.3 times faster than the same stroke segment by segment through paintLine. Points are free: a 132-point contour is 0.35 s in one paintPath.

Memory: measured wrongly once

The first version attributed the growth to “233 KB of undo stack per call that cannot be cleared”, arrived at by dividing 2.8 GB by 12,000 strokes. It was measuring the wrong thing (ch. 17, case 26). On the same tree, change one thing: where the strokes land.

12,000 strokesRSS growth
all landing in the same three 1100 px areas, first round+0.65 GB
another 12,000 in the same areas+0.06 GB
scattered at random over the whole 8K canvas+4.21 GB
another 12,000 scattered+0.95 GB

The same 12,000 calls, seventy times apart. The undo stack has a limit of Krita’s own, and once the tiles are saturated each stroke adds only 5 KB. What grows is the layers’ tile memory plus the groups’ projections: wherever a stroke lands, that 64x64 tile goes from empty to dense, until the whole layer is covered (an 8K RGBA layer is 400 MB). The one-shot route’s tree has over a hundred paint layers, its airbrush at 200 px diameter spills out of each one’s box, and then there are dozens of groups each with its own projection – which is where a dozen gigabytes at 8x comes from. Semantic layering is itself the fix for memory: the layered route paints more than three hundred thousand calls in a few minutes with no trouble at all.

Document.setBatchmode, kritarc’s undoStackLimit and Scratchpad (which has no painting API at all, ch. 15) were all tried and none of them is the road – and now the reason is clear: what was growing was never the undo stack.

Save, close, reopen

saveAs -> close -> openDocument -> addView is faithful (identical pixel hashes, clip, blend and vector layers intact, and painting works after reopening), but it does nothing for memory: closing returns only about 1 GB of undo stack and cache, and the dense layers load straight back out of the .kra (7.32 -> 6.40 -> 8.84 GB). Its use is showing a person the intermediate states: the finished version saves a checkpoint after each top-level group.

Two pitfalls the probe fell into itself: it wrongly concluded that painting after reopening had no effect – the layer was already full, so counting opaque pixels cannot grow and the content hash has to be compared instead; and it held a node handle from the old document, so of course the memory did not come back after closing (ch. 17, cases 27 and 28).

How long each step takes

The plain-Python half (segmentation, arbitrating the lines, recovering the completion, planning) does not depend on the multiplier and runs entirely at 1x; the ComfyUI half depends on the GPU; and the Krita half depends on the multiplier and the number of calls. The measured times on the worked example are in ch. 19, and the orders of magnitude are in the table in “Ten minutes”.

origin: one picture, two routes

This chapter is the running account of one particular picture. It is not translated with the rest of the book; in the English edition it stays in Chinese.

✅ 已验证(一次成型:work/shinku-hug-basic-1/、-2/,dtp 0.2.0 / 0.3.0)· 🧪 跑到分层线稿(先分层后上色:work/shinnku-hug-pro/,dtp 0.5.2)· 标「历史」的表来自 v9.0.0 那本书(旧走法「先素描后上色」),step 目录已删、指不到文件,只作参照

正文不认识这张图;它的一切都在这一章。每个数字后面是它来自的 step 目录。

源图与要什么

input/origin.png,1024×1536,一张 diffusion 生成的插画:金发白裙、双臂抱在胸前的少女,黑底一片辉光。要的是画师能接手的分层原画;两条走法各开一个子项目(-basic 一次成型、-pro 先分层后上色),parts.py 同一份。

分割(shinku-hug-basic-1/phase1-layers/step01-segment)

65 个区域 → 手写表 15 个子部件 → 多数票后仍 15 个;alpha 洞补回 5,572 px;多数票改了 34,923 px(主体的 7.9%);12 个 SAM 词(hair / face / eyes / mouth / dress / red bow / shoes / legs / arms / hands / neck / shoulders);38 s。

手写表的子部件px多数票改了去了哪
Hair_Back65,4161,023(1.6%)Ribbon_Hair
Skin_Leg_L / R31,144 / 26,9543 / 0Shoe
Skin_Foot8,839496(5.6%)Shoe
Shoe6,7642,234(33.0%)Skin_Foot 1,989、Skin_Leg_L 245(鞋口的袜)
Skin_Torso / Skin_Arm22,374 / 6,8360 / 0
Skin_Face10,8731,625(14.9%)Hair_Front(压在脸上的刘海)
Dress_Bodice10,4982,128(20.3%)Skin_Torso 1,946、Ribbon_Waist 182
Dress_Skirt65,5332,393(3.7%)Ribbon_Waist 2,232、Ribbon_Hem 160
Dress_Frill74,4641,886(2.5%)Ribbon_Hem 1,023、Ribbon_Waist 857
Ribbon_Waist10,0827,755(76.9%)Hair_Front——手写表把腰边的粉色发梢当成了腰带
Ribbon_Hair3,8602,222(57.6%)Hair_Front 1,544、Hair_Back 678
Hair_Front90,4737,586(8.4%)Skin_Torso 5,255(脖子回到皮肤)、Ribbon_Hair 2,318

脖子:手写表里脖子和整头前发是同一个区域,所以一次成型(只用手写表)的脖子是头发画的;多数票里 hair 占这个区域 90%,整区域判给头发,靠 neck / shoulders 两个词当种子按颜色长回来(第 1 章)。neck 只圈到脖子的 45%,shoulders 22%,chest / upper body / skin 都退化成整个人(29% 画面),不能用。剩下的:刘海之间的额头还归前发(face 的 mask 圈不到)。

左手写表(腰边那缕粉发梢是腰带的品红),中多数票,右变动的像素。

线当墙(shinnku-hug-pro/phase1-layers/step03-segment,WALLS = step02-lineart、ASSIGN_FROM = step01-segment,dtp 0.4.0,38 s):step02 的 244 笔线光栅化成 38,997 px 的墙,区域 65 → 71;ASSIGN 表继承 step01;多数票之后与 step01 标签不同的像素 5,472(主体的 1.24%)。变的集中在两处:脖子框(CROPS["neck"],1x 128×128)里 Skin_Torso 9,923 → 9,664、Hair_Front 1,944 → 2,240——step01 在锁骨的皮肤上有几块假的 Hair_Front,右边那缕真头发是断的;step03 假块没了,那缕头发沿着墙成了一条完整的带,Hair_Front 多出来的是真头发(只看计数会得出相反结论);鞋框里 1,901 px 从 Skin_Foot 还给 Shoe(鞋头连蝴蝶结那一片)。脖子本身 step01 就已归 Skin_Torso(neck / shoulders 词),墙没改它;手写表那一半继承老标签,脖子框 Hair_Front 6,812 → 6,835,没变。全图其余的差:Skin_Foot → Shoe 1,901、Skin_Torso → Hair_Front 1,304、Hair_Front → Skin_Torso 849、Dress_Bodice → Skin_Torso 230。特写脚本 phase1-layers/step03-segment/compare_neck.py。闸门结论:过。

源图 | step01 | step03 | 墙(红)。上脖子框,下鞋框。

一次成型(shinku-hug-basic-1/phase3-paint/step02-trace,dtp 0.2.0,4x)

读画(138 s):15 个子部件,13 个切了 5 档、两条腿 4 档,共 73 档;594 个环 + 剪影 13 = 607,20,495 控制点。笔 80,695:soft 38,846 · detail 38,963(中位长 24 px@8x)· strand 2,690 · hatch 196。

sub-partspx档
Hair_Back65,416Flat · Shadow1 · Light1 · Light2 · Light3
Skin_Leg_L / Skin_Leg_R31,144 / 26,954Flat · Shadow1 · Shadow2 · Shadow3 / Light1
Skin_Foot · Shoe · Skin_Arm8,839 · 6,764 · 6,836Flat · Shadow1 · Shadow2 · Shadow3 · Light1
Skin_Torso · Skin_Face · Dress_Bodice · Dress_Skirt · Dress_Frill · Ribbon_HemFlat · Shadow1 · Shadow2 · Shadow3 · Shadow4
Ribbon_Waist · Hair_Front10,082 · 90,473Flat · Shadow1 · Shadow2 · Light1 · Light2
Ribbon_Hair3,860Flat · Light1 · Light2 · Light3 · Light4

落笔(作业 276 s):建树 100 s——layers.json 317 层 = 73 档 × 4 + 15 个部件组 + 骨架 10(Background、01_BG ×3、04_Color、剪影、06_Line ×2、09_Post ×2),74 个矢量层;逐笔 170 s,80,695 笔 = 99,802 次调用(475 笔/秒),丢弃 0;写像素 2 次;final.kra 96.9 MB。总耗时 414 s。

判据(phase3-paint/step02-trace/gauge.json,归一 2508 px,源图为锚):

Criteria读数threshold
落笔 / 写像素80,695 笔 / 2 次≤ 2
gradient p9937.7(源图 41.6 的 91%)≥ 85%
8 级色数1877(源图 2900 的 65%)≥ 55%
the 8 px stair fingerprint0.81(源图 6.19)below the source’s
low-frequency likeness34.79 dB≥ 30
内部锐度 p909.5(头发 10.8;源图 10.5 / 8.4)anchored to the source
the darkest pixel(0, 0, 0)(0,0,0)
node317(layers.json);规则 ≤ 5 + 10 × 15 = 155,每组 ≤ 10按设计不过

1x 全图;六处 1:1 特写(4x):脸、脖子、左手、右手、腿、鞋。脖子上那些黄色发丝——脖子是头发画的。

Skin_Face 的五档形状:Flat / Shadow1(头发投在脸上的影)/ Shadow2(耳影、嘴、腮混在一起)/ Shadow3、Shadow4(两只眼睛的虹膜和瞳孔)。没有一层叫「眼睛」。

只铺皮肤和鞋的档:没有补齐,腿在裙下是断的,手臂是切开的。

核对 v9.0.0 那本书第 6 章的数字(phase3-paint/step02-trace/verify_ch9.py):

项v9 书本次conclusion
笔 / 落笔调用 / 写像素80,695 / 99,802 / 2一样
梯度 p99 / 阶梯 / 内部锐度 p9037.7 / 0.81 / 9.5一样
8 级色数18781877噪声内
likeness34.834.79噪声内
头发内部 p9011.010.8噪声内
源图头发内部9.18.4书错:v9 换了分割,头发 mask 变了,书没跟着改
节点 / 矢量层 / 环 / 控制点307 / 74 / 607 / 20,495317 / 74 / 607 / 20,495书里 307 与 317 混用:307 是部件子树,317 是整棵树
建树 / 落笔 / 读画93 / 162 / 144 s100 / 170 / 138 s机器噪声
笔种「六种」4 种书错
每部件档数2–54–5书错
Skin_Face_Shadow3 = 瞳孔 + 嘴 + 鼻孔 + 耳影 + 脖影Shadow3 / 4 = 两只眼睛;嘴、耳影、腮在 Shadow2;脖子不在 Skin_Face书错,结论(档没有名字)不变
隐藏裙子腿是断的 · 粉发梢当腰带图确认对

闸门结论(phase3-paint/step02-trace/README.md):过。数字与第 6 章一致;脖子是头发画的、腿在裙下是断的、档没有名字——这条走法的固有限制。

同一份计划两次落笔(shinku-hug-basic-2/step02-trace,dtp 0.3.0;这个子项目之后删了):读画产物与 basic-1 逐字一致(plan.json 只多一个 crops 键,strokes.jsonl 相同);Krita 落笔第二次:建树 93 s、逐笔 162 s(498 笔/秒)、作业 406 s(含作业自己写 review/ 的 142 s:15 个部件组 + 73 个档 + 12 处特写)。判据(step03-gauge):likeness 34.79 → 34.79(差 −0.0006 dB)、p99 37.71 → 37.74、色数 1877 → 1879、阶梯 0.81 → 0.81、内部锐度 9.51 → 9.51、头发 10.76 → 10.76——这条走法两次落笔的差远小于第 7 章写的噪声上限(0.6 dB / 3 个点 / 0.2);那个上限是先素描后上色量出来的(airbrush 多、随机种子多),一次成型的笔多数是确定性的。节点 317(layers.json),规则 ≤ 155、每组 ≤ 10(最多的一组 21):按设计不过。

先分层后上色(shinnku-hug-pro/)—— 跑到底;上色不过

跑到底(step08)。step01 / step02 在 dtp 0.3.0 上跑,走法在 step02 之后改(线在图层之前抽是顺序错误,第 2 章),step03 起是 0.4.0,step04 是 0.5.0(补齐换成 See-through 合并),step05 是 0.5.2,step06 起是 1.0.0(每个 step 一个 main.py)。上色(step07)人看了判不过——它抄像素,不是画(第 6 章「基线『抄』」);step07 / step08 挪成 -legacy-wrong-1,同一个号等按部位的画法,头发第一个。

step工序status数
step01segment过与 basic-1 逐字一样(上面「分割」)
step02lineart没有单独验收;只当墙和对照下面
step03segment 线当墙过上面「分割」末尾
step04complete(See-through 合并)过下面。一圈一圈的那次不过(step04-complete-legacy-wrong-1),See-through 实验(step04-complete-legacy-experiment-1)都挪到一边,不占号
step05lines放行(用户不满意,问题没列,底稿上再看)下面。第一次不过(step05-lines-legacy-wrong-1,solo 图带了黑剪影)
step06draft(闸门)过下面
step07paint不过(抄像素)下面。挪成 step07-paint-legacy-wrong-1
step08gauge不过(量的是不过的上色;读数留作基线尺)下面。挪成 step08-gauge-legacy-wrong-1

线稿(step02-lineart,35 s):轮廓候选 106 + AI 402 = 508,修剪出 114 段,共 622 条;留 244(AI 185 / 轮廓 59),淘汰 378(源图没边 39、被已留的线盖住 339)。线的尺:变号/100 px 中位 1.8,笔长中位 302 px@8x,链 186;按层 Line_Hair 119 笔 · Line_Dress 54 · Line_Body 44 · Line_Ribbon 16 · Line_Shoe 6 · Line_Face 5(变号 3.4,最抖)。三种提取器出的位图线稿(step02-lineart/ai/),只用 manga:

Manga2Anime 单线、细、眼睛线框、发丝分开;AnimeLineArt 粗、灰阶、眼内成块、两侧幻觉格子纹;LineartStandard 是边不是线。

补齐,一圈一圈的做法(step04-complete-legacy-wrong-1,输入 step03 的分割,seed 0,400 s;不过):

组圈遮挡区 px补出 px补出 / 可见取回规则
Hair_Back8372,831256,831372%颜色规则(SAM 退化)
Skin7260,685163,001145%SAM person 减 hair shoes
Shoe0253,74300%第一圈就没取回,停
Dress2109,8648,3136%SAM dress
Ribbon195,96800%SAM red bow
Hair_Front—00没有遮挡物,不补

叠回差异 0,但 layer_Hair_Back.png / layer_Skin.png 是黑白斑块,头发层还收进了裙子、腿、鞋。查过每一圈:给模型看的 solo 图里有一个黑色人形剪影(辉光参考图只从主体边往里填约 96 px,主体内 21% 是黑的),模型顺着黑剪影画黑裙子;SAM hair 对这种图把整个人框进来(前 7 圈每圈收 84–99%),颜色规则的对手是背景,任何比背景亮的都算头发;每圈换 seed 拼成斑块。这个做法留作对照。

See-through 实验(step04-complete-legacy-experiment-1,SIGGRAPH 2026 的分层模型,1280 画布,group offload,348 s):15 层——back hair 270,284 px@1x(一整头头发,含马尾和发带)、topwear 188,463(整条裙子)、legwear 96,760(两条腿,裙下的大腿补出来了)、front hair 36,340(只有刘海)、handwear 30,431、face 27,714(额头在刘海下补出来了)、footwear、neck、irides、eyewhite、eyelash、ears。它重新生成每一层:自己的叠回和源图比主体内 PSNR 18.4 dB(Dress 23.8、Skin 20.0、Hair_Back 16.3、Eye 14.4、Ribbon 10.9),手指、发丝、蕾丝丢,层边锯齿,丝带拆进头发和裙子。所以只供被遮住部分。

补齐,See-through 合并(step04-complete,dtp 0.5.0,复用实验那一步的层,合并 7 s;过):被遮 = 它的层 ∩ 上层区域 − 可见,可见钉死源图,边平滑、只留连通的、黑像素不收、按可见区重叠的均值差调色、接缝羽化。

组来源补出 px补出 / 可见
Hair_Backback hair203,988295%
Skinface + neck + handwear + legwear59,12253%
Dresstopwear10,6527%
Ribbon接缝 2 px1,0568%
Eyeirides + eyewhite + eyelash36017%
Shoe / Hair_Front没有东西压着0

叠回差异 0。后发是一整头头发;皮肤层是头、手臂、腿,额头和脖子补在头发下,大腿补在裙下,裙下的躯干不编;裙子在手臂和头发下面补齐。Skin 单层胸口带几片肩带和荷叶边是 step03 分割的事(Dress_Bodice → Skin_Torso 1,904 px)。

每组单层:左 一圈一圈(legacy-wrong),右 See-through 合并(step04)(后发、皮肤、裙子、前发)。

补出来的像素是编的,账在 amodal.npz。

分层线稿(step05-lines,dtp 0.5.2,每层只给这一层 + 平底色,manga 提取器,85 s):297 笔,其中编出来区域里的 55(后发身后 39、裙子手臂下 14、眼 1)。每层:Hair_Back 轮廓 16 + AI 239 → 89 · Skin 4 + 192 → 41 · Eye 2 + 8 → 7 · Shoe 3 + 28 → 6 · Dress 10 + 150 → 65 · Ribbon 21 + 39 → 28 · Hair_Front 12 + 214 → 61。线的尺:变号中位 1.4 次/100 px,笔长中位约 290 px@8x。对照 step02(源图上的线):可见的 242 笔里 85% 落在源图线 4 px 内,源图线 244 笔里 96% 被盖住,差异在遮挡边界。第一次(step05-lines-legacy-wrong-1)solo 图带了辉光参考图,参考图深处的黑剪影让提取器把整个人的鬼影轮廓画进每层的线稿(留下的线没事,候选只在这一层的 mask 里取),306 笔、一致率 88% / 98%;改成平底之后数字在噪声内。错的和试验的 step 都挪成 legacy 目录,不占号。

底稿(step06-draft,dtp 1.0.0,第一个用 main.py 开的 step,Krita 52 s):7 组 / 40 节点 / 底色 1,335 笔 / 线 297(编出来区域里的 55);线稿层 Line_Hair · Line_Body · Line_Face · Line_Shoe · Line_Dress · Line_Ribbon。闸门:过(用户「ok」)。

上色(step07-paint-legacy-wrong-1,dtp 1.0.0,计划 51 s,Krita 364 s):每组的做法 Hair_Back generic · Skin tiers · Eye eye · Shoe generic · Dress generic · Ribbon generic · Hair_Front generic(两组头发的 hair 做法没启用:上一轮实验里它没赢过基线)。182,248 笔(sharp 141,110 · shade 20,366 · soft 11,991 · blend 7,164 · strand 1,174 · shadow 405 · deep 34 · light 4),落笔调用 185,822;线 297 → 141(外轮廓 49 · 粗 29 · 细 63 · 太淡不画 156);主体内 1x 模拟 PSNR 24.42 dB;树 57 节点。每组(笔数,1x 模拟 PSNR 前 → 后):Hair_Back 78,593(发丝 492)16.4 → 27.4 · Skin 14,364(档 39,468 px,残差 4,917,硬笔 9,085)14.1 → 23.4 · Eye 2,328 · Shoe 1,018 · Dress 15,002 · Ribbon 18,661 · Hair_Front 40,291(发丝 682);背景辉光 11,991 笔。

人的裁决:不过(用户 2026-09-10「画出来的内容很垃圾」)。看的是同位置对比 cmp/(源图 | 一次成型 | 先分层后上色):整张糊——十四万笔 1.5–3 px 的硬小笔加软笔在 1x 规划、4x 落笔,把源图的明暗复制了一遍,不是画;没有干净的阴影形状(只有皮肤那一档);头发是随机发丝加喷枪;荷叶边被几万个小圆点涂成斑驳的一片;眼眶那条线沿着 SAM 的 mask 走成一圈粗棕线;线只按够不够黑筛掉 156 笔,裙褶、发丝那些淡线全丢,留下的又粗又一样。前面的步骤(分层、补齐、每层的线)给出了结构,上色把结构扔了,回到抄像素。结论进了第 6 章:上色按部位、一部位一页、人在 PAINT 表里定画法。

step08-gauge-legacy-wrong-1/cmp/:左源图,中一次成型(shinku-hug-basic-1),右先分层后上色的基线「抄」。脸:眼眶的粗棕线、软的脖子;荷叶边:斑驳的小圆点。

判据(step08-gauge-legacy-wrong-1,归一到 2508 px,源图为锚):五项全过——落笔 183,583 / 写像素 2 · 梯度 p99 39.1(源图 41.6 的 94%)· 8 级色数 1,633(56%)· 阶梯 0.87(源图 6.19)· likeness 31.03 dB · 内部锐度 p90 5.8(头发 6.8;源图 10.5 / 8.4)· 最暗 (0,0,0)。五项全过、人判不过:五项管的是「像不像人画的」,管不了「是不是画」(第 6 章「审阅与改判」)。

组像素内部锐度 p90源图硬边处 p50源图1x PSNR
Hair_Back69,0334.64.42.912.923.02 dB
Skin110,0291.01.81.012.122.12 dB
Eye2,1418.815.29.320.913.25 dB
Shoe6,9422.92.93.26.617.81 dB
Dress143,8792.76.41.718.822.40 dB
Ribbon13,89611.520.96.332.218.69 dB
Hair_Front95,9448.010.03.819.822.38 dB

硬边处全组都远低于源图(裙子 1.7 对 18.8,丝带 6.3 对 32.2)——软笔抄不出硬边,这是「抄」的指纹。档的边(Shape 环两侧 2 px):Skin 阴影 15.5(源图 29.6)· Eye 虹膜 59.9(66.4)· 暗块 51.2(58.2)· 高光 76.4(57.7)——皮肤的档比源图软一半。图层树 57 节点:规则 ≤ 5 + 10 × 7 = 75 过,每组 ≤ 10 不过(Skin 14:Shape · Flat · Shade · Shadow 子组 3 · Blend · Line 加每个成员的 fill)。线的尺:141 笔 · 变号 2.0 / 100 px · 中位 325 px@8x · 链 117 · 编出来区域里的 11。

历史(v9.0.0,dtp 0.2.0,旧走法「先素描后上色」;work/origin/step23–39 已删):

Criteria读数
node57(通用做法 42;素描版 40)
笔 / 落笔调用 / 写像素216,470 / 300,051 / 2
梯度 p99 · 色数 · 阶梯49.0(118%)· 2818(97%)· 0.90
low-frequency likeness29.98 dB(通用做法 30.22)
内部锐度 p906.2(头发 7.1;通用 6.6 / 7.1)
每组 内部 / 硬边处 / 1x PSNRSkin 1.1 / 1.2 / 20.18 · Eye 8.6 / 9.1 / 14.82 · Hair_Front 8.3 / 4.1 / 21.49 · Hair_Back 4.9 / 3.1 / 22.10 · Dress 2.7 / 1.9 / 22.29
the band edgesSkin 阴影 38.3(源图 29.7)· Eye 虹膜 / 暗块 / 高光 61 / 49 / 79(源图 66 / 58 / 58);眼高光团 3(源图 3,通用 0)
lines232 笔 · 变号 2.2/100 px · 中位 298 px@8x · 158 链(素描版 350 条,成品按浓淡去掉太淡的);候选 轮廓 95 + AI 400 → 293 → 175
Flat colour1,437 笔(这一版还在用笔扫满;换地基后是 0 笔,见下一节)
补齐Hair_Back 8 圈 +253,695 px(可见的 363%)· Skin 8 圈 +260,745(246%)· Dress 2 圈 +8,310(6%)· Ribbon 2 圈 +1,811 · Shoe 0 · Hair_Front 无遮挡;叠回差异 0
固有色头发 (253,224,136) · 眼白 (248,245,244) · 皮肤 (253,241,227) · 裙 (251,251,253) · 丝带三簇 (243,82,138) / (176,6,78) / (249,244,250) · 鞋 (248,246,253)
timesegment 34 s · lineart 35 s · complete 424 s · sketch 75 s · paint 585 s(Krita 422 s)

换地基:一串笔 → 一棵图层树(shinnku-hug-pro/step06-draft + step07-paint,dtp 2.0.0,4x)

同一份输入(step03-segment / step04-complete / step06-draft 的计划一字未改),只换了「画」这件事的表达方式和落笔的路。画法没动——这一轮量的是地基,不是画法。

这一版的笔是什么(重构前的 66,823 笔):笔长不足五个笔半径的 60,926 笔(91.2%)——那是点不是笔;真正顺着形体走的一笔(发丝 + 缕缝)136 笔(0.20%);在「把一块区域填满」的扫线 13,304 笔(19.9%),底稿那 1,341 笔是 100%。矢量层已经填对的形状被笔盖到只剩 < 2.5% 可见。

三条走法换成一个落笔作业之后(probes/probe_bake_equiv.py 按层逐笔对账;同一棵树连跑两遍的噪声底是 54.51 dB):

node逐层逐笔相同final.png
底稿 step0641 = 411341 / 1341 = 100%46.64 dB
上色 step0765 = 6566869 / 68164 = 98.10%43.79 dB
一次成型 basic-1 step02317 = 31780695 / 80695 = 100%54.20 dB

type/clip/blend/opacity/visible 三条走法各 0 处不一致,作画日志的 by_op 计数相同。上色那 1.90% 是一处修好的 bug:老作业只在笔种变了时才换 preset,于是「这一笔用软笔」(soft)只有恰好落在某个笔种连续段第一笔时才生效——1,295 笔平涂带着这个标记却被当硬圆头画了。

换完地基之后(每一列都是当次跑出来的 step):

阶段 3(等价重跑)阶段 4(填色不用笔)阶段 5(色场 + 模糊蒙版)
图层树节点645151
Brushes68,31662,99850,855
description numbers780,588 = 16.54%718,531 = 15.23%595,228 = 12.61%
bare pixel writes——0
梯度 p99(源图 41.6)—42.1(101%)41.7(100%)
8 级色数(源图 2900)—2043(70%)2029(70%)
low-frequency likeness—28.62 dB28.80 dB
内部锐度 p90(源图 10.5 / 头发 8.4)—5.7 / 9.25.4 / 7.6
Krita 落笔264.6 s180.2 s282.8 s
底稿 step0640 节点 / 1,578 笔 / 1.51%25 / 237 / 1.20%同左

阶段 4 删掉档内平涂之后档的边比源图还硬(Hair_Back 的几档:源图 16.8 / 成品 28.8,源图 15.5 / 27.7),头发内部锐度 8.0 → 9.2 越过源图的 8.4——那些笔在偷偷干的事是把边揉软。阶段 5 给档组挂一个高斯模糊 FilterMask 补回来:档边 28.8 → 15.8(源图 16.8),头发内部 9.2 → 7.6。

背景(这一轮省得最狠的一处):

The methoddescription numbers占源图the background area against the source
平涂三遍 + 一遍 soft(11,991 笔)127,9192.71%34.33 dB
16×24 的色场1,1520.02%34.39 dB
32×48 的色场(采用)4,6080.10%37.09 dB
128×192 的色场73,7281.56%39.92 dB

剩下的 12.61% 在哪(这就是下一步要动的地方):

nodedescription numbers占全图
Ribbon_Shade199,37733.5%
Dress_Shade152,49925.6%
Skin_Shade88,12414.8%
Skin_Blend45,9497.7%
Eye_Blend21,7313.7%
头发两组合计49,3358.3%

前五项全是基线「抄」的软笔逐点补差。判据现在直接指着它们说「这里在用点重编码源图」——以前这件事没有数字,只有人看了说不像画。

ir/render.py 不开 Krita 画出来的 1x 预览对真成品 30.32 dB,比老的「一串笔」1x 模拟(29.34 dB)还准;老模拟填的是 amodal 掩码而不是矢量形状,所以它一直和 Krita 画的不是同一张。

头发在新地基上重做(shinnku-hug-pro/step07-paint,dtp 2.1.0,4x)

只改了头发的画法,别的组一字未动。换掉的是「底」是什么:以前一缕是一个固有色加几档量化出来的硬边影,现在一缕是一根圆柱、两条渐变(顺轴绝对色 + 横切乗算)。

一缕几个平色一缕一根圆柱
两组头发的描述数49,335(全图的 8.3%)26,319(4.6%)
头发的笔约 5,300210
头发区残差(对源图,1x,越小越像)17.4313.05
明显偏粉的像素(源图 56.4%)34.3%53.7%
组内 8 级色数(源图 123)11101
全图描述数12.61%12.04%
Krita 落笔180 s206 s

为什么定在「一缕一根圆柱」。几种底色的表达都量过(92 缕,只算颜色的数,不含轮廓):

底色怎么写description numbers残差
每组一个固有色620.58
每缕一个固有色13817.49
每组一条线性渐变5618.34
每缕横切一条渐变(5 stop)2,20813.01
每缕横切 + 顺轴两条4,41611.56
每缕切 4 条带 + 模糊色 1,101 + 多三圈轮廓12.92

条带比渐变贵三倍:每多一档就多一圈轮廓(一缕的轮廓约 100 个数),而渐变把整个剖面压进二十个数。

平滑模型的地板在 12 附近:把源图自己按 σ=8 模糊,残差也只有 12.07;每缕拟一个二维三次曲面(30 个数一缕)是 11.92。所以每缕两条渐变(11.56)已经贴到地板,剩下的差额是发丝那层细纹理——那是几十根笔的活。误差驱动的残差笔(一组三四百笔)因此删掉。

缕数不是越多越好(同样两条渐变):

分缕缕轮廓 + 渐变残差
粗468,63212.91
中6710,86812.03
细9213,12811.56

还剩两处。一是分缕在细缕密排的地方把几条真发缕并成一条,横切一平均就全成了一个颜色;参数扫过,残差只小幅改善。二是上游:分割那一步的 subject 掩膜在左侧有洞,丢掉的发丝约相当于头发面积的 15%,画法只能画掩膜说有头发的地方。

两条走法并排

同一张图、同一份判据(step08-gauge-legacy-wrong-1 的 REF_FINAL 指向 shinku-hug-basic-1/phase3-paint/step02-trace/krita/final.png,同位置对比 cmp/ 13 张):

Criteria一次成型(basic-1 step02-trace)先分层后上色(pro step07-paint-legacy-wrong-1,基线「抄」)
gradient p9937.7(91%)39.1(94%)
8 级色数1,877(65%)1,633(56%)
阶梯指纹0.810.87
low-frequency likeness34.79 dB31.03 dB
内部锐度 p909.5(头发 10.8)5.8(头发 6.8)
Layers15 个部件组 + 73 个档,档没有名字7 组 57 节点,档有名字(Skin_Shadow、Eye 四样),每层完整
人的裁决过(这条走法的固有限制:脖子归头发、裙下腿断、档没名字)不过:抄像素

像的那条图层不是人要的;图层是人要的那条,上色是抄的——两边差的都是「画法」:一次成型没有画法(按亮度切档往上堆点),先分层后上色的基线也没有(软笔复刻)。出路是第 6 章:一部位一个画法,人定。

左源图,中一次成型,右先分层后上色的基线。腿:一次成型的裙下腿是断的、这条路是完整的(补齐),但两边的皮肤都没有画师要的那一档影。

历史(旧走法先素描后上色,v6-* 图):一次成型 34.8 / 9.5(头发 11.0)vs 先素描后上色 29.98 / 6.2(7.1)。

v9 的三个部件实验(历史,v9.0.0;step 目录已删)

第 6 章的三个做法是在这张图上做出来的;变体表留在这里。

皮肤(基线通用做法;Skin 内部 p90 源图 1.5,硬边处 8.3,档的边 30.0):

变体内部 p90硬边处1x PSNRthe band edges全图 likeness
A 通用1.32.721.3531.330.40
B 只有档0.00.519.9937.729.85
C 档 + 残差(沉库)1.01.321.1133.030.30

眼睛(源图:内部 15.0,硬边处 21.6,虹膜 / 暗块 / 高光边 66 / 58 / 58,高光团 3):

变体内部 p90硬边处1x PSNR三条边高光团
通用11.88.315.3358 / 46 / 600
E1 四样、没有残差(赛璐璐眼)28.93.814.0074 / 56 / 843
E2 四样 + 残差8.78.115.3556 / 43 / 510
E2′ 残差避开高光 5 px(沉库)8.69.114.8261 / 49 / 793

头发(源图头发内部 8.4;Hair_Front 内部 / 硬边处 10.1 / 19.8,Hair_Back 4.4 / 12.9):

变体头发内部 p90Hair_FrontHair_Back1x PSNR 前 / 后likenessBrushes
通用7.18.4 / 4.04.9 / 3.221.48 / 22.1030.22219k
H2 缕 + 定向发丝,去掉硬笔10.812.7 / 3.46.2 / 1.719.41 / 20.2229.06100k
H2s = H2 + 硬笔10.012.0 / 4.26.0 / 3.020.79 / 21.2929.91220k
H2s 高光带更淡更少10.012.0 / 4.26.0 / 3.020.79 / 21.2929.91220k

1x 模拟上头发的 PSNR:通用 27.3 / 25.3,去掉硬笔 23.6 / 21.6,加回 28.0 / 26.2——模拟和 Krita 反着来(第 6 章)。

历史图(v9):皮肤一档阴影的脖影特写;眼睛四样;顶帽亮脊聚出来的缕。

这一章的图

assets/ 里 v10-* 来自 shinku-hug-basic-1,v11-* 来自 shinnku-hug-pro,v13-* 是 shinnku-hug-pro/phase3-paint/step08-gauge-legacy-wrong-1/cmp/ 的同位置对比(脸、腿、荷叶边缩了一半);v6-*、v7-*、v9-* 是历史版本的审阅图,对应的 step 目录已删,留着看形状不看数字。

Appendix A: how to run it

The code is dtp/ one directory up (a uv project, import dtp) – a library, with no command line. One picture, one route, one subproject at work/<name>/; one stage per stepNN-<stage>/, with one main.py in it. The conventions are all in AGENTS.md one level up.

One main.py per step

mkdir -p work/pic/phase1-layers/step03-segment
cp dtp/templates/segment/main.py work/pic/phase1-layers/step03-segment/       # eight templates: segment lineart complete lines draft paint gauge trace
$EDITOR work/pic/phase1-layers/step03-segment/main.py                          # edit the two blocks of constants at the top: upstream and knobs
uv run python work/pic/phase1-layers/step03-segment/main.py

Every template’s head is the same:

HERE = Path(__file__).resolve().parent          # this step
ROOT = HERE.parents[1]                          # work/<name>
SRC = ROOT / "input/origin.png"
cfg = table.load(ROOT / "parts.py")
# ---- upstream (edit these; None where there is none)
SEG = ROOT / "phase1-layers" / "step01-segment"
# ---- knobs
SCALE = 4

main() is this stage’s wiring: read the upstream artifacts (dtp.io), call the library’s pure functions, write the artifacts, and write the table of numbers with io.write_readme(). What there is not: no “take the latest upstream by default” (the upstream is a constant), no --k=v (the knobs are constants), no step numberer (a person runs mkdir), and no “retire” (a person runs mv ... -legacy-wrong-1).

The routes: layered is segment -> lineart -> segment (with lines as walls) -> complete -> lines -> draft (which a person looks at) -> paint -> gauge; one-shot is segment -> trace. Each step is in “Ten minutes”.

The environment scripts, dtp/tools/

uv run python dtp/tools/setup_harness.py         build the Krita harness (once; changing the library needs no rerun)
uv run python dtp/tools/selfcheck.py             31 self-checks against a real Krita
uv run python dtp/tools/comfy.py                 start ComfyUI (127.0.0.1:8188); stop it with fuser -k 8188/tcp
uv run python dtp/tools/probe.py [name]          run one capability probe; with no name, list them all (appendix C)
uv run python dtp/tools/gauge.py <final.png> [--src source]  measure one picture offhand (normalized to 2508px, anchored to the source)
uv run python dtp/tools/gui.py <step dir>        replay that step's Krita job on a real desktop (it reads the step's job.json)
uv run pytest                                    the library's unit tests

The constants at the top of each main.py

Two blocks: upstream (which steps this one reads – paths relative to the subproject root, None where there is none) and knobs. The main text describes what each one does; these are their names.

stageupstreamknobs
segmentWALLS (a lineart step, whose kept lines become walls), ASSIGN_FROM (a segment step whose ASSIGN is inherited)SCALE, ALPHA_MIN, VOTE (vote_thr, split_below, grow_tol, grow_sep, grow_seed, grow_dist), SMOOTH, EPS, EXPAND
lineartSEG, OVERRIDESKINDS, SOURCE, CT, AIK, BOX
completeSEG, SEETHROUGH_FROM (a step whose model output is reused; None runs the model fresh)ST (resolution, steps, seed, group_offload), MERGE (margin, smooth, feather, black_lum, shape_expand, eps, ct_smooth)
linesSEG, COMP, LINEART (comparison only; None skips it), OVERRIDESSOURCE, CT, AIK, DROP_MADEUP, DROP_MADEUP_LEN
draftSEG, COMP, LINESSCALE, SHAPE (smooth, eps, expand, fit_err, fit_sigma), SIL_EXPAND, JOB (bg, sil), RUN_JOB
paintSEG, COMP, DRAFTSCALE, T_SOFT, LINE_DROP, CHECKPOINTS, RUN_JOB
gaugePAINT (a paint or trace step), SEG, REF_PAINT, REF_FINALDIST
traceSEGSCALE, PLAN (expand, smooth, eps, detail_t), RUN_JOB, GAUGE_AFTER

The knobs are constants at the top of main.py (each template lists what used to be run()’s keyword arguments one per constant, with comments); change them and run again and that is a new run, with this run’s parameters recorded at the head of its README. Each step’s table of knobs is in its own chapter: segment (ch. 1), lineart (ch. 2), completion (ch. 3), per-layer lineart (ch. 4), the draft (ch. 5), painting (ch. 6), the criteria (ch. 7) and one-shot (ch. 8). A knob needs evidence that it took effect – once, not one knob in the whole chain ever reached the code (ch. 17, case 38); now a knob is in the very file being run, and the README holds its value.

Machine-level settings live in dtp.toml (optional) or DTP_*: comfy_dir, comfy_url, harness_home and seethrough_dir. Apart from dtp.settings and the Krita process boundary (dtp.jobs.run reading DTP_JOB), the library reads no environment variables; the multiplier and the seed are constants in each main.py.

Experiments

Trying an idea without changing the library: open a step and write its main.py however you like. The conventions: copy the plan files from a baseline step, change the plan for one group only, use RUN_JOB = False for the 1x simulated preview alone (which takes seconds), then RUN_JOB = True for a full painting, and finally a gauge step whose PAINT points at it and whose REF_PAINT points at the baseline, comparing on the same rulers. When it works it sinks into dtp/ as a pure function with tests, and the main.py stays in the step as the record (rule 3 of AGENTS.md).

Running a long job in the background

The last section of “Ten minutes”: in the background, with absolute paths, logging to disk; kill by PID, by port or with pkill -x, never pkill -f.

Appendix B: the code index

The code lives in dtp/, sitting beside this handbook one directory up with its own git. Since 4.0.0 it is one distribution per rank under packages/, all contributing into the one dtp. namespace (PEP 420), so an import path such as dtp.craft.copy says nothing about which package ships it. The library is atomic: pure functions take arrays, dicts, a parts table or the path of a single file, and return values; io.py is a thin layer of helpers for the fixed-format artifacts alone; and the library knows nothing about step directories, orchestrates no stages and has no command line – what wires it together is the main.py inside each step (templates in templates/). The library knows no particular picture.

The directories are five ranks, divided by who decides what, and the dependency rule is enforced by tests/test_layering.py, which walks the AST of every module (including imports inside function bodies): a rank may import downward and never upward.

layerrankpackagedistributionwhat it decides
atoms0geometry/ raster/ ir/ · io.py settings.py config.py vocab.pydtp-corepure mathematics, pure raster, and the layer tree’s schema. It knows neither Krita nor parts
capability1ops/ (the only place that imports krita)dtp-kritawhat Krita can do
reading1read/dtp-readwhat can be read out of a picture
the vocabulary of painting2craft/dtp-craftan artist’s actions (filling, banding, copying, hatching, strands, 色トレス), with aesthetic constants and no part names
part3paint/dtp-painthow hair is painted, how skin is painted
wiring the stages4stages/dtp-stageswhat a stage calls, producing the IR and the account
the rulers (cross-cutting)5gauge/dtp-gaugemeasuring. No rank imports it

Each package declares which other dtp-* packages it depends on, and test_layering.py holds that declaration against the edges its AST pass actually found. The rank rule is therefore written down twice, in two places that cannot drift apart in silence.

One stage, which modules

The main text names no module – it describes what a step does and sends you here. This is the way back in: one row per stage, in the order the routes run them.

stagech.what it calls
segment1read/segment.py – the boundary barrier and the regions, the hand-written table, the majority vote and growing from a seed, lines as walls, and the sub-pixel rings; the isolines are geometry/contour.py, the rings geometry/rings.py
lineart2read/lineart.py – source one (the contour candidates), source two (the AI candidates, with the crossing number, tracing and joining at junctions), then scoring, resolving overlaps, applying the overrules, the arbitration and the review image; thinning is geometry/skeleton.py, fitting geometry/fit.py; the ComfyUI driver is read/comfy.py
complete3read/seethrough.py (running the model in its own venv, and the merge rules) and read/complete.py (the occluded area, the account, and the restack test); the groups’ masks are read/groups.py
lines4read/lines.py – this layer’s image alone, then ch. 2’s candidate pool and arbitration reused, plus the invented-line account
draft5stages/draft.py for each group’s rings and local colour, read/tiers.py for the local colour itself, stages/tree.py to turn the plan into a layer tree, ops/layers.py for the tree’s building blocks, and jobs/bake.py to paint it
paint6stages/paint.py for the step, paint/ for one method per part (dispatched through the registry in paint/__init__.py), craft/ for the actions a method is built from, craft/color_trace.py for how heavy a line is, raster/sim.py for the geometry of a stroke, ops/brushes.py for the brush table, and jobs/bake.py to paint it
gauge7gauge/report.py for the criteria and the per-group rulers, gauge/lines.py for the ruler on the lines, gauge/compare.py for the matching-position comparison, and ir/count.py for the description count
trace8stages/trace.py reads the picture, read/tiers.py gives the value bands, and jobs/bake.py paints it

Each stage is wired by the main.py in its own step directory (appendix A); none of this is reachable from a command line.

jobs/ is the Krita process’s entry point and may import only ops, ir and gauge (the last only to write the review images), and it ships together with ops in dtp-krita, one capability in one process – it knows no painting method, so what the tree looks like cannot possibly be hiding inside a job.

dtp/
  ruff.toml                 the style shared by every package: 100 columns, E W F N D, Google docstrings
  packages/                 one distribution per rank, each with its own pyproject.toml and its own src/dtp tree
    dtp/                    the umbrella: no code, depends on the seven below, so `dtp` still installs the whole library
    core/ read/ craft/ paint/ stages/ gauge/ krita/
  tests/                    87 pytest items. Four guardrails: test_layering.py (the rank rules, jobs naming only what exists, pyflakes), test_style.py (ruff, English throughout, every why/cite resolving),
                            test_frozen.py (four methods over a toy input, compared by sha256 -- a pure move may not change one bit), test_ir.py (the layer tree's schema, counting and rendering).
                            test_layering.py also holds each package's declared dependencies against its actual imports
  probes/                   capability probes (appendix C)
  templates/                project/parts.py, the parts-table template; <stage>/main.py, eight step templates; not in the wheel
  tools/                    setup_harness.py, selfcheck.py, comfy.py, probe.py, gauge.py, gui.py (appendix A)
  packages/<dist>/src/dtp/       (no __init__.py at this level: the packages share one namespace)
    io.py                   reading and writing the fixed-format artifacts: load/save_json, load/save_strokes (STROKE_KEYS), load_rgb/rgba/mask, save_png, load/save_plan, load/save_amodal, md_table, write_readme (which keeps the human's "## Verdict")
    settings.py             machine-level settings (dtp.toml / DTP_*) -- the one place in the library that reads the environment
    vocab.py                strings two ranks both have to say the same way (a review image's legend, a table header, the bands' role names), so that no two places keep their own copy
    config.py               the parts table: a parts.py path to a module; check_parts(methods=, tags=) has the methods and tags it knows **injected by the caller**, so config never imports upward
    geometry/               L0, pure geometry
      contour.py            sub-pixel alpha, SDF, isolines (trace(forbid=) never covers a lower layer), RDP, Catmull-Rom to SVG, rasterization
      fit.py                Schneider least-squares cubic beziers, curvature sign changes, breaking at corners, bridging gaps
      skeleton.py           thinning, walking lines, RDP, sampling a line's width and colour, band_path() for the longest geodesic along a band
      flow.py               a double-angle harmonic flow field, anisotropic partitioning along it, evenly spaced streamlines (Jobard-Lefer)
      rings.py              vectorize() / rings_json() / fit_rings(): mask to rings to bezier rings (one ruler shared by lineart, bands and shapes)
    raster/                 L0, pure raster
      sim.py                Sim, the simulated canvas (paint it once while planning to see how far off it is), plus kernels, normalized blur, flow fields and seed growth
      mask.py               tidy_mask / tidy_labels (dust **goes to its neighbour** rather than being deleted) / clean_mask / seam_mask / nblur
      color.py              lab() / psnr()
    ir/                     L0, the layer tree -- the intermediate form "painting" takes, knowing neither Krita nor parts
      schema.py             IR_VERSION, KINDS, the required fields, the defaults, the 32 blending modes measured to work, walk(), validate()
      build.py              doc() shape() group() fill() gradient() strokes() mask() filter_node() raster()
      count.py              **the description count**: node_cost() / describe_cost() / honesty() -- ch. 7's criterion
      render.py             draw the tree without opening Krita (a 1x preview, with clipping, blending modes, blur and masks)
      svg.py                rings to SVG (what a fill node needs to land on a vector layer)
    ops/                    L1, the capability rank and **the only package that imports krita**
      session.py            Session: building trees, addressing, changing brushes, painting, reading and writing pixels, selections, filters, saving, the painting journal, note(), replay, manifest()
      atoms.py              capability atoms with no aesthetic constants: gradient_pixels / gradient / selection_from_mask / transparency_mask / filter_mask / blur_mask / transform
      bake.py               **one tree to one Krita document**: build() builds it, paint() paints it, bake() keeps the account. This is the only seam between "painting" and Krita
      layers.py             three building blocks: vector layer, group, paint layer
      brushes.py            the brush table KIND: kind to (preset, width factor, layer, taper), and the only one
      harness.py            setup(): a separate HOME, the autopaint extension and run_job.sh; run_job(): start Krita and run jobs.<x>.job(**args)
    read/                   L1, reading the picture
      segment.py            1, segment: the boundary barrier, the hand-written table, lines as walls, the SAM majority vote and seed growth, review, vectorization
      lineart.py            2, lineart: candidates, scoring, deduplication, arbitration
      lines.py              4, per-layer lineart: this layer's image alone, candidates, arbitration, the made_up account
      complete.py           3, completion: the upper area intersected with a See-through layer minus what is visible, to rings, to an account; plus the restack test
      seethrough.py         3, See-through itself: starting its venv, mapping its square canvas back to 1x, the merge rules, the seam
      groups.py             groups: each one's visible mask, whole mask, colour clusters and mutually exclusive mask
      tiers.py              value bands: pick_k / levels / flat_color / lab / quantize / hsv_check / split_tiers
      locks.py              hair's locks: bright ridges and dark seams, partitioning along the flow, each lock's principal axis, the seams, each lock's own bands, the 天使の輪 (halo_cut draws the whole band and then cuts it)
      comfy.py              Comfy(url, dir): raster lineart and SAM masks
    craft/                  L2, the vocabulary of painting (an artist's actions, with aesthetic constants and no part names)
      fill.py               wash() to fill by scanning -- only the background still uses it (the in-band filling pass was deleted in stage 4: the vector layer had already filled the same area)
      cylinder.py           **the cylinder**: a form with a direction becomes an absolute-colour gradient along its axis plus a multiply gradient across it (a lock of hair, a finger, a ribbon)
      tone.py               bands: tier_entries() / one_tier()
      copy.py               the copy-it baseline: soft() / detail() / passes_for() / soft_passes()
      texture.py            hatch() for hatching, strands() for strands
      hair_marks.py         seams() between locks, tips() at the ends, stray() hairs
      color_trace.py        color_lines() for 色トレス (a line's weight from its own layer's image) and contour_lines()
    paint/                  L3, the parts: one method per part in one module, all with the same plan() signature
      __init__.py           the METHODS registry, PLACEHOLDERS, and the plan_group() dispatcher
      generic.py            the copy-it baseline, which is not a way of painting: error-driven soft and hard brushes; the default for "*", and what every part's method has to beat
      skin.py hair.py eye.py background.py                skin 🧪 / hair 🧪 (one cylinder per lock, producing an IR subtree) / eyes 🧪 / background (the colour field)
      _ref.py               target(): the reference image being copied
      stocking cloth shoe metal building tree sky water   ⬜ placeholders: the docstring is how an artist paints it plus what has to be decided, and plan() raises with the handbook page's name
    stages/                 L4, wiring the stages together, all pure functions
      draft.py              5, the draft: plan_groups() for each group's rings and local colour, check_orphans(), plan_json(), preview()
      paint.py              6, painting: each group by the PAINT table, plus line weights and the background field, giving strokes, each group's account and the 1x simulation
      trace.py              8, one-shot: value bands to rings to strokes to the 1x simulation
      tree.py               **a plan to a layer tree**: from_draft / from_plan / from_trace plus prune() and graft() (attaching the subtree a method produced); brush_of() decides which brush a stroke uses
      table.py              load(): injects METHODS and TAGS into config.load_parts, so that config never imports upward
    gauge/                  the rulers, cross-cutting, imported by no rank
      report.py             7, the criteria: report() for the five plus interior sharpness, the painting journal and the description count; group_metrics() (per group: interior sharpness, at the hard edges, the **8-level colour count**, 1x PSNR); tier_edges(); tree_rule()
      lines.py shape.py compare.py review.py              the ruler on lines / edge quality / matching-position comparison / review images
    jobs/                   imported only inside Krita; its arguments come from DTP_JOB as JSON
      run.py                the shell the autopaint extension execs: import dtp.jobs.<x>, job(**args)
      bake.py               **the only painting job**: read tree.json, build the tree, paint it, write the review images (shared by all three routes)
      selfcheck.py probe.py 31 self-checks; running one probe script

What runs where

runs inmodules
plain Python (uv run python .../main.py, one process)geometry/, raster/, ir/, read/, craft/, paint/, stages/, gauge/, io.py and config.py; seethrough.run_model() starts a separate process in its own venv
Krita (krita-bot/run_job.sh -> xvfb-run krita -> the autopaint extension execs jobs/run.py)jobs/*, ops/* and probes/*

ir/render.py is the most useful thing on that boundary: it draws the whole tree in plain Python (at 1x, in twenty-odd seconds), so changing a method does not mean waiting three minutes for Krita every time. It is not a substitute for Krita – a brush’s texture, the antialiasing and the blending modes are all approximations – but measured, it is closer to the real output than the old “1x simulation of a list of strokes” was (the readings are in ch. 19). The numbers have to come from real painting: the criteria always measure what Krita actually painted (ch. 6).

Krita embeds the system Python; run_job.sh puts every package’s src on PYTHONPATH (colon-separated, passed in at run time as $DTP_SRC, so adding a package needs no re-setup either), so what a job imports is the library’s own source and changing the library needs no setup. A job has no __file__ (it is exec-ed), and from dtp.ops.session import * only works at module level, which is why the painting code lives in its own jobs/*.py. Starting Krita from inside a uv venv requires stripping VIRTUAL_ENV, PYTHONHOME and .venv/bin (which run_job() does), or Krita’s plugin system fails to start.

Session at a glance

s = Session(w, h, "name", journal=True, out_dir=...)
s.build(spec) / s.find(path) / s.use(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, why=…) / s.ground(r,g,b)
s.note(op, **fields)                      # append an entry to the painting journal (bake uses it to record how many numbers a node cost)
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()

Appendix C: the probes

Each probe answers one question of the form “does this actually work in headless Krita”, and its conclusion goes into the main text. The main text does not name them – it states the conclusion and points here; the last column is the way back, naming the chapter each probe’s conclusion is used in. They all live in probes/; how to run them is in appendix A.

probequestionconclusionchapter
probe_stroke_budget.pyHow many strokes a second at 8K? Does changing colour or brush size need a round of the event loop?paintLine runs at 450 to 850 a second regardless of size; changing colour without the event loop works (24 out of 24), and with it drops to 71 a second12, 18
probe_throughput_tree.pyWhy is it slow on a real layer tree, and why does it get slower as it goes?the tree costs only 7% more; path() is 2.3 times faster than paintLine segment by segment; setBatchmode does nothing. “233 KB of undo stack per call” was this probe’s own measuring mistake, overturned by probe_checkpoint.py12, 18
probe_taper.pyWhich of 36 presets taper, and which have clean edges?the mypaint engine reads aliased near 0 and b) Basic-* 88 to 95%; Pencil 1 Sketch tapers to 0.28 but is the softest at W = 3.2512
probe_markshape.pyIs a dab round or square?Bristles-5 Flat has an aspect ratio of 2.52 and is a flat brush; mypaint cannot paint a 1 px dab at all12, 17
probe_glaze.pyWhat units does setPaintingOpacity take? What is the build-up curve? What are the width factors?it takes 0 to 1 and saturates at 255; the build-up follows 1-(1-a)^N; the factors are Airbrush 0.93, Bristles-5 0.44 and Pencil 1 Sketch 0.2012
probe_fill.pyWhich approach fills an area with a smooth edge at 8K?only a vector layer (0.083); select_mask reads 0.331 and paintPolygon is binary at 0.49812
probe_clipvector.pyCan a paint layer be clipped to a vector layer?it can, and the fractional alpha passes straight through (3 equals 1 at 0.0828)13
probe_vecstack.pyCan a group be clipped to a vector layer? How long do 15 vector layers take?it can, clipping the whole group’s composite (7,147,891, which is the silhouette); 12.3 s; a 13.8 MB .kra13
probe_vector.pyAre vector layers dependable at 8K?5 of 5: they reach the composite, beziers render, coordinates are 1:1 with zero offset, the .kra round trip works, and one shape spans 7700 px13
probe_clipping.pyDoes inherit alpha actually work?it works inside a group, clipping to the accumulated alpha; passthrough breaks it; and alpha has to be looked at13
probe_layer_order.pyWhere does addChildNode(child, None) put it?at the top; childNodes() returns bottom to top13
probe_bigcanvas.pyDoes 8192x12288 work at all?creating the document 2.3 s, reading the whole canvas 3.7 s, peak RSS 4.9 GB, and the .kra saves and opens normally18
probe_filtermask.pyDoes a filter with a selection work?apply gives a 7.2 px transition band (the floor), while FilterMask(Selection) gives 59.9 px and is non-destructive; the band is about 1.5 times the feather; and the Node overload confines nothing14
probe_colorize.pyCan ColorizeMask produce flats headless?it can: three regions matching at 0.98, 12.3 s, RSS up 1.1 GB. But its edges are binary (0.4981). initializeKeyStrokeColors has to be called first15
probe_preset_xml.pyCan a brush be made programmatically, and can the table of width factors be got rid of?toXML and fromXML work and write nothing to disk; changing diameter works on an auto brush and does nothing on a predefined tip; the factors cannot be got rid of; and a mypaint round trip is lossy by a factor of 3112
probe_scratchpad.pyCan an offscreen canvas get round the undo stack?No – there is no painting API. But it is the only way to a gradient’s or a pattern’s rasterized result, and moving that back into the document is lossless15
probe_delivery.pyWhat else can a .kra carry?a SelectionMask, a FileLayer and a CloneLayer all survive the round trip; a Palette cannot be saved; and a layer style cannot be fed in15, 16
probe_shape_edit.pyCan a vector layer still be edited after it is built?it can: setZIndex, setTransformation, createGroupShape and setAntialiased (0.2624 against 0.0891) all work. The units are points. Node transforms and a TransformMask hold up too13
probe_checkpoint.pyWhat actually eats the memory? Does save, close and reopen give it back?the same 12,000 strokes: 0.06 GB in fixed areas and 4.21 GB scattered over 8K – what grows is the tiles, not the undo stack; the round trip is faithful but returns only about 1 GB18
probe_longstroke.pyPoints are free in one paintPath call, so how should a stroke be painted?one contour is one paintPath(cubicTo) and the number of points does not affect throughput; paintLine only draws polylines12
probe_gradient_layer.pyCan headless Krita produce a gradient?a generated layer can be built but not one parameter takes effect (all five shapes look the same, the foreground and background colours do nothing, and start_position_x stays 0 after being set); there are only six keys and none of them is a stop’s colour; so the pixels have to be computed. Warning: the seexpr generator core dumps15, 16
probe_filtermask_stack.pyStacking N FilterMasks, is compositing O(n) or O(n^2)?24 on one layer is O(n^2) (1.6 s each); one each on 24 layers is O(n) (0.08 s each). The rule: at most one per layer. All 24 survive a .kra round trip14, 15
probe_bake_equiv.pyAfter changing the painting job, are the strokes still the same ones? (A reconciliation tool that never opens Krita)compare two real runs’ painting journals, aligning stroke by stroke per layer – which layer is painted first does not affect the output, so a global order that does not match is normal17, 19

Rules for writing a probe

  1. One probe, one question, and the docstring’s first line is that question
  2. the criterion goes in the docstring, including what counts as working and what it is anchored to
  3. read pixels with s.stable() or s.settle(); concluding from a single read gives the opposite answer
  4. match the probe’s geometry to the real use (a long curve against a short segment)
  5. measure inside the real structure (in a group, at 8K); a single-layer probe misses how a group composites
  6. print how to read it: [verdict] 3 clearly below 4 means ..., so that someone without the context can read the conclusion
  7. a criterion cannot be coarser than what it measures: measuring “is this edge antialiased” with a binary classifier always answers “it is binary” – which measures the classifier (probe_colorize was caught by this)
  8. a criterion needs a floor too: work out what an ideal step reads under it first (7.2 px for probe_filtermask), or “it did nothing” cannot be told from “it worked and is narrow”
  9. do not judge a translation by the centroid: a box smaller than the object cuts it off, and a group’s projection leaves a ghost. Use the object’s own boundingBox(), or measure the deviation against an analytically translated ground truth (probe_shape_edit was caught by this twice)

Appendix D: glossary

termmeaning
study (临摹)repainting from the source as a reference. Judged by whether it looks like a person painted it and whether it resembles the source at low frequencies, not by matching pixel for pixel
replica (复刻)reconstructing the source pixel for pixel. The old route’s goal, judged by PSNR
1x / 4x / 8xthe source’s resolution (1024x1536), the default delivery resolution (4096x6144), and 8K (8192x12288, more than twice as slow and an option only). The planner works at 1x and painting happens at 4x; a stroke length quoted “@8x” in this book is a historical figure
sub-part / parta piece segmentation produced: a face, a torso, a skirt, a frill. One row of the hand-written ASSIGN table, and what the one-shot route builds its groups from
groupthe layered route’s unit of layering: the union of some sub-parts (GROUPS), each becoming one group in the .kra – a Shape, a flat colour, shading, named bands and lines
route (走法)one path through the steps: one-shot (segment -> trace) or layered (segment -> lineart -> complete -> lines -> draft -> paint). One picture, one route, one subproject
worked examplech. 19: one real picture’s complete record and numbers down both routes. The main text never quotes it
subproject / stepwork/<name>/: the picture, the parts table, and one stepNN-<stage>/ per stage; steps pass files to each other through directories and nothing else
the hand-written ASSIGN tablea table of region number to sub-part, written from the region-number map; the one-shot route uses only this
growing from a seedafter the vote, where one class’s mask encloses a small, clearly differently coloured patch inside a region another class won, it grows outward from that patch by colour, within a distance limit
part / methodpainting is divided by part – hair, skin, eyes, stockings, clothing – with one page and one module per part (dtp/paint/), and a person deciding each layer’s in the PAINT table (ch. 6). An unstudied part is a placeholder (⬜): the skeleton, how an artist paints it, what has to be decided, and an empty module in the code
the copy-it baseline, genericerror-driven soft and hard brushes reproducing the source’s light and shade inside a group. It produces a complete picture and is not a way of painting; it is kept only as a ruler, and each part’s method has to beat it
band / tier (档)a layer cut out of a part by luminance or by rule: the one-shot route’s Flat / Shadow1 / Light1 (by luminance, with no names), and the layered route’s Skin_Shadow / Eye_Light (named, a vector Shape plus a flat fill)
value band / celthe bands a part is split into by luminance: Flat / Shadow1 / Shadow2 / Light1, nested inside one another
cellthe joint (part, band) label, pid x 100 + tier. The area a stroke may move in
alpha / sub-pixel coveragewhat fraction of a boundary pixel belongs to a part, solved from the antialiased edge
SDFa signed distance field, positive inside and negative outside, in pixels. The scalar field vectorization rests on
isolinethe curve where a scalar field takes a given value. The zero isoline is the boundary
RDPRamer-Douglas-Peucker polyline simplification, with tolerance eps
Catmull-Roma spline through every control point, converted to cubic beziers for SVG
clip / inherit alphaKrita’s setInheritAlpha(True): clipping to the accumulated alpha of the layers below it in the same group
passthrougha group’s passthrough mode. On, clipping stops working; off, a multiply inside the group does not multiply against what is below the group
色トレス (colour trace)coloured lineart: a line’s colour is sampled from the colour beside it and darkened, rather than being black
cel shading (赛璐璐)the animation style of distinct value bands with hard edges
soft painterlythis book’s paint style: an airbrush plus a semi-transparent detail brush, with a gouache feel
wash / flat fillscanning an area full at a fixed spacing without regard to error. Used only on a pure gradient
tapera stroke thin at both ends and thick in the middle, from a pressure of 0 to 1 to 0
gradient p99the 99th percentile of the luminance gradient’s magnitude: how sharp the edges are. It changes with resolution
the 8 px stair fingerprintthe periodic signal a 1x pixel grid leaves when enlarged eight times. High if anything was upscaled
K90the 90th percentile of the edge’s level-set curvature, a direct measure of per-pixel jaggedness. Analytic antialiasing reads about 0.04 to 0.06
Wthe edge’s 10-to-90% transition width in px. Small is sharp, large is soft
aliased%the fraction of edge pixels with W below 1 (a hard step)
emptya measure of empty resolution, in dB. It points the wrong way on an upscaling target and has to be paired with edge_ratio
edge_ratiothe fraction of high-frequency energy falling within 3 px of a gradient ridge. It drops to 0.6 when noise is cheating
low-frequency likenessthe PSNR after reducing both sides to 256 wide
normalizationresizing to the same physical width (2508) before measuring any whole-picture indicator
the source anchora criterion’s threshold is written as a percentage of the source’s own reading
measuring the wrong thingthe class of mistake where a measurement raises nothing and its conclusion points the wrong way. Ch. 16
harnessthe Krita runtime with its own HOME (krita-bot/)
pumprunning a few rounds of Qt’s event loop. Needed before reading pixels; not needed when changing colour or brush size (a tax of 8.5x)
the undo stackKrita stores one per paint* and has its own limit. Memory growth was once wrongly attributed to it; what actually grows is the tiles (below)
tilea Krita layer’s 64x64 pixel block. Wherever a stroke lands, that block goes from empty to dense; the budget is the number of layers times the area painted on each
amodala part’s whole shape, including what the layers above hide. “The kettle is occluded, and the kettle itself is still a whole layer”
the majority votesegmentation’s regions are assigned by how much each SAM mask covers them, ties going by priority; it corrects the hand-written ASSIGN
the candidate pool / support / flipsarbitrating the lines: both contours and model lines are candidates; support is the fraction of samples along a line where the source really has an edge, flips is curvature sign changes per 100 px, and score is support minus 0.05 times flips
Schneider fittingleast-squares cubic beziers (Graphics Gems’ FitCurve): approximation rather than interpolation, splitting only when the maximum error exceeds the tolerance
crossing numberhow many 0-to-1 transitions there are going round a skeleton pixel’s eight neighbours: 1 is an endpoint, 2 the middle, 3 or more a junction. Counting neighbours instead takes a stair on a diagonal for a junction
the draft / the finished versionthe layered route’s two deliverables: a .kra of each layer’s shape, its flat colour and its lines for a person to review; and once that passes, the shading, the bands, the strands and the background added. Sketch version is the draft’s old name, and it is not a sketch
per-layer lineartonce the layers exist, the lineart extracted from each layer alone on a ground and arbitrated against that layer’s own image; the lines step
made_upthe fraction of a line’s samples falling in the invented region; a line at 0.5 or above was extracted from the completed image and sits under the layers above in the finished painting
lines as wallssegmentation’s second pass (WALLS pointing at the lineart step): the lines arbitrated on the source are rasterized into the boundary barrier and the regions split along them; the labels are inherited from the old table and correcting them is left to the vote
gate (闸门)each step ends by waiting for a person to look at the review images; overrides.json and parts.py are where they overrule it
interior sharpnessthe gradient p90 more than 30 px from the outline. p99 is propped up by the outlines and cannot see a blurry interior
likenessthe PSNR after reducing both sides to 256 wide, resembling only at the low-frequency scale. A study reads at least 30 dB
SAM / GroundingDINOtext-prompted segmentation: GroundingDINO finds a box from a word and SAM produces the mask. The more specific the word the better
inpainterasing an area and having a diffusion model repaint it. Completion grows outward ring by ring with it (ch. 3)
the AMODAL ruleseach group’s prompt, negative prompt, the SAM words that recover its shape and the words to subtract, in parts.py
the restack differencestacking the completed group layers back in occlusion order and comparing pixel by pixel with the source inside the subject; the visible area is pinned to the source, so anything non-zero is a hole or a misalignment in the masks
probea small job answering one “does this actually work” question, whose conclusion goes into the book
Hertzmann 1998Painterly Rendering with Curved Brush Strokes of Multiple Sizes, where the detail brush’s algorithm comes from: multi-scale, growing along the gradient’s normal, and stopping when the canvas is close enough
the layer tree IR / tree.jsonthe intermediate form painting takes inside the library: a JSON describing an editable .kra (appendix F). What used to pass was a list of strokes, a model that forced fills, gradients and erasing all to become strokes
the description countrecursively counting how many numbers a layer tree holds (ir/count.py). The criterion is that count over the source’s pixel count times three, at most 5%. How many pixels you write is not the sin; being unable to say why is: a gradient covering 8K is nineteen numbers, and pasting the 1x source in is 4.7 million (ch. 7)
a bare pixel writea set_pixels with no why. The threshold is zero – writing pixels has to say why, not be forbidden
bakethe step that lays a tree down as a Krita document (ops/bake.py plus jobs/bake.py). The only painting job, shared by all three routes; it knows neither parts nor taste
the five ranksthe library is ranked by who decides what: atoms (geometry, raster, ir) -> capability and reading (ops, read) -> the vocabulary of painting (craft) -> parts (paint) -> wiring the stages (stages), plus the cross-cutting rulers (gauge). A rank may import downward only, and tests/test_layering.py enforces it (appendix B)
the vocabulary of painting, craftan artist’s actions: filling, banding, copying, hatching, strands, 色トレス. With aesthetic constants and no part names – how hair is painted belongs to the rank above (paint/)
colour fielda low-resolution image stretched out to serve as something blurred (the background). The criterion accounts for how many pixels it has itself, not how much it covers once stretched

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: 的时候才要求每个参数都有描述;给一个新参数补一行说明,会把同一个函数的另外十个参数一起点名。

Appendix F: file formats

A subproject, work/<name>/

input/origin.png (the picture), parts.py (this picture’s parts table), overrides.json (line overrides) and README.md (written by a person); then one phaseN-<name>/ per milestone (phase1-layers, phase2-draft, phase3-paint), each with a README.md a person writes saying what it produces and its verdict; and inside those, one stepNN-<stage>/ per stage, numbered globally: main.py (this step’s wiring, with the upstream and the knobs at the top), README.md (the numbers main.py wrote plus the ## Verdict a person adds), job.json (for the three steps that paint: the Krita job’s arguments), and the artifacts. A failed or experimental step is stepNN-<stage>-legacy-<wrong|experiment>-<k>/ and is not a step.

What painting produces, in stepNN-*/krita/

The names are fixed:

filefor whomcontents
final.krathe artistthe whole layer tree; clip, blend, opacity, label and vector layers all survive the round trip
final.pngpreviewing and the criteriathe composite
strokes.jsonlreplay and auditthe painting journal: every stroke (below)
stages/NN-*.png, stages/*.krathe processone image per stage; the finished version saves a checkpoint after each group
review/a personthe review images (below; what to look for in each is in ch. 7)

The review images

The main text says which image to look at and what to look for; these are their names. The ones a painting step writes land in stepNN-*/krita/review/, the rest beside the step’s other artifacts.

namewritten bywhat it shows
regions.png, regions.jsonsegmentthe map of numbered raw regions, which a person reads the numbers off
review-parts.png, review-parts.mdsegmentthree columns – the hand-written table, the state after the majority vote, and the pixels the vote moved – plus the table of what was reassigned
lines-review.png, lines-review.jsonlineartthe kept lines over a faded source (blue = a kept AI line, brown = a kept contour, red dashes = dropped), and every candidate’s scores
review-amodal.pngcompleteone row of four panels per group: the occluded area, the completed image, this layer alone, its outline
review-restack.pngcompletesource, restacked, and the difference – red for a mismatch, yellow for what no group claimed (which must be empty)
lines-<G>-review.png, .jsonlinesone per layer, that layer alone on a ground, in the same three colours
review-lines.pnglinesall the kept lines over the source, coloured by layer; the pale ones fall in the invented area
composite-1x.pngdraft, paint, tracethe whole picture at 1x – also where black rims around the silhouette show up
crop-<tag>-1to1.pngdraft, paint, tracethe 1:1 close-ups, one per entry in the parts table’s CROPS
layer-<G>.png, layer-<G>-<kind>.pngdraft, paint, traceeach layer alone on grey: its coverage, its holes, and what one method’s own layers look like
group-<G>.pngdraft, paint, traceeach group rendered alone with nothing over it – “is the kettle whole”, and where the hidden lines are
cmp/gaugethe matching-position crops against another subproject’s finished picture: source, it, this one

The painting journal, strokes.jsonl

Session(journal=True) wraps every painting and pixel-writing method and writes one line of JSON per call. It is the only evidence that this picture was really painted, and the input for replaying it. Every line has i (a sequence number), t (seconds) and operation. Coordinates are always canvas pixels (4096x6144 for a 4x job), with y downward.

Painting operations, which replay exactly: stroke (paintLine segment by segment, with points and pressure), path (one paintPath at constant pressure), line, dab, ellipse and polygon. Setting operations: set_preset, set_color and brush (the fast path, recording only the fields actually passed). Pixel-writing operations record a fingerprint rather than the pixels: set_pixels (box, shape, mean, sha1), ground, erase and filter.

{"i": 4, "t": 221.043, "operation": "brush", "preset": "b) Airbrush Soft"}
{"i": 5, "t": 221.044, "operation": "brush", "rgb": [0, 0, 0], "size": 584.95}
{"i": 6, "t": 221.045, "operation": "stroke", "layer": "BG_Glow", "points": [[0.0, 0.0], [292.32, 0.0]], "pressure": [0.55, 1.0]}

A stroke’s colour and brush size come from the nearest preceding brush entry; size is the value passed to setBrushSize and not the width painted (the brush factor, ch. 12).

A pixel-writing entry carries a why: writing pixels is allowed, but every write has to say why (ch. 7). One without a why is a bare write, of which the threshold is zero. ground calls set_pixels internally, so one fill records two entries, both carrying a why.

bake is another kind of entry, one per node as it is laid down: {"operation":"bake","node":"04_Color/Hair_Front/...","kind":"strokes","numbers":4097,"strokes":427}. numbers is computed from the layer tree’s node, not from pixels – a gradient covering 8K is nineteen numbers even though it wrote 25 million pixels.

gauge.report() tallies it, and Session.replay(jsonl, session) repaints from the journal (a pixel-writing entry records only a fingerprint, so the original data has to be present to replay it).

The layer tree, tree.json

This is the intermediate form handed to Krita, and the final form painting takes inside the library. What used to pass between the planner and Krita was a list of strokes, a model that forced everything to become strokes; now a tree passes, and the painting job (jobs/bake.py) is only its executor. It can be read, diffed, edited and counted without opening Krita.

Coordinates are always 1x source pixels (measured from the top-left corner, y downward); only ops/bake knows about scale.

{ "ir": 1, "unit": "px", "w": 1024, "h": 1536, "scale": 4, "dpi": 300.0,
  "color": {"model": "RGBA", "depth": "U8", "profile": "sRGB-elle-V2-srgbtrc.icc"},
  "source": {"path": "…/input.png"},
  "mode": "layered",              // layered | trace -- the criteria gate one and not the other (ch. 8)
  "crops": {"eye": [4160, 3440]}, // 8x coordinates, straight from parts.py's CROPS
  "root": [ <node>, … ],           // **bottom to top**, the same direction Krita's layer docker reads
  "made_by": "stages.tree.from_plan" }

Every node’s common fields: id (the layer’s path in Krita, unique in the tree), kind, name, blend, opacity (0 to 1, not 0 to 255), clip (which is setInheritAlpha), visible, label, why (which action of the vocabulary produced it) and cite (where in this book it comes from). Anything omitted takes its default, so the JSON holds only what differs.

kindits own fieldswhat it becomes
groupchildren[] and passthroughcreateGroupLayer
fillpaths: [{shape, rgb}]createVectorLayer plus addShapesFromSvg; a shape of null means a full-canvas solid colour and becomes a paint layer filled flat (three numbers)
gradienttype (linear / radial / conical), p0, p1, stops[], repeat and shapecompute the pixels yourself and setPixelData – not one parameter of Krita’s gradient generator layer takes effect (appendix C)
strokesbrush: {preset, width_factor} and strokes[]paintPath, or paintLine segment by segment
maskof, shape and feathercreateTransparencyMask plus setSelection; attached to the group it belongs to
filteras, filter: {name, params}, shape and feathercreateFilterMask; attached to the parent group likewise, and at most one per layer (stacking more is O(n^2), appendix C)
rasterfile, box, size and a required reasonsetPixelData; its description count follows size

A shape: {"rings": [{"bezier": [[p0,c1,c2,p3], …], "closed": true}], "rule": "evenodd", "from": "read.complete/Hair_Front@amodal"} – the same ruler the lineart and the bands use (geometry/rings.py).

A stroke: {"points": [[x,y], …] | "bezier": […], "radius": 14.0, "rgb": [r,g,b], "opacity": 0.35, "taper": true, "brush": {…}}. A brush is written only when this stroke’s brush differs from its layer’s – a layer holding several kinds is the normal case (<G>_Shade takes both the soft airbrush’s shade and the hard round’s sharp).

The plans, in each step’s directory

Every coordinate is 1x, and the painting job multiplies.

segment (stepNN-segment/): plan.json (each sub-part’s Flat ring in parts[], the silhouette, assign_names), plan.npz (parts after the vote, parts_assign from the hand-written table, subject with its alpha holes filled, subject_assign, rgba), ai/mask_<word>.png, regions.png and regions.json, and review-parts.png and .md. trace (one-shot, stepNN-trace/): plan.json (w h scale expand smooth eps, the silhouette rings, and parts[] with id name px bbox flat tiers[], each band carrying role rgb rings[], where rings[].points are Catmull-Rom control points and band 0 bottom-up is always Flat), plan.npz (parts subject rgba shade light lines), strokes.jsonl (the plan, one stroke per row: kind points radius rgb opacity taper part tier – the same name as the painting journal and a different thing), tree.json, preview.png (the 1x simulation), preview_cel.png (the bands alone), parts.png and cells.png; plus krita/review/ with the close-ups and each part group and each band rendered alone.

lineart: ai/lineart_{manga,anime,std}.png, strokes_lines.jsonl (the arbitrated lines), lines.json (the line layers), and lines-review.png and .json. complete: seethrough/<source name>/ (what See-through produced: <layer>.png and _depth.png per layer, src_img.png, info.json; absent when another step’s is reused), seethrough.json (which directory was used, each layer’s pixel count, and the group-to-layer mapping), amodal.npz (amodal_<G> the whole mask and region_<G> the invented part), amodal.json (seed, and per group px_vis px_added px_region rule rings bez_rings plus the merge’s account px_cand px_island px_black shift px_overlap and restack), layer_<G>.png (each layer’s complete RGBA pixels: the source where visible, its own layer where invented), review-amodal.png and review-restack.png. lines: ai/solo_<G>.png (that layer alone on a ground), ai/<G>/lineart_*.png, strokes_lines.jsonl (each stroke also carrying group and made_up), lines-<G>-review.png and .json, review-lines.png, and lines.json (layers, per group contour ai kept made_up, source, agreement). draft: plan_draft.json (groups[] with name rgb rings bez_rings fills[]; lines[]; crops), tree.json, strokes_draft.jsonl, strokes_lines.jsonl (copied unchanged from lines), preview.png and krita/. paint: plan_draft.json (rewritten in place: the eye method replaces the Eye group’s Shape with the eye area the source defines), plan_paint.json (format 2: mode="parts", crops from parts.py’s CROPS, and groups[] with name method shade psnr_before psnr_after [strands] [blend], where method is a name in dtp.paint.METHODS; a banded group also carries shadow / deep / light, each {rings, bez_rings, rgb}, from which the Krita job builds the <G>_Shadow subgroups as a vector Shape plus a clipped Paint layer), strokes_paint.jsonl, strokes_lines.jsonl (weighted against each layer’s own image), tree.json (the layer tree, above), bg_field.png (the background colour field, 32x48), masks_paint.npz (each group’s visible mask at 1x, mutually exclusive, used by the rulers), preview_paint.png, krita.log (the Krita job’s full log, with [tree] N nodes), and krita/ (which also holds layers.json: path, type, blend, clip and bounds for every node in the tree, which has to agree with tree.json – that agreement is the evidence the tree was laid down correctly). The crops in each plan file are parts.py’s CROPS, which the job writes its close-ups from. An experimental step’s main.py copies the plan files from a baseline step and changes the plan for one group only. gauge: gauge.json (the five criteria plus groups, each with px dist interior src_interior edge src_edge psnr1x, plus tier_edges, nodes and lines_made_up) and cmp/.

overrides.json (shared by lineart and lines; its boxes are 1x full-image coordinates):

[{"box": [x0, y0, x1, y1], "use": "ai" | "contour" | "none"}, {"id": "…", "use": "…"}]

A stroke’s row in a plan

{"kind": "soft", "points": [[x, y], …], "radius": 14.0, "rgb": [r, g, b], "opacity": 0.35, "taper": true, "part": "Hair_Back", "tier": 0}

radius is the radius at 1x; converting it to a setBrushSize value is 2 x radius x multiplier / the brush factor, done while building the tree (stages/tree.py). taper is only a shape marker for the planner’s own simulation – tapering is decided by the stroke’s kind (ops/brushes.KIND) and no single stroke may override it. Letting a stroke decide was tried, and soft went from a light sweep to full pressure covering everything, blurring the whole picture. Both are now looked up once while building the tree and written into every stroke in tree.json; the painting job looks nothing up.

Appendix G The part table’s fields, at a glance

work/<name>/parts.py, an ordinary Python module; a missing field is a hard error (config.REQUIRED). The template is dtp/templates/project/parts.py — copy it into the subproject.

fieldwhich stages it governsformathow to write itchange it, rerun from
ASSIGNsegment (1); the one-shot route uses only this one; with the lines as walls (ASSIGN_FROM) it inherits the previous step’s[("sub-part", [region number, …]), …]written against the regions.png of the first segment; the order is the occlusion order, bottom -> topall of it, from segment
ORDERsegment, and the one-shot route’s tree[n for n, _ in ASSIGN]never edited—
CLASSESthe majority vote in segment[("class", ["word", …], ["sub-part", …]), …]the order is the priority: small and specific firstfrom segment
SAM_WORDSsegment["hair", "red bow", …]the more specific the word the better; a word whose mask covers more than 40% of the subject is uselessfrom segment
KEEP_ASSIGN_REGIONsegment["sub-part", …]the sub-parts that skip the vote and keep the whole region the hand-written table gave themfrom segment
GROUPScomplete (3), per-layer lineart, draft (5), paint[("group", ["sub-part", ...]), ...]a group is one layer, the union of its sub-parts; the order is the occlusion order, which is the order of the groups in the .kra. Written by looking at the region image and the lineart imagefrom complete
SHARED_FLAT_COLORdraft (5){"group": "shared name", …}the groups that share one local colourfrom draft
GROUPS_FROM_MASKper-layer lineart (4), draft (5)[("group", "mask_x.png", "after which group", "the sub-part it depends on")]a layer split out of another group with an AI maskfrom lines
LINESlineart (2)[("Line_X", ["sub-part", …]), …]the lineart layers; every sub-part belongs to exactly one of themfrom lineart
LINE_LAYER_OF_GROUPper-layer lineart (4){"group": "Line_X", …}which lineart layer each layer’s lines go to, the GROUPS_FROM_MASK groups included; a group that is not listed gets no linesfrom lines
STRAND_GROUPSpaint (6), one-shot{"group", …}the groups that grow strands. Empty in the template; the one-shot route also needs strands=Truepaint / trace
HATCH_GROUPSone-shot (8){"sub-part", …}the sub-parts that get hatching. Empty in the template; the one-shot route also needs hatch=Truetrace
SEETHROUGHcomplete (3){"group": ["its layers", …], …}which See-through layers each group takes as candidates for its whole shape (only the occluded part is used); a group that is not listed only gets its seam filled; the layer names are in the template’s commentsfrom complete
LINE_DROP_BY_LAYERpaint (6){"Line_X": 6.0, …}one lineart layer’s own threshold: a line that is not darker than its surroundings by at least this much is not painted; a layer that is not listed uses the line_drop knob of the paint steppaint
PAINTpaint (6){"group": dict(method=..., ...), "*": dict(method="generic")}which method paints each group (method is a name in dtp.paint.METHODS, one page per body part: shadow_tier skin - eye eyes - hair hair - generic the baseline “copy” - eight placeholders) and its knobs; "*" is mandatory; a misspelled name fails the part table, and a group that picked a placeholder raises “not decided yet” at paint timepaint
CROPSreview (7){"face": (x, y), "neck": …, …}the centres of the 1:1 close-ups, in 8x coordinates; at least the face, the neck, the hands and the feetthe next painting run