Skip to content

Making Pixel Art

m4bwav edited this page Oct 26, 2026 · 2 revisions

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

The render at 2x

Snap: find the grid

$ 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
  • scale is the block size and offset where the first block starts. The true values were 6 and 4.
  • native is the grid in blocks: 22x22, the 20 sprite blocks plus the ground around them.
  • coherence is 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_blocks is 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.
  • detected is 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 $?
0

A 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 $?
0

Too few edges to be a grid

A 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 $?
0

0.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 $?
0

This 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 $?
0

Background

bg 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 $?
0

The 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 $?
0

enclosed_cleared (since 0.8.0) counts the pixels cleared by --holes, below; it is 0 without it.

Enclosed holes

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 $?
0

The 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

Palette

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 $?
0

Clean

clean 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 $?
0

On 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.

Pixel-perfect corners

--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 $?
0
row 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 $?
0

Swap: recolour a finished sprite

swap (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 $?
0

A 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 $?
1

By 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 $?
0

A 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 $?
0

Two colours mapped to one print a note that those parts merge (from the source; not run).

Fog: a far-layer variant

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

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 $?
0

A 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 $?
1

Tiles

tile 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 $?
0

seam_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 $?
0

The default orphan cleaning changed 20 of the texture's 256 pixels on the way.

The whole pipeline as a recipe

{"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 $?
0

make 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.

Defaults worth knowing

  • With no clean key, the recipe cleans with {"alpha": 128, "orphans": true}. A clean object 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 snap also takes max (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, make prints {"ok": ..., "sprites": [...]}. Paths in it are relative to --root (the current folder by default).

Before and after

The render at 2x The 32x32 sprite at 8x

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.

Clone this wiki locally