Repository navigation
Errors and Edge Cases
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.
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 errprints:
{
"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>".
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 16The 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.
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.pngprints, 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 sharpengives, 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 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.
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 4The 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.pngThe 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_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 4The 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 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-8prints:
{
"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).
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.jpgThe 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.
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.jpgThe 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).
- On Windows, files found by listing a folder print with backslashes (
"renders\\render_01.png"in the JSON), and so do output paths built byaw.py("batch\\artwright\\verdict.json","notan\\grey.png"). Insideverdict.json, paths are always forward slashes. - The PNG files
aw.pywrites 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.pngwas 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,compandgeomgategive slightly different numbers without Pillow, on the same PNG:statsvalue mean 0.561 against 0.56 fordusk.png,geomgateregion 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, socompeting_focalfired, 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.0fortile_ramp.png), where Linux printed0.0. - Without Pillow and ffmpeg only PNG is read; every other format fails per file with the message above.
-
verdict validatesaysvalid: truefor adraft; checkstatusyourself. -
rankwith pairs prepared but none judged ranks every image at strength 1, allclose_to_next. -
rank --nextstops early when every remaining pair has met, and each image appears in at most one suggested pair. -
compare recordaccepts 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 initon an existing file never changes itskind. -
fitwithout--pixelnever enlarges an image. -
doctorreportshome_exists: falseuntil something is written there (cache put,cache label, a fetched palette).
This wiki describes artwright 0.7.2 (the v0.7.2 tag, commit a185c05) and was last updated on 2026-10-26. The plugin is MIT licensed. Report problems in the issues. It installs from the repository, m4bwav/artwright, or from the 0.7.2 release.
Using it
- Getting started
- Skills
- Commands
- Light, colour and composition
- How images are judged
- Verdict contract
- Errors and edge cases
- Recipes
- FAQ
The releases
Contributing
Elsewhere