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

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.