Skip to content

Errors and Edge Cases

Mark Rogers edited this page Oct 27, 2026 · 5 revisions

Every error aw.py prints and the inputs that behave unexpectedly, in 0.7.2 (the v0.7.2 tag, commit a185c05). The JPEG block guard now works through ffmpeg as well as Pillow (below). The tie issue fixed since 0.1.0 is on Versions and upgrading.

Error messages

A failure aw.py catches prints {"ok": false, "error": "..."} and exits 1. Each of these was run:

Command error
fit missing.png -o x.png no such file: missing.png
fit dusk.png -o x.png --crop 2100,0,100,100 crop box is outside the image
fit dusk.png -o x.png --cell 3:4 --cell is RxC:i, e.g. 2x3:4 (row-major, from 0)
sheet empty -o x.png (a folder with no images) no images
palette dusk.png --against Not_A_Palette unknown palette Not_A_Palette
palette dusk.png --against 12345,abcdef not a hex colour: 12345
compare prepare dusk.png dusk.png the two files are identical
compare record 000000000000 1 1 unknown pair 000000000000 (run compare prepare first)
compare record 7313d500b577 3 1 ORDER is 1 or 2, PICK is 1, 2 or tie
rank --work nothing-here no pairs in nothing-here (run compare prepare)
verdict validate renders (no verdict there) no renders/artwright/verdict.json
cache label A B 1 cache label A B a|b|tie (the human's pick)
hash renders/render_01.png missing.png no such file: missing.png
comp dusk.png --subject 0.5,0.5 (also light --box) a box is x,y,w,h as fractions of width and height, e.g. 0.3,0.2,0.4,0.6
light dusk.png --box left,top,half,half ValueError: could not convert string to float: 'left'
light grey.png (one flat grey) one value family only: nothing to compare
depth dusk.png --depth missing.png no such file: missing.png
info photo.jpg with no Pillow and no ffmpeg cannot decode .jpg: install Pillow (pip install pillow) or ffmpeg (inside the item)
geomgate mill mill_painted.png --part gear no ring 'gear' in sprite.json (rings: wheel)
geomgate houses mill_painted.png (a folder with no sprite.json) no sprite.json in houses (contract: references/sprite-json.md)
sprite two/house1.png --refs two (two files, one of them the candidate) need at least two reference sprites (a candidate is never its own reference)
setfit screen_new.png --refs series/screen1.png need at least two reference images (a candidate is never its own reference)
setfit screen_new.png --refs series --treat "python darken.py {in}" --treat needs {in} and {out} in the command, e.g. "python treat.py {in} {out}"

One of them in full:

python aw.py compare prepare dusk.png dusk.png --work err

prints:

{
 "ok": false,
 "error": "the two files are identical"
}

compare prepare compares content, not names: two copies of one file under different names are refused too.

From the source, not run: fetch failed: <url> (<reason>) when a Lospec palette cannot be fetched, and Lospec palette <slug> has no colours. Also the image is fully transparent from palette, too many images for one 2000 px sheet; split the batch from sheet, ffmpeg could not read <file> and ffmpeg decode failed: ... on the ffmpeg route, and verdict.json is not JSON: .... Any other operating-system or value error comes back as "<ErrorType>: <message>".

One bad file in a list

info and pixel measure each file on its own: a file that fails gets "ok": false and an error in its item, the rest are measured, and the command's ok is false with exit 1.

python aw.py pixel sprite.png missing.png --budget 16

The failed item in what it prints, after the full result for sprite.png:

   "file": "missing.png",
   "ok": false,
   "error": "no such file: missing.png"

sheet gives the failed file an error in its map entry and leaves its cell empty. hash, dedupe and compare stop at the first file they cannot read, with no per-file results.

Usage errors: exit 2 and no JSON

Mistakes in the command line itself (a required option missing, a value outside a fixed list, a number that is not a number) are caught by Python's argument parser. It prints the usage to stderr, prints nothing on stdout, and exits 2. A caller that parses stdout as JSON must check the exit code first.

python aw.py fit dusk.png

prints, on stderr:

usage: aw.py fit [-h] -o OUT [--long LONG] [--tokens TOKENS] [--crop CROP]
                 [--cell CELL] [--pixel]
                 image
aw.py fit: error: the following arguments are required: -o/--out
python aw.py degrade dusk.png -o x.png --kind sharpen

gives, on stderr:

usage: aw.py degrade [-h] -o OUT
                     --kind {saturate,crush,crop,jpeg,orphans,mixels,blur,warm-shadows,glow-shadows,uniform-sat,midgrey,haze-off,hotspot,tangent,noise,band,alias,banding,pillow}
                     [--seed SEED] [--amount AMOUNT]
                     image
aw.py degrade: error: argument --kind: invalid choice: 'sharpen' (choose from saturate, crush, crop, jpeg, orphans, mixels, blur, warm-shadows, glow-shadows, uniform-sat, midgrey, haze-off, hotspot, tangent, noise, band, alias, banding, pillow)

The wording of these lines comes from Python: on Python 3.9 the choices are quoted ('saturate', 'crush', ...) and the usage wraps differently.

aw.py without aw_measure.py or aw_geom.py

aw.py imports aw_measure.py (since 0.2.0) and aw_geom.py (since 0.4.0) from its own folder when it starts. A copy of aw.py on its own fails before any command runs, --version included, with a Python traceback on stderr and exit 1:

mkdir -p lone && cp aw.py lone/
python lone/aw.py --version 2>&1 | tail -n 1; echo "exit ${PIPESTATUS[0]}"

prints:

ModuleNotFoundError: No module named 'aw_measure'
exit 1

With aw_measure.py beside it but no aw_geom.py, the last line names the other file:

mkdir -p pair && cp aw.py aw_measure.py pair/
python pair/aw.py --version 2>&1 | tail -n 1; echo "exit ${PIPESTATUS[0]}"

prints:

ModuleNotFoundError: No module named 'aw_geom'
exit 1

Copy or link the whole scripts folder, or the whole skills/artwright folder, never aw.py alone.

The grid is the largest block that fits

pixel reports the largest block size (up to 64) that the colour changes line up with, not the size the artist drew at. A checkerboard of 4 x 4 squares drawn at 4x is reported as scale 16 with a native size of 4 x 4, because every 16 x 16 block is one colour. Give the scale when you know it:

python aw.py pixel grass.png --scale 4

The grid and native_size part of what it prints:

   "grid": {
    "scale": 4,
    "offset": [
     0,
     0
    ],
    "lattice_alignment": 100.0
   },
   "native_size": [
    16,
    16
   ],
   "uniform_blocks": 100.0,

With --scale, grid holds no alignment_x or alignment_y.

Smooth gradients go the other way: when neighbouring pixels differ by less than --tol (24, summed over the channels), no change is seen at all. A 16 x 16 gradient drawn at 4x, whose steps are 12 and 10 apart, is read as one 64 px block:

python aw.py pixel gradient.png

The grid and native_size part of what it prints:

   "grid": {
    "scale": 64,
    "offset": [
     0,
     0
    ],
    "lattice_alignment": 100.0,
    "alignment_x": 100.0,
    "alignment_y": 100.0
   },
   "native_size": [
    1,
    1
   ],
   "uniform_blocks": 0.0,

Seam checks on patterned tiles

seam_suspected compares the difference across a wrap edge with the mean difference between side-by-side pixels inside the tile. A pattern whose squares end exactly at the tile's edge has a colour change at the wrap, as it should, but most neighbours inside are equal, so the check fires on a tile that repeats perfectly:

python aw.py pixel grass.png --tile --scale 4

The tile part of what it prints:

   "tile": {
    "inner_mean": 18.0,
    "wrap_left_right": 90.0,
    "wrap_top_bottom": 90.0,
    "seam_suspected": true

A real seam, speckled grass whose brown path stops at the right edge, is flagged the same way:

python aw.py pixel seam.png --tile --scale 4
   "tile": {
    "inner_mean": 22.4,
    "wrap_left_right": 136.8,
    "wrap_top_bottom": 16.9,
    "seam_suspected": true

So seam_suspected means "look at the 3 x 3 preview", which is what the pixel skill does, not "this tile is broken".

Palette lists a colour twice

palette splits the pixels into -k boxes by median cut, each split at the box's middle pixel. When many pixels share one colour, the split can fall inside that run, and the colour comes back as two entries with separate shares. It shows most with few colours and a large -k. The test sprite has four colours, asked for the default eight:

python aw.py palette sprite.png --against pico-8

prints:

{
 "file": "sprite.png",
 "colours": [
  {
   "hex": "#d27d2c",
   "share": 0.25,
   "nearest": "#ffa300",
   "delta_e": 13.85
  },
  {
   "hex": "#140c1c",
   "share": 0.25,
   "nearest": "#000000",
   "delta_e": 10.43
  },
  {
   "hex": "#96562f",
   "share": 0.125,
   "nearest": "#ab5236",
   "delta_e": 7.02
  },
  {
   "hex": "#140c1c",
   "share": 0.125,
   "nearest": "#000000",
   "delta_e": 10.43
  },
  {
   "hex": "#64392a",
   "share": 0.125,
   "nearest": "#5f574f",
   "delta_e": 16.12
  },
  {
   "hex": "#854c30",
   "share": 0.062,
   "nearest": "#ab5236",
   "delta_e": 8.82
  },
  {
   "hex": "#854c30",
   "share": 0.031,
   "nearest": "#ab5236",
   "delta_e": 8.82
  },
  {
   "hex": "#a4856b",
   "share": 0.031,
   "nearest": "#ab5236",
   "delta_e": 18.85
  }
 ],
 "against": {
  "palette": "pico-8",
  "size": 16,
  "weighted_delta_e": 11.68,
  "note": "delta-E 2000; under 2 is hard to see, over 10 is a different colour"
 },
 "ok": true
}

#140c1c and #854c30 each appear twice. Add the shares of equal hex values, or ask for -k no larger than the colour count (info gives unique_colours, which counts transparent pixels as a colour).

JPEG blocks through ffmpeg

Since 0.6.0, pixel does not take a JPEG's 8 px block edges for a 2 or 4 px pixel grid: when at least 0.8 of the colour or luma steps sit on the 8 px lattice, it reports scale 1 with a note. A smooth JPEG (random colours, enlarged, blurred, saved at quality 55), with Pillow:

python aw.py pixel smooth.jpg

The grid part of what it prints:

   "grid": {
    "scale": 1,
    "offset": [
     0,
     0
    ],
    "lattice_alignment": 100.0,
    "note": "JPEG: the 2 px lattice is the 8 px block edges, not a pixel grid; pass --scale if it is real"
   },

Through 0.7.1, ffmpeg's chroma upsampling added 2 px colour steps: only 0.665 of the RGB steps sat on the 8 px lattice, below the guard's 0.8 threshold. The file read as scale 2. Since 0.7.2 the guard also checks luma, where the share is 0.881, and the same fixture reads as scale 1 without Pillow. An explicit --scale still bypasses the guard. On 112 real JPEGs, both decoders kept their earlier grid scales.

A small grid in a high-quality JPEG

Up to 0.7.0, scale 2 and scale 3 palette art saved as a quality 90 JPEG read as scale 1: the ringing beside each edge scattered colour steps off the lattice. Since 0.7.1, on a JPEG, pixel also reads the grid counting a step only where it peaks along its row or column. It uses that reading only when it finds a scale under 8 and the steps on that lattice are at least 1.6 times the size of those off it, and then reports the ratio as grid.lattice_contrast (CHANGELOG C-20261026-1). A 64 x 64 palette sprite with 30% random pixels, enlarged 2x and saved at quality 90:

python aw.py pixel pal_s2_q90.jpg

The grid and native_size part of what it prints:

   "grid": {
    "scale": 2,
    "offset": [
     0,
     0
    ],
    "lattice_alignment": 97.4,
    "alignment_x": 97.4,
    "alignment_y": 97.9,
    "lattice_contrast": 4.32
   },
   "native_size": [
    64,
    64
   ],
   "uniform_blocks": 0.0,

The grid is found, but the file still fails the pass rule: clean_pixel_art is false with off-grid pixels, no uniform blocks, 21.19% blend pixels and 3895 colours, because the JPEG changed the colours inside each block. The smooth JPEG above still reads as scale 1 with Pillow. On the ffmpeg route (Windows, Python 3.9, no Pillow) the same sprite read as scale 2 too, from the plain reading (lattice alignment 86.7, no lattice_contrast).

Paths and platforms

  • On Windows, files found by listing a folder print with backslashes ("renders\\render_01.png" in the JSON), and so do output paths built by aw.py ("batch\\artwright\\verdict.json", "notan\\grey.png"). Inside verdict.json, paths are always forward slashes.
  • The PNG files aw.py writes are compressed by the zlib that Python was built with, so the same drawing can have different bytes, SHA-256 and pair ids on two machines. In the Windows run, dusk.png was 16318 bytes; on Linux it was 16342.
  • Decoders differ. The test JPEG had 6381 unique colours through Pillow 13.0.0 and 6566 through ffmpeg on Windows.
  • Since 0.6.0 the measuring copy is reduced by Pillow's box filter when Pillow is there and by 2x2 halving when it is not, so stats, light, gamut, accents, depth, comp and geomgate give slightly different numbers without Pillow, on the same PNG: stats value mean 0.561 against 0.56 for dusk.png, geomgate region scores 0.78 against 0.74 for the wheel. One flag changed in these runs: on the tangent test image (dusk_tangent.png) the focal margin was 0.015 with Pillow, so competing_focal fired, and 0.042 without, so it did not. A flag near its threshold can flip with the decoder. The pages show the Pillow run.
  • On Windows with Python 3.9, a tile measure that rounds to zero from below printed as -0.0 ("gradient_tb": -0.0 for tile_ramp.png), where Linux printed 0.0.
  • Without Pillow and ffmpeg only PNG is read; every other format fails per file with the message above.

Things that are not errors

  • verdict validate says valid: true for a draft; check status yourself.
  • rank with pairs prepared but none judged ranks every image at strength 1, all close_to_next.
  • rank --next stops early when every remaining pair has met, and each image appears in at most one suggested pair.
  • compare record accepts any number of re-queries; only the first record per order, and then the first re-query that is not a tie, count toward the result.
  • verdict init on an existing file never changes its kind.
  • fit without --pixel never enlarges an image.
  • doctor reports home_exists: false until something is written there (cache put, cache label, a fetched palette).

Clone this wiki locally