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):
| approach | transition band | time | destructive |
|---|---|---|---|
filter.apply(node, box) | 7.2 px (the floor, a knife cut) | 4.4s | yes |
createFilterMask(f, sel.feather(40)) | 59.9 px | 4.1s | no (the source layer is byte-for-byte untouched) |
the same, with feather(120) | 177.7 px | 7.6s | no |
createFilterLayer(f, sel.feather(40)) | 59.9 px | 3.9s | no |
createFilterMask(f, node) | none – the whole canvas is processed | 9.3s | no |
- 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 andnode.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:
| Criteria | result |
|---|---|
| each of the three regions’ area over the analytic area | 0.98 / 0.98 / 0.98, the missing 2% being the line width |
| unclassified pixels | 0.3% |
| time and memory | 12.3s,RSS +1100 MB |
| boundary-band deviation | 0.4981, a binary hard edge |
.kra round trip | a 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)
| preset | xml | what setBrushSize(90) actually paints | factor | fromXML(toXML()) |
|---|---|---|---|---|
| b) Basic-1 | 1689 | 92 px | 1.02 | lossless |
| f) Bristles-5 Flat | 16516 | 40 px | 0.44 | lossless |
| b) Airbrush Soft | 9552 | 82 px | 0.91 | lossless |
| c) Pencil 1 Sketch (mypaint) | 22658 | 14 px | 0.16 | lossy: 14 becomes 442 px, a factor of 31, and nothing raises |
- Changing
diameterinsidebrush_definitionworks: Airbrush Soft from 90 to 200, after whichsetCurrentBrushPresetcarriesview.brushSize()to 200 automatically and it paints 184 px. - But on a preset with a predefined tip (Bristles-5 Flat), changing
diameterdoes nothing at all and raises nothing. - Zeroing
hfadeandvfadeleaves 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
fromXMLtakes effect globally within the process (callingresources("preset")again returns the changed one) but writes nothing to disk (paintoppresets/gains and changes nothing), andfromXML(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()andsetGeometry()silently do nothing: it starts at 640x480 and stays there. It has to beshow()n beforeresize()to become 1180x780 (still a border short).- The colour
fillForegroundfills with matchessetForeGroundColorexactly. fillGradientandfillPatternreally produce something (32 colours for a gradient; a mean texture gradient of 54.9 for a pattern).copyScratchpadImageData()to numpy tosetPixelDatais lossless (a maximum per-channel difference of 0).- Memory: 200
fillForegroundcalls cost 2.9 KB each, and 200paintLinecalls 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
| operation | result |
|---|---|
shapes() / shapesInRect / shapeAtPosition | fine 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 |
setZIndex | works, 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 |
setTransformationreplaces rather than composes. Feeding itQTransform().translate(...)directly wipes out the shape’s original placement; and sinceQTransform.translatetranslates in local coordinates first, translating in the parent’s coordinates has to be writtenT0 * 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:
| operation | opaque px in the composite | Flat bounds |
|---|---|---|
| (original) | 640,000 | 800×800 |
move(+400, +300) | 640,000 | moved to (1800,1700) |
rotateNode(0.3) | 641,516 | 1002×1002 |
scaleNode(QPointF(cx,cy), 1200, 1200, "Bicubic") | 921,221 | 1199×1200 |
cropNode(x, y, 400, 400) | 2,983 | 43×138 |
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
| thing | Building | effect 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 work | renders 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 | ❌ | 0 | see 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.