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
pixelDataand 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 to | gradient p99 |
|---|---|
| 8192 px wide | 33.2 |
| 2508 px wide | 42.2 |
| 1024 px wide | 56.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 paintings | 59.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.