Repository navigation
Making Pixel Art
make runs six steps in a fixed order, each optional: snap, background, palette, clean, crop, tile. Each step is also a command of its own. This page runs them one at a time on the render from Home, then as one recipe. The recipe's full schema is in the skill's recipe reference.
The input, render.png, is a 20x20 mushroom drawn in code and scaled 6x with nearest neighbour. It sits 4 pixels in from the top-left corner of a near-white ground, blurred (radius 1) and noised the way an image model's "pixel art" comes out:
render.png: 132x132, 10103 colours

$ python pxw.py snap render.png snapped.png
{
"ok": true,
"out": "snapped.png",
"scale": [
6.0,
6.0
],
"offset": [
3.998,
4.004
],
"native": [
22,
22
],
"uniform_blocks": 0.0,
"detected": true,
"coherence": [
0.713,
0.71
]
}
$ echo $?
0-
scaleis the block size andoffsetwhere the first block starts. The true values were 6 and 4. -
nativeis the grid in blocks: 22x22, the 20 sprite blocks plus the ground around them. -
coherenceis how well the column and row edges line up with that grid, from 0 to 1. Under 0.5 counts as no grid. Since 0.8.0 so do too few edges for the coherence to mean anything (below). -
uniform_blocksis the share of blocks whose inner 60% holds one colour. On this render it is 0.0, since the noise gives every block several colours; each block still takes its most common colour. On a clean upscale it is 1.0, as on the fractional one below. -
detectedis true when the scale was found, false when you gave it.
Fractional scales work. The same sprite resized to 110x110 (5.5x, blocks of 5 and 6 pixels):
$ python pxw.py snap frac.png frac-snapped.png
{
"ok": true,
"out": "frac-snapped.png",
"scale": [
5.498,
5.498
],
"offset": [
0.147,
0.173
],
"native": [
20,
20
],
"uniform_blocks": 1.0,
"detected": true,
"coherence": [
0.936,
0.93
]
}
$ echo $?
0A smooth painted gradient has no grid. snap then keeps the image as it is, reports scale 1, and prints no coherence field:
$ python pxw.py snap painting.png painting-snapped.png
{
"ok": true,
"out": "painting-snapped.png",
"scale": [
1,
1
],
"offset": [
0,
0
],
"native": [
128,
96
],
"uniform_blocks": 1.0,
"detected": true
}
$ echo $?
0A few colour edges always line up with some period by chance. Since 0.8.0 snap asks for enough edges first: fewer than 8 edge positions, or a best coherence under 0.5 or under 2.8 divided by the square root of the edge count, means no grid. (The skill's recipe reference says random edges reach about 2.6 divided by that root one time in 20.) One axis with no grid takes the other axis's scale only when that axis rests on 16 or more edges.
The input here is the one the repository's test uses: random colours resized 8x with bicubic filtering, blurred and saved as a JPEG at quality 55, so it has no pixel grid at all:
smooth.jpg: 768x768 JPEG, quality 55: random colours resized 8x bicubic and blurred
$ python pxw.py snap smooth.jpg smooth-snapped.png
{
"ok": true,
"out": "smooth-snapped.png",
"scale": [
1,
1
],
"offset": [
0,
0
],
"native": [
768,
768
],
"uniform_blocks": 1.0,
"detected": true
}
$ echo $?
00.7.0 snapped the same file at scale 57 into a 13x13 image, with coherence 1.0 across and 0.0 down:
$ python pxw.py snap smooth.jpg smooth-old.png
{
"ok": true,
"out": "smooth-old.png",
"scale": [
57.0,
57.0
],
"offset": [
26.0,
26.0
],
"native": [
13,
13
],
"uniform_blocks": 0.0,
"detected": true,
"coherence": [
1.0,
0.0
]
}
$ echo $?
0This is the one change to default output in 0.8.2. The changelog says 304 real renders came out at the same scale as before.
Forcing the scale and offset (--scale is a number, --offset is X,Y) skips detection:
$ python pxw.py snap render.png forced.png --scale 6 --offset 4,4
{
"ok": true,
"out": "forced.png",
"scale": [
6.0,
6.0
],
"offset": [
4.0,
4.0
],
"native": [
22,
22
],
"uniform_blocks": 0.0,
"detected": false,
"coherence": [
null,
null
]
}
$ echo $?
0bg takes the most common border colour and clears every pixel joined to the border whose colour is within --tol (40, measured in OKLab x 255) of it:
$ python pxw.py bg snapped.png cleared.png
{
"ok": true,
"out": "cleared.png",
"colour": [
243,
242,
236
],
"cleared": 252,
"enclosed_cleared": 0
}
$ echo $?
0The ground's colour came out as 243, 242, 236 (it was drawn as 244, 242, 236, then noised). On an image whose border is already mostly transparent it does nothing:
$ python pxw.py bg cleared.png cleared2.png
{
"ok": true,
"out": "cleared2.png",
"colour": null,
"cleared": 0,
"note": "border already transparent"
}
$ echo $?
0enclosed_cleared (since 0.8.0) counts the pixels cleared by --holes, below; it is 0 without it.
The flood fill starts at the border, so ground colour inside a ring, a handle or the gap between an arm and the body stays. --holes N (in a recipe, "background": {"holes": N}) also clears enclosed patches of the ground colour of N pixels or more. It is off by default, since a window that shows the sky may be meant. The input is a blue square with an 8x8 hole on a magenta ground:
ring.png: 32x32, a 16x16 blue square with an 8x8 hole of the magenta ground in it, and one magenta pixel at 9,9
$ python pxw.py bg ring.png ring-bg.png
{
"ok": true,
"out": "ring-bg.png",
"colour": [
255,
0,
255
],
"cleared": 768,
"enclosed_cleared": 0
}
$ echo $?
0$ python pxw.py bg ring.png ring-holes.png --holes 4
{
"ok": true,
"out": "ring-holes.png",
"colour": [
255,
0,
255
],
"cleared": 832,
"enclosed_cleared": 64
}
$ echo $?
0The 64 pixels of the hole went; the single magenta pixel, smaller than 4, stayed:
ring-bg.png: hole alpha 255, pixel 9,9 alpha 255; ring-holes.png: hole alpha 0, pixel 9,9 alpha 255
The automatic background step runs inside make on every input unless the recipe says "background": "none". On the painted gradient, with no flat ground, it cleared 4,030 of 12,288 pixels:
{"id": "painting", "input": "painting.png", "palette": 8} "background": {
"colour": [
35,
178,
198
],
"cleared": 4030,
"enclosed_cleared": 0
See Palettes for every source of colours. Here the cleared sprite (221 colours) goes to PICO-8:
$ python pxw.py palette cleared.png pico.png --palette pico-8
{
"ok": true,
"out": "pico.png",
"palette": [
"000000",
"1d2b53",
"7e2553",
"008751",
"ab5236",
"5f574f",
"c2c3c7",
"fff1e8",
"ff004d",
"ffa300",
"ffec27",
"00e436",
"29adff",
"83769c",
"ff77a8",
"ffccaa"
],
"used": 8,
"before": 221
}
$ echo $?
0clean always makes alpha binary (--alpha 128 is the cut). --orphans gives a pixel with no same-coloured neighbour its neighbours' most common colour. --thin removes the inner pixel of a doubled outline. --outline selective recolours each edge pixel to a darker palette shade of its own colour, less dark on the lit side (--light top-left by default):
$ python pxw.py clean pico.png clean.png --orphans --outline selective
{
"ok": true,
"out": "clean.png",
"colours": 7,
"orphans_fixed": 1,
"outlined": 60
}
$ echo $?
0On the command line the outline shades come from the image's own colours; inside make they come from the recipe's palette, or from the image's colours when the recipe has none.
--corners (since 0.8.0; in a recipe, "clean": {"corners": true}, or an "R,G,B" colour, run after the outline) removes the extra pixel of each stair step in a one-pixel line of the darkest colour. Each removed pixel takes the colour on its outer diagonal, so the line stays joined corner to corner and its ends stay. A diagonal drawn two pixels a row:
stairs.png: 14x10, a diagonal of 12 pixels, two a row, each row one to the right
$ python pxw.py clean stairs.png stairs-clean.png --corners
{
"ok": true,
"out": "stairs-clean.png",
"colours": 1,
"corners_fixed": 5
}
$ echo $?
0row 0: #.......
row 1: .#......
row 2: ..#.....
row 3: ...#....
row 4: ....#...
row 5: .....##.
Real corners, where two arms meet at a right angle, stay. The chest's outline box has four, and nothing changed:
$ python pxw.py clean chest.png chest-corners.png --corners
{
"ok": true,
"out": "chest-corners.png",
"colours": 5,
"corners_fixed": 0
}
$ echo $?
0swap (since 0.8.0) replaces colours exactly on the opaque pixels of one PNG, or of every PNG in a folder (a sheet's frames), from a JSON map or by position between two palette files. The skill names enemy tiers, team colours and night tiles as its uses. A night version of the chest:
{"281824": "141428", "a46422": "523a3c", "c83438": "642848", "f8f0e2": "a8b0c8", "78401e": "3c2830"}$ python pxw.py swap chest.png chest-night.png --map night.json
{
"ok": true,
"out": [
"chest-night.png"
],
"mapped": 5,
"unmapped": [],
"note": null
}
$ echo $?
0A map that leaves out a colour the sprite uses still writes the file, exits 1 and lists the colours it left alone:
{"281824": "141428", "a46422": "523a3c"}$ python pxw.py swap chest.png chest-part.png --map night-part.json
{
"ok": false,
"out": [
"chest-part.png"
],
"mapped": 2,
"unmapped": [
"78401e",
"c83438",
"f8f0e2"
],
"note": null
}
$ echo $?
1By palette files, the first colour of --from becomes the first of --to, and so on in file order:
281824
a46422
c83438
f8f0e2
78401e
201428
7a4a30
9a2c40
d8c8c0
5a3424
$ python pxw.py swap chest.png chest-dusk.png --from day.hex --to dusk.hex
{
"ok": true,
"out": [
"chest-dusk.png"
],
"mapped": 5,
"unmapped": [],
"note": null
}
$ echo $?
0A folder in, a folder out:
$ python pxw.py swap chests night-chests --map night.json
{
"ok": true,
"out": [
"night-chests/f00.png",
"night-chests/f01.png"
],
"mapped": 5,
"unmapped": [],
"note": null
}
$ echo $?
0Two colours mapped to one print a note that those parts merge (from the source; not run).
A recipe's fog (since 0.8.0) blends the image toward an air colour before the palette step, which then locks the colours again. The skill's recipe reference suggests about 0.4 for a middle layer and 0.8 for the far one:
{"id": "mushroom-mid", "input": "render.png", "palette": 8,
"fog": {"colour": "194,195,199", "amount": 0.4}, "crop": {"pad": 1, "size": "32x32"}} "fog": {
"colour": "194,195,199",
"amount": 0.4
},
With PICO-8 and 0.8, every fogged colour landed on the same PICO-8 colour, and the whole mushroom came out as one flat shape:
{"id": "mushroom-far", "input": "render.png", "palette": "pico-8",
"fog": {"colour": "194,195,199", "amount": 0.8}, "crop": {"pad": 1, "size": "32x32"}} "palette": {
"colours": 16,
"used": 1,
"dither": null
},
The spread of brightness over the opaque pixels, for the plain sprite from Home and the two fogged ones:
luminance spread (standard deviation over opaque pixels): out/mushroom.png 70.3 (7 colours), out-fog/mushroom-mid.png 42.1 (6 colours), out-fog-far/mushroom-far.png 0.0 (1 colours)
With a fixed palette, check how many colours a fogged sprite keeps before you use it.
crop cuts tight to the opaque pixels, then places the sprite on a canvas: centred across, on the bottom row by default so a set of sprites shares a baseline, --pad pixels in from the edges:
$ python pxw.py crop clean.png crop.png --pad 1 --size 32x32
{
"ok": true,
"out": "crop.png",
"sprite": [
20,
19
],
"canvas": [
32,
32
],
"at": [
6,
12
]
}
$ echo $?
0--size pow2 picks the next power of two that holds the sprite plus padding:
$ python pxw.py crop clean.png crop2.png --size pow2
{
"ok": true,
"out": "crop2.png",
"sprite": [
20,
19
],
"canvas": [
32,
32
],
"at": [
6,
13
]
}
$ echo $?
0A sprite that does not fit is an error, exit 1, not a failed gate:
$ python pxw.py crop clean.png tiny.png --size 16x16
{
"ok": false,
"error": "sprite is 20x19 (+0 pad); it does not fit 16x16"
}
$ echo $?
1tile makes a texture wrap: near the edges each pixel comes from a copy rolled by half a tile, in the middle from the original, crossing over through a Bayer pattern, so no new colour appears. On a 16x16 grass texture that was denser on the left than on the right:
$ python pxw.py tile grass.png grass-tile.png
{
"ok": true,
"out": "grass-tile.png",
"seam_ratio_before": 1.467,
"seam_ratio": 0.991,
"new_colours": 0
}
$ echo $?
0seam_ratio is the mean colour step across the wrap edges over the mean step between neighbouring pixels inside. The command's ok checks only new_colours; the 1.5 limit on seam_ratio is a gate of make, which this recipe runs (snap off, since the texture is already at its native size, and no background step):
{"id": "grass", "input": "grass.png", "snap": false, "background": "none", "palette": "from:grass.png", "tile": true}$ python pxw.py make recipe-tile.json out-tile
{
"ok": true,
"id": "grass",
"out": "out-tile/grass.png",
"preview": "out-tile/grass.x4.png",
"size": [
16,
16
],
"steps": {
"input": {
"size": [
16,
16
],
"colours": 3
},
"palette": {
"colours": 3,
"used": 3,
"dither": null
},
"clean": {
"orphans_fixed": 20
},
"tile": {
"band": 4
}
},
"gates": {
"alpha_binary": true,
"colours": 3,
"colours_outside_palette": 0,
"within_budget": true,
"seam_ratio": 0.836,
"seam_ok": true,
"aw_pixel": {
"skipped": "artwright's aw.py not found (set PIXELWRIGHT_AW)"
}
}
}
$ echo $?
0The default orphan cleaning changed 20 of the texture's 256 pixels on the way.
{"id": "mushroom", "input": "render.png", "palette": {"colours": 8},
"clean": {"orphans": true, "thin": true, "outline": "selective", "light": "top-left"},
"crop": {"pad": 1, "size": "32x32", "anchor": "bottom"}}$ python pxw.py make recipe-full.json out-full
{
"ok": true,
"id": "mushroom",
"out": "out-full/mushroom.png",
"preview": "out-full/mushroom.x4.png",
"size": [
32,
32
],
"steps": {
"input": {
"size": [
132,
132
],
"colours": 10103
},
"snap": {
"scale": [
6.0,
6.0
],
"offset": [
3.998,
4.004
],
"native": [
22,
22
],
"uniform_blocks": 0.0,
"detected": true,
"coherence": [
0.713,
0.71
]
},
"background": {
"colour": [
243,
242,
236
],
"cleared": 252,
"enclosed_cleared": 0
},
"palette": {
"colours": 7,
"used": 7,
"dither": null
},
"clean": {
"orphans_fixed": 13,
"doubles_thinned": 7,
"outlined": 60
},
"crop": {
"sprite": [
20,
19
],
"canvas": [
32,
32
],
"at": [
6,
12
]
}
},
"gates": {
"alpha_binary": true,
"colours": 7,
"colours_outside_palette": 0,
"within_budget": true,
"size_ok": true,
"aw_pixel": {
"skipped": "artwright's aw.py not found (set PIXELWRIGHT_AW)"
}
}
}
$ echo $?
0make writes four files per sprite: the sprite, a 4x nearest-neighbour preview, a GIMP palette and the report printed above. From the first example on Home:
out/mushroom.png: 32x32, 7 colours, alpha values [0, 255]; files in out: mushroom.gpl, mushroom.make.json, mushroom.png, mushroom.x4.png
The .gpl file holds the colours the sprite uses, not the whole palette it was matched against:
GIMP Palette
Name: mushroom
Columns: 8
#
41 22 36 291624
49 33 43 31212b
181 156 127 b59c7f
198 52 56 c63438
231 213 179 e7d5b3
233 103 94 e9675e
248 236 222 f8ecde
{"colours": 8} gave 7 colours here: median cut made 8 and then merged two that look alike.
- With no
cleankey, the recipe cleans with{"alpha": 128, "orphans": true}. Acleanobject you write replaces that default, so orphans stay unless you list them. This recipe has"clean": {"outline": "auto"}and no orphan step ran:
{"id": "mushroom", "input": "render.png", "palette": 8, "clean": {"outline": "auto"}} "clean": {
"outlined": 60
- Orphan removal is on by default, also for hand-made art. On a 16x16 grass texture it changed 20 pixels (Tiles). Turn it off for art where a single pixel is an eye or a glint:
"clean": {"orphans": false}. - With no
crop, the sprite keeps the snapped canvas size: the recipe above wrote a 22x22 sprite with the cleared ground around it. - A recipe's
snapalso takesmax(the largest scale searched, 64 by default; detection never goes above a quarter of the image's width or height). - A recipe file can hold one sprite, a list, or
{"sprites": [...]}; with more than one,makeprints{"ok": ..., "sprites": [...]}. Paths in it are relative to--root(the current folder by default).

The sprite is the output of the Home recipe: 8 colours asked, 7 kept, ground cleared, cropped to 32x32 with a pixel of padding. The stem's shaded side came back as a broken column of dark pixels. The make skill's Step 4 lists the usual repairs (a larger native size when detail is lost, 2 to 4 more colours when the palette is muddy), one change at a time, then make again.
This wiki describes pixelwright 0.8.2 and was last updated on 2026-10-26. The plugin is MIT licensed. Report problems in the issues. On GitHub: release v0.8.2.
Using it
The releases
Contributing
Elsewhere