Skip to content

Commands

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

Every aw.py command, with its options and real output. python aw.py stands for the script's full path (skills/artwright/scripts/aw.py in the plugin); the agent runs it from your folder, and relative paths are read from there.

How every command behaves

  • One JSON object on stdout, indented one space, UTF-8, with an "ok" key: last on success, first on a failure. Exit code 0 on success, 1 on a failure that aw.py caught ({"ok": false, "error": "..."}).
  • Usage mistakes are the exception. A missing required option or a value outside a fixed list is caught by Python's argument parser before aw.py runs. The usage text goes to stderr, nothing goes to stdout, and the exit code is 2. See Errors and edge cases.
  • --version prints the bare version, 0.7.2, not JSON.
  • A folder given where images are expected is read one level deep, sorted by name, keeping .png, .jpg, .jpeg, .webp, .gif, .bmp, .tif and .tiff.
  • Paths come back as you gave them. Files found by listing a folder are printed with the system's separator, so on Windows the JSON holds "renders\\render_01.png" where these pages show "renders/render_01.png". The verdict.json file always holds forward slashes.
  • Images written by aw.py (fit, sheet, --notan, --preview, pair images, degrade) are PNG files, whatever extension you give, and the folders they go in are created.

With no command it lists the commands:

python aw.py

prints:

{
 "commands": [
  "accents",
  "cache",
  "comp",
  "compare",
  "dedupe",
  "degrade",
  "depth",
  "doctor",
  "fit",
  "gamut",
  "geomgate",
  "hash",
  "info",
  "light",
  "palette",
  "pixel",
  "rank",
  "setfit",
  "sheet",
  "sprite",
  "stats",
  "verdict"
 ],
 "help": "aw.py <command> -h",
 "ok": true
}
doctor [--probe]
info IMG...
fit IMG -o OUT [--long N] [--tokens N] [--crop x,y,w,h] [--cell RxC:i] [--pixel]
sheet IMG|DIR... -o OUT [--cols N] [--cell PX] [--pixel] [--no-labels] [--bg R,G,B]
stats IMG [--edge N] [--notan DIR]
light IMG [--edge N] [--box x,y,w,h]
gamut IMG [--edge N] [--overlay OUT]
accents IMG [--edge N]
depth IMG [--edge N] [--depth MAP] [--far-bright]
comp IMG [--edge N] [--subject x,y,w,h] [--text-band x,y,w,h]... [--out DIR]
palette IMG [-k N] [--against PAL]
pixel IMG|DIR... [--budget N] [--palette PAL] [--scale S] [--tol N] [--tile] [--frames] [--preview OUT] [--zoom N] [--bg R,G,B]...
hash IMG|DIR...
dedupe IMG|DIR... [--threshold N]
compare prepare A B [--work DIR] [--cell PX]
compare record PAIR ORDER PICK [--work DIR] [--confidence low|medium|high] [--criteria WORD...] [--judge MODEL]
rank [--work DIR] [--next N] [--write]
cache get|put IMG [--model M] [--rubric R] [--rubric-version V] [--prompt-version P] [--temperature T] [--seed N] [--verdict JSON]
cache stats | cache label A B a|b|tie [--shown-first a|b] [--note TEXT] | cache agreement [WORK...]
verdict init DIR [IMG...] [--kind pairwise|brief-fit|critique|pixel] [--brief TEXT]
verdict validate DIR
geomgate RENDER_DIR PAINTED [--part P] [--spokes N] [--contact A:B]... [--game-px N] [--no-edges] [--out F]
sprite CAND... --refs DIR|IMG... [--pad F] [--min-coverage F] [--out F]
setfit CAND... --refs A,B,C|DIR [--treat "CMD {in} {out}"] [--treated-out DIR] [--widen F] [--width N] [--out F]
degrade IMG -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 N] [--amount A]

--work defaults to artwright in the current folder.

doctor

What decodes here, where the private record lives, and the Python and Pillow versions. The output is on Getting started. --probe adds one request each to the Cleveland Museum of Art's open-access API and to Lospec, and reports whether each answered; it was not run for this wiki, since the examples make no network requests.

info

Format (read from the file's first bytes), size in bytes and pixels, SHA-256, alpha use, the number of distinct RGBA colours, and the token cost of reading the image on each tier.

python aw.py info sprite.png photo.jpg

prints:

{
 "items": [
  {
   "file": "sprite.png",
   "ok": true,
   "format": "png",
   "bytes": 284,
   "size": [
    64,
    64
   ],
   "sha256": "06c39fb5bc2fd067794c45adf034e0719d4f340c7b7b270ed244fb3935493704",
   "alpha": "binary",
   "partial_alpha_pixels": 0,
   "unique_colours": 5,
   "tokens": {
    "standard": {
     "tokens": 9,
     "read_at": [
      64,
      64
     ]
    },
    "high": {
     "tokens": 9,
     "read_at": [
      64,
      64
     ]
    }
   }
  },
  {
   "file": "photo.jpg",
   "ok": true,
   "format": "jpeg",
   "bytes": 21677,
   "size": [
    800,
    480
   ],
   "sha256": "7ab435253c1508d8071a8f2280c7127a7165681594df511e1ac9e0709d153649",
   "alpha": "none",
   "partial_alpha_pixels": 0,
   "unique_colours": 6381,
   "tokens": {
    "standard": {
     "tokens": 522,
     "read_at": [
      800,
      480
     ]
    },
    "high": {
     "tokens": 522,
     "read_at": [
      800,
      480
     ]
    }
   }
  }
 ],
 "ok": true
}
Field Meaning
alpha none (every pixel opaque), binary (only fully clear or fully opaque) or partial
unique_colours distinct RGBA values in the whole image
tokens.standard the image fitted to a 1568 px long edge and at most 1568 visual tokens, counted as ceil(w/28) x ceil(h/28)
tokens.high the same with 2576 px and 4784 tokens (the high-resolution tier, Claude 4.7 and later)
read_at the size the model would see on that tier

A file that cannot be read gets "ok": false and an error in its own item; the others are still measured, and the command exits 1.

fit

Writes a smaller copy to read: area-average downscaling, never an upscale unless --pixel.

Option Effect
-o OUT required; the output PNG
--long N fit the long edge to N px. When neither --long nor --tokens is given, N is 1024
--tokens N shrink until ceil(w/28) x ceil(h/28) is at most N. With --tokens alone there is no long-edge limit
--crop x,y,w,h crop this box first (pixels of the original); a box running past an edge is cut at the edge
--cell RxC:i crop cell i of an R-row, C-column grid, row-major from 0; after --crop when both are given
--pixel nearest neighbour. When the image fits inside the long edge (1024 unless --long), it is scaled up by the largest whole factor that fits
python aw.py fit dusk.png -o dusk_small.png --tokens 400

prints:

{
 "out": "dusk_small.png",
 "size": [
  712,
  418
 ],
 "tokens": {
  "standard": {
   "tokens": 390,
   "read_at": [
    712,
    418
   ]
  },
  "high": {
   "tokens": 390,
   "read_at": [
    712,
    418
   ]
  }
 },
 "ok": true
}

A box around the fox, at full resolution:

python aw.py fit dusk.png -o fox_crop.png --crop 1100,850,300,150

prints:

{
 "out": "fox_crop.png",
 "size": [
  300,
  150
 ],
 "tokens": {
  "standard": {
   "tokens": 66,
   "read_at": [
    300,
    150
   ]
  },
  "high": {
   "tokens": 66,
   "read_at": [
    300,
    150
   ]
  }
 },
 "ok": true
}

The centre cell of a 3 x 3 grid (cell 4, counting from 0):

python aw.py fit dusk.png -o cell4.png --cell 3x3:4

prints:

{
 "out": "cell4.png",
 "size": [
  666,
  400
 ],
 "tokens": {
  "standard": {
   "tokens": 360,
   "read_at": [
    666,
    400
   ]
  },
  "high": {
   "tokens": 360,
   "read_at": [
    666,
    400
   ]
  }
 },
 "ok": true
}

A 64 px sprite blown up 8x with hard pixel edges:

python aw.py fit sprite.png -o sprite_big.png --pixel --long 512

prints:

{
 "out": "sprite_big.png",
 "size": [
  512,
  512
 ],
 "tokens": {
  "standard": {
   "tokens": 361,
   "read_at": [
    512,
    512
   ]
  },
  "high": {
   "tokens": 361,
   "read_at": [
    512,
    512
   ]
  }
 },
 "ok": true
}

sheet

One labelled contact sheet for many images, at most 2000 px a side. Cells are labelled A to Z, then AA, AB and on, in a built-in bitmap font; the map gives the file for each label. Since 0.6.0 the sheet's background is neutral grey (119, 119, 119; L* 50), since white makes colours read darker and duller; --bg 255,255,255 gives white.

Option Effect
-o OUT required
--cols N columns; default the square root of the count, rounded up
--cell PX cell size, default 384, shrunk until the sheet fits 2000 px. Below 32 px the command fails: split the batch
--pixel nearest neighbour, and small images scaled up by a whole factor
--no-labels no letters on the cells
--bg R,G,B (0.6.0) the background; default 119,119,119

The six-image sheet is on Getting started. Two sprites side by side at a whole-number zoom:

python aw.py sheet renders/render_01.png renders/render_02.png -o pair.png --pixel --cols 2 --cell 256

prints:

{
 "out": "pair.png",
 "size": [
  530,
  268
 ],
 "cell": 256,
 "map": {
  "A": {
   "file": "renders/render_01.png"
  },
  "B": {
   "file": "renders/render_02.png"
  }
 },
 "tokens": {
  "standard": {
   "tokens": 190,
   "read_at": [
    530,
    268
   ]
  },
  "high": {
   "tokens": 190,
   "read_at": [
    530,
    268
   ]
  }
 },
 "ok": true
}

An image that fails to load gets an error in its map entry and an empty cell; the sheet is still written.

stats

Numbers that describe an image; none of them is a beauty score. They are measured on a copy shrunk to a 256 px long edge (--edge N changes that); since 0.6.0 the shrink averages every source pixel. Since 0.6.0 the value numbers are CIE lightness, L*/100, not luma: luma on encoded sRGB read pure blue as 0.07, L* puts it at 0.32. value.luma_mean keeps the old Rec. 709 luma beside it. Since 0.4.0, value and colour numbers read the opaque pixels only (alpha 128 and up), while edges, symmetry, balance and the notan read the image laid on white.

python aw.py stats dusk.png --notan notan

prints:

{
 "file": "dusk.png",
 "size": [
  2000,
  1200
 ],
 "measured_at": [
  256,
  154
 ],
 "alpha": {
  "transparent_share": 0.0,
  "excluded": false
 },
 "value": {
  "mean": 0.561,
  "scale": "L*/100",
  "luma_mean": 0.535,
  "key": "middle",
  "histogram_10": [
   0.0,
   0.121,
   0.152,
   0.119,
   0.125,
   0.094,
   0.013,
   0.001,
   0.006,
   0.369
  ],
  "groups_estimate": 4,
  "middle_share": 0.291,
  "bimodality": 0.997,
  "clip_black": 0.0,
  "clip_white": 0.0,
  "highlight_tint": {
   "C": 4.6,
   "hue": "azure"
  },
  "note": "groups_estimate counts tones, not masses: comp counts masses"
 },
 "chroma_cells": {
  "mean": 18.46,
  "spread": 0.719,
  "peak_cell": [
   0.0625,
   0.5625
  ],
  "peak_focal_distance": 0.125
 },
 "rms_contrast": 29.6,
 "noise": {
  "sigma_L": 0.0,
  "reading": "clean",
  "proxy": "flattest tenth of 16x16 cells",
  "note": "grain, not texture: SD in L* units"
 },
 "colourfulness": {
  "M": 56.7,
  "level": "quite colourful",
  "method": "Hasler-Suesstrunk 2003, signed rg/yb, nearest Table 2 anchor"
 },
 "saturation": {
  "mean": 0.314,
  "share_hot": 0.0
 },
 "temperature": {
  "warm_share": 0.667,
  "chromatic_share": 0.624,
  "b_mean": 1.75,
  "b_sd": 17.36,
  "note": "warm_share buckets HSV hue (yellow-green counts as warm); judge temperature relatively with light (lights against shadows)"
 },
 "edge_density": 0.04,
 "mirror_symmetry": {
  "left_right": 0.972,
  "top_bottom": 0.486
 },
 "balance": {
  "centre_of_mass": [
   0.498,
   0.531
  ],
  "imbalance_pct": 6.3,
  "note": "0 is centred; one centre-of-mass offset, not the Aesthetics Toolbox eight-axis Balance"
 },
 "focal_cell": [
  0.188,
  0.562
 ],
 "thirds_distance": 0.179,
 "fractal_dimension": 1.289,
 "note": "descriptions, not a beauty score; thirds_distance is never a reason (references/evidence.md)",
 "notan": {
  "grey": "notan/grey.png",
  "two_value": "notan/notan2.png",
  "three_value": "notan/notan3.png",
  "cut_two": 0.629,
  "light_share": 0.378,
  "cuts_three": [
   0.36,
   0.697
  ],
  "shares_three": [
   0.346,
   0.278,
   0.376
  ]
 },
 "ok": true
}
Field Meaning
alpha (0.4.0) transparent_share, and excluded: true when transparent pixels were left out of the value and colour numbers
value.mean, value.key mean lightness, L*/100 (value.scale), since 0.6.0; low below 0.38, high above 0.68, otherwise middle. Before 0.6.0, mean luma with cuts at 0.35 and 0.65
value.luma_mean (0.6.0) the mean Rec. 709 luma of the sRGB values, 0 to 1, as value.mean was before 0.6.0
value.histogram_10 share of pixels in ten value bands, dark to light
value.groups_estimate peaks in a 16-band histogram that hold more than 3% of pixels: a rough count of tones, not of masses (comp counts masses)
value.middle_share (0.2.0) share of pixels with CIE L* from 35 to 65, the middle values
value.bimodality (0.2.0) how deep the valley is between the two highest peaks of a smoothed L* histogram (at least 15 L* apart), 0 to 1: high when the values split into a light and a shadow family, 0 with one peak
value.clip_black, value.clip_white (0.2.0) share of pixels with L* below 3, or above 97
value.highlight_tint (0.2.0) chroma C and hue name of the mean colour of the brightest 0.5% of pixels: the colour of the light
chroma_cells (0.2.0) the median chroma of each cell of an 8 x 8 grid: their mean, spread (standard deviation over the mean), the centre of the most saturated cell (peak_cell) and its distance from focal_cell
rms_contrast standard deviation of CIE L* (0 to 100)
noise (0.6.0) grain: sigma_L, the SD in L* units from the flattest tenth of a 16 x 16 grid of the 256 px copy (Immerkaer's estimator), read as clean under 1, fine grain under 3, otherwise noisy. Grain on a large image is averaged away by the shrink (Light, colour and composition)
colourfulness Hasler and Süsstrunk's M, named by the nearest anchor of their scale, from not colourful to extremely colourful
saturation mean HSV saturation; share_hot is pixels above 0.8 saturation and 0.8 value
temperature warm_share of chromatic pixels with hue below 90 or from 300 degrees; chromatic_share of all pixels; b_mean and b_sd (0.2.0), the mean and spread of CIELAB b* (positive is yellow, negative blue)
edge_density share of pixels where the Sobel magnitude of luminance exceeds 0.4
mirror_symmetry 1 minus the mean luminance difference against the mirrored half, left-right and top-bottom
balance centre of mass of each pixel's distance from mean luminance, 0 to 1 per axis; imbalance_pct is 0 when centred
focal_cell centre of the 8 x 8 grid cell with the highest local contrast
thirds_distance distance from focal_cell to the nearest thirds intersection; a description only
fractal_dimension box-counting slope of the edge map; null when the image is too small for three box sizes
notan with --notan DIR: a greyscale image and two- and three-value thumbnails at the measured size, in L*. Since 0.6.0 they are cut where the values divide (Otsu for two, k-means for three), never at the median, and the output gives the cuts, light_share and shares_three

light, gamut, accents, depth, comp

Five measures added in 0.2.0: light and shadow families, the gamut's shape, off-hue accents, recession into the distance, and composition (value masses, the focal peak, the frame's edges, the horizon, hot spots). Each returns numbers, readings in words and flags that say where to look. They have a page of their own with full output: Light, colour and composition. They live in aw_measure.py beside aw.py and run through it. Later additions: comp --text-band (0.5.0), light's separation and the grain floor in comp (0.6.0).

palette

Dominant colours by median cut, measured on a copy shrunk to a 128 px long edge, counting pixels at least half opaque.

python aw.py palette dusk.png -k 5

prints:

{
 "file": "dusk.png",
 "colours": [
  {
   "hex": "#e2e8f0",
   "share": 0.25
  },
  {
   "hex": "#382e4a",
   "share": 0.25
  },
  {
   "hex": "#7f4e49",
   "share": 0.25
  },
  {
   "hex": "#be6a45",
   "share": 0.125
  },
  {
   "hex": "#e3e6eb",
   "share": 0.125
  }
 ],
 "ok": true
}

-k (default 8) is the number of boxes the cut makes. --against PAL adds, for each colour, the nearest palette colour and the CIEDE2000 difference, and a share-weighted mean. PAL is one of:

  • pico-8, the one built-in palette (16 colours);
  • a file: .hex or .txt with six-digit hex colours, or a GIMP .gpl file;
  • comma-separated hex colours, or one hex colour;
  • any other name is taken as a Lospec palette slug and fetched once from lospec.com, then kept in ARTWRIGHT_HOME/palettes/. Slugs are lowercase letters, digits and hyphens. Fetching was not run for this wiki.
python aw.py palette sprite.png -k 4 --against mypalette.hex

prints:

{
 "file": "sprite.png",
 "colours": [
  {
   "hex": "#28171f",
   "share": 0.5,
   "nearest": "#1a1c2c",
   "delta_e": 10.24
  },
  {
   "hex": "#d27d2c",
   "share": 0.25,
   "nearest": "#ef7d57",
   "delta_e": 13.28
  },
  {
   "hex": "#96562f",
   "share": 0.125,
   "nearest": "#b13e53",
   "delta_e": 20.84
  },
  {
   "hex": "#8c5a3e",
   "share": 0.125,
   "nearest": "#b13e53",
   "delta_e": 19.37
  }
 ],
 "against": {
  "palette": "mypalette",
  "size": 6,
  "weighted_delta_e": 13.47,
  "note": "delta-E 2000; under 2 is hard to see, over 10 is a different colour"
 },
 "ok": true
}
python aw.py palette sprite.png -k 3 --against 140c1c,d27d2c,854c30

gives:

{
 "file": "sprite.png",
 "colours": [
  {
   "hex": "#28171f",
   "share": 0.5,
   "nearest": "#140c1c",
   "delta_e": 7.47
  },
  {
   "hex": "#d27d2c",
   "share": 0.25,
   "nearest": "#d27d2c",
   "delta_e": 0.0
  },
  {
   "hex": "#915836",
   "share": 0.25,
   "nearest": "#854c30",
   "delta_e": 4.59
  }
 ],
 "against": {
  "palette": "custom",
  "size": 3,
  "weighted_delta_e": 4.88,
  "note": "delta-E 2000; under 2 is hard to see, over 10 is a different colour"
 },
 "ok": true
}

When the image has fewer colours than -k, the same colour can appear twice: see Errors and edge cases.

pixel

Pixel-art checks on one or more images, or a folder. It finds the block size and offset of the pixel grid, reads one pixel per block (the block's most common colour) into a native-size image, and measures that.

python aw.py pixel sprite.png --budget 16 --preview sprite_x4.png

prints:

{
 "items": [
  {
   "file": "sprite.png",
   "size": [
    64,
    64
   ],
   "grid": {
    "scale": 4,
    "offset": [
     0,
     0
    ],
    "lattice_alignment": 100.0,
    "alignment_x": 100.0,
    "alignment_y": 100.0
   },
   "native_size": [
    16,
    16
   ],
   "uniform_blocks": 100.0,
   "transitional_pixels": 0.0,
   "unique_colours": 4,
   "budget": 16,
   "near_duplicate_colour_pairs": 0,
   "partial_alpha_pixels": 0,
   "orphans": {
    "count": 2,
    "first": [
     [
      6,
      6
     ],
     [
      9,
      6
     ]
    ]
   },
   "banding": {
    "count": 13,
    "per_100_edge_px": 8.2,
    "first": [
     [
      2,
      3
     ],
     [
      5,
      3
     ],
     [
      11,
      3
     ],
     [
      7,
      5
     ],
     [
      2,
      13
     ],
     [
      6,
      13
     ],
     [
      12,
      13
     ],
     [
      1,
      4
     ],
     [
      1,
      9
     ],
     [
      1,
      12
     ],
     [
      13,
      4
     ],
     [
      13,
      9
     ],
     [
      13,
      12
     ]
    ]
   },
   "corner_doubles": {
    "count": 8,
    "first": [
     [
      3,
      7
     ],
     [
      12,
      7
     ],
     [
      5,
      12
     ],
     [
      6,
      12
     ],
     [
      9,
      12
     ],
     [
      10,
      12
     ],
     [
      5,
      13
     ],
     [
      10,
      13
     ]
    ]
   },
   "jaggies": 0,
   "jaggies_interior": 0,
   "outline": {
    "pixels": 48,
    "colours": 1,
    "top": [
     {
      "hex": "#140c1c",
      "share": 1.0
     }
    ]
   },
   "pillow": {
    "edge_corr": 0.47,
    "light_corr": 0.22,
    "pillow": false
   },
   "preview": "sprite_x4.png",
   "clean_pixel_art": true,
   "problems": []
  }
 ],
 "pass_rule": "one integer scale, 100% lattice alignment, uniform blocks, no blend pixels, colours within budget, binary alpha (R-20261021-8)",
 "ok": true
}

The same sprite after an off-grid resample, the way AI "pixel art" usually arrives:

python aw.py pixel sprite_ai.png --budget 16

prints:

{
 "items": [
  {
   "file": "sprite_ai.png",
   "size": [
    64,
    64
   ],
   "grid": {
    "scale": 4,
    "offset": [
     0,
     0
    ],
    "lattice_alignment": 66.7,
    "alignment_x": 66.7,
    "alignment_y": 72.8
   },
   "native_size": [
    16,
    16
   ],
   "uniform_blocks": 66.0,
   "transitional_pixels": 2.95,
   "unique_colours": 4,
   "budget": 16,
   "near_duplicate_colour_pairs": 0,
   "partial_alpha_pixels": 145,
   "orphans": {
    "count": 2,
    "first": [
     [
      6,
      6
     ],
     [
      9,
      6
     ]
    ]
   },
   "banding": {
    "count": 13,
    "per_100_edge_px": 8.2,
    "first": [
     [
      2,
      3
     ],
     [
      5,
      3
     ],
     [
      11,
      3
     ],
     [
      7,
      5
     ],
     [
      2,
      13
     ],
     [
      6,
      13
     ],
     [
      12,
      13
     ],
     [
      1,
      4
     ],
     [
      1,
      9
     ],
     [
      1,
      12
     ],
     [
      13,
      4
     ],
     [
      13,
      9
     ],
     [
      13,
      12
     ]
    ]
   },
   "corner_doubles": {
    "count": 8,
    "first": [
     [
      3,
      7
     ],
     [
      12,
      7
     ],
     [
      5,
      12
     ],
     [
      6,
      12
     ],
     [
      9,
      12
     ],
     [
      10,
      12
     ],
     [
      5,
      13
     ],
     [
      10,
      13
     ]
    ]
   },
   "jaggies": 0,
   "jaggies_interior": 0,
   "outline": {
    "pixels": 48,
    "colours": 1,
    "top": [
     {
      "hex": "#140c1c",
      "share": 1.0
     }
    ]
   },
   "pillow": {
    "edge_corr": 0.47,
    "light_corr": 0.22,
    "pillow": false
   },
   "clean_pixel_art": false,
   "problems": [
    "off-grid pixels (lattice alignment 66.7%)",
    "blocks not uniform (66.0%)",
    "transitional (blend) pixels 2.95%",
    "145 semi-transparent pixels"
   ]
  }
 ],
 "pass_rule": "one integer scale, 100% lattice alignment, uniform blocks, no blend pixels, colours within budget, binary alpha (R-20261021-8)",
 "ok": true
}
Field Meaning
grid.scale, grid.offset the block size and where the lattice starts. The largest block size up to 64 that 98% of colour changes line up with on both axes; failing that, the commonest spacing of change peaks
grid.lattice_alignment share of colour changes that fall on the lattice, in percent (the lower of the two axes)
native_size the image at one pixel per block
uniform_blocks percent of blocks that are a single colour; mixels and resampling lower it
transitional_pixels percent of pixels whose left and right neighbours differ and whose own colour lies between theirs: blend pixels
unique_colours distinct opaque colours at native size
near_duplicate_colour_pairs colour pairs closer than CIEDE2000 3; counted only up to 256 colours
partial_alpha_pixels semi-transparent pixels in the original
orphans single pixels with no neighbour of the same colour (8 directions), with the first 20 coordinates at native size
banding, corner_doubles, jaggies_interior, pillow, edge_lost_on_bg (0.7.0) reported, never failed: see Craft numbers below
jaggies steps in the silhouette that break a monotonic run; only for sprites with transparency
outline colours of the opaque pixels that touch transparency; only for sprites with transparency
clean_pixel_art, problems the pass rule below, and each rule that failed

orphans, jaggies and outline are computed only when the native image is at most 512 x 512 (orphans is null above that).

The pass rule, as pass_rule states it: one integer scale, 100% lattice alignment, uniform blocks, no blend pixels, colours within budget, binary alpha. Blend pixels fail above 0.1%. Without --budget, more than 256 colours fails.

Option Effect
--budget N the colour budget
--palette PAL adds palette: percent of opaque pixels on the palette, and up to 20 off-palette colours with their nearest palette colour. PAL as for palette --against
--scale S skip grid detection and use block size S. grid then holds only scale, offset and lattice_alignment
--tol N how different two neighbours must be to count as a change: the sum of the channel differences. Default 24
--bg R,G,B (0.7.0, repeatable) a ground the sprite will sit on: adds edge_lost_on_bg
--tile adds tile: the mean difference between side-by-side native pixels (inner_mean), across the left-right and top-bottom wrap edges, and seam_suspected, true when a wrap edge differs by more than twice inner_mean plus 10
--frames with more than one image, adds frames (below)
--preview OUT writes the native image scaled up by --zoom (default 4). With --tile, it writes a 3 x 3 repeat instead, named <OUT stem>_tile3x3.png beside OUT

Frames of a walk cycle, where the third frame is lifted one pixel:

python aw.py pixel walk --frames

The frames part of what it prints:

 "frames": {
  "count": 3,
  "scales": [
   4
  ],
  "sizes": [
   16
  ],
  "palette_union": 4,
  "palette_per_frame": [
   4,
   4,
   4
  ],
  "bboxes": [
   [
    1,
    1,
    14,
    14
   ],
   [
    1,
    1,
    14,
    14
   ],
   [
    1,
    0,
    14,
    13
   ]
  ],
  "baselines": [
   14,
   14,
   13
  ],
  "baseline_consistent": false

bboxes are [left, top, right, bottom] of the opaque pixels at native size and baselines their bottom rows.

A tile, checked for seams. The block size found here is 16, not the 4 it was drawn at, because the checker's squares are four pixels wide: see Errors and edge cases.

python aw.py pixel grass.png --tile --preview grass.png

The tile part of what it prints:

   "tile": {
    "inner_mean": 90.0,
    "wrap_left_right": 90.0,
    "wrap_top_bottom": 90.0,
    "seam_suspected": false,
    "gradient_lr": 0.0,
    "gradient_tb": 0.0,
    "detail_spread": 0.26,
    "flat_border_sides": [],
    "repetition_suspected": false,
    "preview": "grass_tile3x3.png"

Craft numbers (0.7.0): reported, never failed

0.7.0 added numbers for the faults a pixel-art critique names: banding, pillow shading and doubled corners, plus jagged colour edges inside a sprite, a silhouette lost on the game's grounds, and repetition in a tile. None of them has a pass line. They are reported and never change clean_pixel_art or problems; the repository keeps it that way until there is evidence to set thresholds (CHANGELOG C-20261025-2, LEARNINGS L-019). On 30 AI renders quantised the naive way, banding ran from 0 to 6.5 per 100 edge pixels (median 3.3), so the number locates banding, it does not grade it. Even the clean test sprite above reports 13 banding pixels (8.2 per 100) and 8 corner doubles. Three fields are true or false, pillow, lost and repetition_suspected, and their cut-offs are marked heuristic in the source (the tile ones set on test drawings); they never fail a sprite either.

Field Meaning
banding runs of 2 to 8 pixels of one colour that the next row or column repeats at the same length in another colour, straight under or one pixel along: bands hugging each other. count, per_100_edge_px and the first coordinates at native size
corner_doubles stair-step corners on one-pixel lines, pixels that can go without breaking the line; a square corner is not counted. count and the first 20 coordinates
jaggies_interior the jaggies step rule on colour edges inside the sprite, for each colour covering 5% or more, outline colours left out (sprites with transparency)
pillow edge_corr, the rank correlation of lightness with distance from the silhouette, and light_corr, the best correlation with position over 8 directions; pillow is true at edge_corr 0.6 or more with light_corr under 0.4 (sprites with transparency)
edge_lost_on_bg with --bg R,G,B (repeatable): for each ground, the share of the silhouette within 10 L* of it; lost over 0.3
tile.gradient_lr, tile.gradient_tb with --tile: mean L* of the left half minus the right, and of the top half minus the bottom
tile.detail_spread with --tile: the coefficient of variation of colour changes over a 4 x 4 grid; high when the detail is bunched in one place
tile.flat_border_sides with --tile: border rows or columns that are one colour over 75% of their length
tile.repetition_suspected with --tile: a ramp over 6 L*, a spread over 1.0, or a flat border side

A 32 px orb with a one-pixel outline, shaded from the top left, at its native scale:

python aw.py pixel orb.png --scale 1

The banding part, and the part from jaggies on, of what it prints:

   "banding": {
    "count": 0,
    "per_100_edge_px": 0.0,
    "first": []
   },
   "jaggies": 8,
   "jaggies_interior": 0,
   "outline": {
    "pixels": 84,
    "colours": 1,
    "top": [
     {
      "hex": "#140f1e",
      "share": 1.0
     }
    ]
   },
   "pillow": {
    "edge_corr": 0.36,
    "light_corr": 0.67,
    "pillow": false

Between them, corner_doubles has a count of 36 and the first 20 coordinates.

The same orb shaded by distance from its rim (orb_pillow.png), the pillow-shading fault:

python aw.py pixel orb_pillow.png --scale 1
   "pillow": {
    "edge_corr": 0.97,
    "light_corr": 0.0,
    "pillow": true

degrade --kind pillow makes the same fault from the clean orb (edge_corr 0.97, pillow true), and degrade --kind banding draws a ring in the second-darkest colour two pixels inside the silhouette: banding went from 0 to 16 (4.7 per 100 edge pixels).

The same orb against a near-black and a light grey ground:

python aw.py pixel orb.png --scale 1 --bg 16,16,16 --bg 192,192,192
   "edge_lost_on_bg": [
    {
     "bg": "#101010",
     "share": 1.0,
     "lost": true
    },
    {
     "bg": "#c0c0c0",
     "share": 0.0,
     "lost": false
    }
   ],

The dark outline disappears on the dark ground. Corner doubles on a one-pixel staircase line and on a clean 2:1 line:

python aw.py pixel stairs.png line21.png --scale 1

stairs.png gives "count": 12 and line21.png gives "count": 0. The clean orb above reports 36 on its round outline, so a count on a curve is where to look, not a fault in itself.

Three 16 px tiles: a ramp from dark to light left to right, even speckle, and one grey rock in the middle of grass:

python aw.py pixel tile_ramp.png tile_even.png tile_rock.png --scale 1 --tile

The tile part of each item, in order:

   "tile": {
    "inner_mean": 19.3,
    "wrap_left_right": 290.0,
    "wrap_top_bottom": 0.0,
    "seam_suspected": true,
    "gradient_lr": -30.4,
    "gradient_tb": 0.0,
    "detail_spread": 0.58,
    "flat_border_sides": [],
    "repetition_suspected": true
   "tile": {
    "inner_mean": 24.8,
    "wrap_left_right": 41.2,
    "wrap_top_bottom": 41.2,
    "seam_suspected": false,
    "gradient_lr": 0.4,
    "gradient_tb": 0.9,
    "detail_spread": 0.31,
    "flat_border_sides": [],
    "repetition_suspected": false
   "tile": {
    "inner_mean": 4.0,
    "wrap_left_right": 0.0,
    "wrap_top_bottom": 0.0,
    "seam_suspected": false,
    "gradient_lr": 0.0,
    "gradient_tb": 0.0,
    "detail_spread": 1.75,
    "flat_border_sides": [],
    "repetition_suspected": true

The ramp and the rock are flagged, the speckle is not. On 20 real seamless tiles the flag fired on 4, the ones that stripe when repeated (CHANGELOG C-20261025-2).

Rules that changed after 0.3.0

  • 0.4.1: with a stated --scale 1, blend pixels are reported in transitional_pixels but do not fail: a native sprite's one-pixel shading steps are not blends. Without --scale, nothing changed. In the craft examples above the orb reports transitional_pixels 0.29 and stays clean_pixel_art: true.
  • 0.4.2: a run that touches a fully transparent pixel is left out of transitional_pixels, since a transparent pixel's RGB (often 0, 0, 0) is not a colour; an outlined sprite on transparent black no longer fails.
  • 0.6.0: on a JPEG, a 2 or 4 px grid made by the 8 px block edges reads as scale 1 with a note, and a found scale of 8 or 16 gets a note to check it by eye.
  • 0.7.1: on a JPEG, a scale 2 or 3 grid hidden by ringing is found, with grid.lattice_contrast. Both JPEG rules, with output, are on Errors and edge cases.

hash

64-bit average hash and difference hash of each image, and its SHA-256.

python aw.py hash renders/render_01.png renders/render_01_copy.png

prints:

{
 "items": [
  {
   "file": "renders/render_01.png",
   "ahash": "bd008100998181ff",
   "dhash": "692b550175135115",
   "sha256": "a281f69710d4b9e2c7c204ee01e0b99def061bf9092bad6f060ed1a77ad60aa2"
  },
  {
   "file": "renders/render_01_copy.png",
   "ahash": "bd008100998181ff",
   "dhash": "692b550175135115",
   "sha256": "07768e2906f046865ea0700f60626d58d298db1a8fdeaaf6d116eaffc0004b1d"
  }
 ],
 "algo": {
  "name": "ahash+dhash",
  "bits": 64,
  "version": 1
 },
 "ok": true
}

Unlike info, one unreadable file fails the whole command.

dedupe

Groups near-copies by difference-hash distance. Two images whose 64-bit hashes differ in at most --threshold bits (default 6) are linked, and links chain into groups. keep holds the first file of each group plus every file in no group.

python aw.py dedupe renders

prints:

{
 "groups": [
  [
   "renders/render_01.png",
   "renders/render_01_copy.png"
  ]
 ],
 "pairs": [
  {
   "a": "renders/render_01.png",
   "b": "renders/render_01_copy.png",
   "distance": 0
  }
 ],
 "keep": [
  "renders/render_01.png",
  "renders/render_02.png",
  "renders/render_03.png",
  "renders/render_04.png",
  "renders/render_05.png",
  "renders/render_06.png"
 ],
 "threshold": {
  "algo": "dhash",
  "version": 1,
  "max_distance": 6
 },
 "ok": true
}

compare

compare prepare A B writes the pair as two side-by-side images, one per order, on grey, each labelled 1 and 2, with no file names. Each candidate is fitted into a --cell square (default 768 px). The pair id is the first 12 hex digits of a SHA-256 over the two files' SHA-256s, sorted, so the same two files give the same id in either order and in any folder.

python aw.py compare prepare dusk.png noon.png

prints:

{
 "pair": "7313d500b577",
 "images": {
  "1": "artwright/pairs/7313d500b577-order1.png",
  "2": "artwright/pairs/7313d500b577-order2.png"
 },
 "instructions": "judge each image in a fresh call; it shows two candidates labelled 1 and 2 with no file names; record the pick per order with: compare record 7313d500b577 <order> 1|2|tie",
 "ok": true
}

compare record PAIR ORDER PICK records one judged order: ORDER is 1 or 2, PICK is 1, 2 or tie. --confidence, --criteria and --judge are stored with it. It answers with the pair's state so far:

python aw.py compare record 7313d500b577 1 2 --confidence medium --criteria value read

prints:

{
 "pair": "7313d500b577",
 "resolved": {
  "status": "waiting",
  "need_order": "2"
 },
 "ok": true
}
python aw.py compare record 7313d500b577 2 1 --confidence medium --criteria value read

gives:

{
 "pair": "7313d500b577",
 "resolved": {
  "status": "decided",
  "winner": "b",
  "flipped": false
 },
 "ok": true
}

Order 1 showed dusk.png | noon.png and order 2 noon.png | dusk.png, so picks of 2 and then 1 both chose noon.png, which is side b (the second file given to prepare). How splits and re-queries resolve is on How images are judged.

The work folder now holds:

ls artwright artwright/pairs
artwright:
matches.jsonl
pairs
pairs.jsonl

artwright/pairs:
7313d500b577-order1.png
7313d500b577-order2.png

rank

Fits a Bradley-Terry model to every resolved pair in the work folder and orders the files by strength.

python aw.py rank

prints:

{
 "ranking": [
  {
   "rank": 1,
   "file": "noon.png",
   "sha256": "1139a0a24d75eabd11ce68c29b4c1c62c69782d5046fd09d1f2d4028088a3834",
   "strength": 2.13,
   "wins": 1,
   "losses": 0,
   "ties": 0,
   "close_to_next": false
  },
  {
   "rank": 2,
   "file": "dusk.png",
   "sha256": "01069b7b7057f04bb65c47b1bd01c995bcae247570c53c24e6b7dde6053350c0",
   "strength": 0.469,
   "wins": 0,
   "losses": 1,
   "ties": 0,
   "close_to_next": null
  }
 ],
 "games": 1,
 "abstained": [],
 "cycles": [],
 "cycle_count": 0,
 "top_in_cycle": false,
 "note": "close_to_next: strengths within 1.5x, never compared directly, or inside a cycle (A beat B, B beat C, C beat A); do not claim an order there. top_in_cycle: the winner is close, say so",
 "ok": true
}
Field Meaning
strength the fitted strength, normalised so the geometric mean is 1
wins, losses, ties resolved pairs only; a pair waiting for its second order is left out
close_to_next true when this row's strength is less than 1.5 times the next row's, or the two never met directly; null on the last row
games resolved pairs counted
abstained pairs whose orders disagreed: a person should see them
cycles (0.3.0) up to 20 three-way loops among decided pairs (A beat B, B beat C, C beat A), each with its files and their ranks
cycle_count (0.3.0) how many loops there are in all
top_in_cycle (0.3.0) true when the top row is in a loop: the winner is close, whatever its strength

--next N adds next_pairs: up to N pairs, taken down the ranking, of files that have no resolved result against each other yet. --write copies the ranking and abstained into verdict.json in the work folder (creating it when missing), with paths made relative to the work folder's parent. A batch with both is on the Verdict contract page.

cache

The verdict store and calibration labels, under ARTWRIGHT_HOME (default ~/.artwright). On these pages that is shown as /home/you/.artwright.

Action Does
cache put IMG --verdict JSON appends a row to verdicts.jsonl, keyed by the image's SHA-256, --rubric (default artwright-judge), --rubric-version (3 since 0.5.0, 2 in 0.3.0 and 0.4.x, 1 before), --model and --prompt-version (1), then --temperature and --seed only when given (0.3.0). A --verdict that is not JSON is stored as {"text": ...}
cache get IMG the newest row with the same key, or "hit": false
cache stats rows, labels and the rubrics seen
cache label A B a|b|tie [--shown-first a|b] [--note TEXT] a person's pick between two images, appended to calibration/labels.jsonl; --shown-first (0.3.0) records which image was shown first
cache agreement [WORK...] how consistent the judge is, from work folders and the labels (default --work)
python aw.py cache put batch/fox_03.png --model claude-opus-5-5 --verdict '{"winner": true}'

prints:

{
 "stored": "f152d4d30a807dc9d6b4ed2fe8ad398d2fe16c7e8db4e96c0c3b45505beb5307|artwright-judge|3|claude-opus-5-5|1",
 "store": "/home/you/.artwright/verdicts.jsonl",
 "ok": true
}
python aw.py cache get batch/fox_03.png --model claude-opus-5-5

prints (the time masked):

{
 "hit": true,
 "key": "f152d4d30a807dc9d6b4ed2fe8ad398d2fe16c7e8db4e96c0c3b45505beb5307|artwright-judge|3|claude-opus-5-5|1",
 "entry": {
  "key": "f152d4d30a807dc9d6b4ed2fe8ad398d2fe16c7e8db4e96c0c3b45505beb5307|artwright-judge|3|claude-opus-5-5|1",
  "sha256": "f152d4d30a807dc9d6b4ed2fe8ad398d2fe16c7e8db4e96c0c3b45505beb5307",
  "file": "batch/fox_03.png",
  "rubric": "artwright-judge",
  "rubric_version": "3",
  "model": "claude-opus-5-5",
  "prompt_version": "1",
  "temperature": null,
  "seed": null,
  "verdict": {
   "winner": true
  },
  "at": "<time>"
 },
 "ok": true
}

A different model, rubric version or prompt version is a different key, so the image is judged afresh:

python aw.py cache get batch/fox_03.png --model another-model

prints:

{
 "hit": false,
 "key": "f152d4d30a807dc9d6b4ed2fe8ad398d2fe16c7e8db4e96c0c3b45505beb5307|artwright-judge|3|another-model|1",
 "entry": null,
 "ok": true
}

A temperature and a seed join the key when given, so a verdict stored with them is found only with them:

python aw.py cache put batch/fox_01.png --model claude-opus-5-5 --temperature 0.7 --seed 42 --verdict '{"winner": false}'

prints:

{
 "stored": "a281f69710d4b9e2c7c204ee01e0b99def061bf9092bad6f060ed1a77ad60aa2|artwright-judge|3|claude-opus-5-5|1|t=0.7|s=42",
 "store": "/home/you/.artwright/verdicts.jsonl",
 "ok": true
}
python aw.py cache get batch/fox_01.png --model claude-opus-5-5

gives a miss:

{
 "hit": false,
 "key": "a281f69710d4b9e2c7c204ee01e0b99def061bf9092bad6f060ed1a77ad60aa2|artwright-judge|3|claude-opus-5-5|1",
 "entry": null,
 "ok": true
}

and the same get with --temperature 0.7 --seed 42 finds the row. A row stored under rubric version 1, as 0.1.0 to 0.2.0 stored them, is found only with --rubric-version 1:

python aw.py cache put batch/fox_04.png --model claude-opus-5-5 --rubric-version 1 --verdict '{"winner": false}'
python aw.py cache get batch/fox_04.png --model claude-opus-5-5 --rubric-version 1

The get prints (the time masked):

{
 "hit": true,
 "key": "50a3b0a92ef22cbd28159ab1bd6e70e147072bd5aa9bef871a884e1b012cd6d5|artwright-judge|1|claude-opus-5-5|1",
 "entry": {
  "key": "50a3b0a92ef22cbd28159ab1bd6e70e147072bd5aa9bef871a884e1b012cd6d5|artwright-judge|1|claude-opus-5-5|1",
  "sha256": "50a3b0a92ef22cbd28159ab1bd6e70e147072bd5aa9bef871a884e1b012cd6d5",
  "file": "batch/fox_04.png",
  "rubric": "artwright-judge",
  "rubric_version": "1",
  "model": "claude-opus-5-5",
  "prompt_version": "1",
  "temperature": null,
  "seed": null,
  "verdict": {
   "winner": false
  },
  "at": "<time>"
 },
 "ok": true
}

Without --rubric-version 1 the same get misses, with the key ending |artwright-judge|3|claude-opus-5-5|1.

python aw.py cache label batch/fox_03.png batch/fox_01.png a --note "warmer light"

gives:

{
 "stored": "/home/you/.artwright/calibration/labels.jsonl",
 "labels": 1,
 "ok": true
}

A label with the side shown first, as the curator skill records it after a coin flip:

python aw.py cache label batch/fox_01.png batch/fox_02.png a --shown-first a

gives:

{
 "stored": "/home/you/.artwright/calibration/labels.jsonl",
 "labels": 3,
 "ok": true
}

After the three put calls above and three labels:

python aw.py cache stats

prints:

{
 "store": "/home/you/.artwright/verdicts.jsonl",
 "entries": 3,
 "labels": 3,
 "rubrics": [
  "artwright-judge@1",
  "artwright-judge@3"
 ],
 "ok": true
}

cache agreement is on How images are judged.

verdict

verdict init DIR [IMG...] creates or extends DIR/artwright/verdict.json (DIR may also be the artwright folder itself), adding each image not already listed as an item with the next letter as its id. --kind (default pairwise) is used only when the file is created; --brief sets the brief, on a new or an existing file. verdict validate DIR checks it and exits 1 when it has problems. Both are on the Verdict contract page with output.

geomgate

Added in 0.4.0. Checks that a painting made over a 3D render kept the model's shapes before anyone judges it. It reads three files from the render folder: beauty.png (the render), id.png (each part a flat colour) and sprite.json (each part's ID colour, in linear 0 to 1, and the rings with their projected rim points); the contract is sprite-json.md. The painting may be any size with the same framing.

The test render mill/ is a 192 px building with a roof, water and an eight-armed wheel that must touch the water. mill_painted.png is the same picture with brush noise added:

python aw.py geomgate mill mill_painted.png --no-edges

prints:

{
 "painted": "mill_painted.png",
 "render": "mill",
 "size": [
  192,
  192
 ],
 "part": "wheel",
 "tol": {
  "centre_frac": 0.06,
  "axis_frac": 0.08,
  "tilt_deg": 8.0,
  "rim_coverage": 0.8,
  "rim_rms_frac": 0.05,
  "spoke_corr": 0.6,
  "region_anomaly": 5.0
 },
 "gates": {
  "ellipse": {
   "visible_angles": 72,
   "coverage": 1.0,
   "rim_rms": 0.0,
   "rim_worst": 0.0,
   "radius_px": 42.0,
   "match_mean": 0.999,
   "centre_off": 0.0,
   "axis_err": 0.0,
   "tilt_err": 0.0,
   "pass": true
  },
  "spokes": {
   "arms": 8,
   "profile_corr": 1.0,
   "comb_render": 0.457,
   "comb_painted": 0.455,
   "pass": true
  },
  "regions": {
   "game_px": [
    96,
    96
   ],
   "anomaly": {
    "wheel": {
     "median": 0.0059,
     "worst": 0.0125,
     "score": 0.78,
     "at_game_px": [
      16,
      66
     ]
    },
    "roof": {
     "median": 0.0059,
     "worst": 0.0135,
     "score": 0.85,
     "at_game_px": [
      57,
      20
     ]
    },
    "building": {
     "median": 0.0057,
     "worst": 0.0135,
     "score": 0.86,
     "at_game_px": [
      48,
      40
     ]
    },
    "water": {
     "median": 0.0059,
     "worst": 0.0158,
     "score": 1.0,
     "at_game_px": [
      38,
      77
     ]
    }
   },
   "failing": [],
   "pass": true
  },
  "contact": {
   "pairs": {
    "wheel:water": true
   },
   "pass": true,
   "note": "checked in the render: the model's parts meet within 3 px"
  }
 },
 "pass": true,
 "failing_gates": [],
 "ok": true
}
Gate Passes when
ellipse the painted rim's edge profile matches the render's along rays from the centre at 80% of the visible angles, the RMS shift is at most 5% of the radius, and an ellipse fitted to the shifted points is within 6% (centre), 8% (axes) and 8 degrees (tilt)
spokes the painting's light profile round the ring correlates with the render's at 0.6 or more and keeps at least half the energy at the arm count
regions at game size (--game-px, default 96) no 3 x 3 patch of a part departs from a fit of paint to render by more than 5 times the part's median residual: a new object painted in
contact each listed pair of parts touches within 3 px in the render. This checks the model, not the painting

The tolerances are in tol. They were set on one sawmill's renders and paintings (sprite-json.md). The wheel squashed to 0.8 of its height fails two gates:

python aw.py geomgate mill mill_squashed.png --no-edges

The two failing gates in what it prints:

  "ellipse": {
   "visible_angles": 72,
   "coverage": 0.903,
   "rim_rms": 0.0567,
   "rim_worst": 0.0833,
   "radius_px": 42.0,
   "match_mean": 0.739,
   "centre_off": 0.037,
   "axis_err": 0.0325,
   "tilt_err": 0.0,
   "pass": false
  },
  "spokes": {
   "arms": 8,
   "profile_corr": 0.531,
   "comb_render": 0.457,
   "comb_painted": 0.168,
   "pass": false
  },

Its regions gate still passed: the worst score, 4.74 on the building, was under 5. A dark disc painted on the roof fails regions with "failing": ["roof"] (score 9.79 at game pixel 67, 16) and passes the rest. A failing gate is a finding, not an error: pass is false, failing_gates names the gates, and the exit code is 0.

Option Effect
--part P the ring to check, when sprite.json has more than one
--spokes N the arm count when sprite.json gives none
--contact A:B parts that must touch, in place of the file's contacts (repeatable). --contact roof:water on the test render gives "pairs": {"roof:water": false} and fails contact
--game-px N the long edge the sprite ships at, for regions (default 96)
--no-edges skips edges_info
--out F also writes the report to F

Without --no-edges the output adds edges_info: edge precision and recall per part, information only. It is measured at most at 256 px on the long edge, never enlarged, so the 192 px test render gives "measured_at": [192, 192]. A part with no edges inside it reads precision and recall 1.0:

 "edges_info": {
  "measured_at": [
   192,
   192
  ],
  "tol_px": 2,
  "scores": {
   "wheel": {
    "precision": 1.0,
    "recall": 1.0,
    "paint_edges": 0,
    "render_edges": 0

sprite

Added in 0.4.0. A painted game sprite's style against reference sprites from the set it joins, at native size and on opaque pixels only: value mean and spread (L*), chroma, unique colours, the share of soft (semi-transparent) edge pixels, texture and outline value. Each must sit inside the references' range widened by --pad (0.15 of the range) on each side. Coverage, the opaque share of the canvas, has a floor only: --min-coverage (0.9) times the references' median. The candidate is never counted as a reference.

houses/ holds five painted 48 px houses with brush noise and soft edge pixels. house_new.png is one more of the same kind; house_flat.png is the same house with no noise and hard edges:

python aw.py sprite house_new.png house_flat.png --refs houses

prints:

{
 "references": 5,
 "reference_range": {
  "value_mean": [
   28.99,
   34.019,
   31.549
  ],
  "value_spread": [
   18.894,
   26.216,
   24.451
  ],
  "chroma_mean": [
   22.442,
   24.422,
   23.446
  ],
  "colours": [
   60.4,
   65.6,
   62.0
  ],
  "soft_edge_share": [
   0.018,
   0.031,
   0.022
  ],
  "texture": [
   2.852,
   2.942,
   2.896
  ],
  "outline_value": [
   25.472,
   30.108,
   27.113
  ],
  "coverage": [
   0.472,
   0.472,
   0.472
  ]
 },
 "pad": 0.15,
 "min_coverage": 0.9,
 "candidates": [
  {
   "file": "house_new.png",
   "size": [
    48,
    48
   ],
   "measures": {
    "value_mean": 31.549,
    "value_spread": 23.721,
    "chroma_mean": 23.446,
    "colours": 61,
    "soft_edge_share": 0.024,
    "texture": 2.931,
    "outline_value": 27.113,
    "coverage": 0.472
   },
   "outside": {},
   "pass": true
  },
  {
   "file": "house_flat.png",
   "size": [
    48,
    48
   ],
   "measures": {
    "value_mean": 31.049,
    "value_spread": 25.724,
    "chroma_mean": 22.992,
    "colours": 31,
    "soft_edge_share": 0.0,
    "texture": 1.145,
    "outline_value": 25.508,
    "coverage": 0.472
   },
   "outside": {
    "colours": [
     31,
     60.4,
     65.6
    ],
    "soft_edge_share": [
     0.0,
     0.018,
     0.031
    ],
    "texture": [
     1.145,
     2.852,
     2.942
    ]
   },
   "pass": false
  }
 ],
 "pass": false,
 "note": "opaque pixels at native size, never posterized; [low, high, median] of the references widened by pad times their range; coverage has a floor only: min_coverage times the references' median (0 turns it off), because bigger is fine and too small breaks the size ladder",
 "ok": true
}

Each outside entry is [value, low, high] of the widened range. The same house drawn at half size on its 48 px canvas fails on coverage (0.116 against a floor of 0.425), and also on colours and outline value:

python aw.py sprite house_small.png --refs houses
   "outside": {
    "colours": [
     51,
     60.4,
     65.6
    ],
    "outline_value": [
     30.122,
     25.472,
     30.108
    ],
    "coverage": [
     0.116,
     0.425,
     null
    ]
   },

--min-coverage 0 turns the floor off; the same command then lists only colours and outline_value.

setfit

Added in 0.5.0. One illustration against the series it joins, such as a loading screen painted to replace one of five: value mean, RMS contrast, saturation and colourfulness (as stats measures them, at 256 px), and edge density and fine grain at --width px (default 960). Each measure passes inside the references' range widened by --widen (0.3) times the range on each side; position is where the value sits, 0 at the lowest reference and 1 at the highest. --refs takes a folder or a comma-separated list, and at least two references besides the candidate.

series/ holds four painted 160 x 90 landscapes of one series. screen_new.png is a fifth; screen_smooth.png is the same scene with no brush texture, and screen_dark.png the same scene 18 L* darker:

python aw.py setfit screen_new.png screen_smooth.png screen_dark.png --refs series --width 160

screen_new.png passes every measure. The start of the smooth candidate's entry:

   "file": "screen_smooth.png",
   "pass": false,
   "failing": [
    "rms_contrast",
    "edge_density",
    "fine_grain"
   ],

and of the dark one:

   "file": "screen_dark.png",
   "pass": false,
   "failing": [
    "value_mean",
    "saturation",
    "colourfulness"
   ],

The smooth copy fails on edge_density (position -1.19), fine_grain (-2.07) and rms_contrast (-1.14); the dark one on value_mean (-1.79), and its saturation and colourfulness moved out of range too. A failing candidate exits 0 with pass: false.

--treat "CMD {in} {out}" runs each image through an external command first, such as the darkening a game lays over its screens, and gates the treated image's mean luma as treated_mean. The command runs without a shell, with {in} and {out} filled in; --treated-out DIR keeps the treated files. Here darken.py halves every channel:

import sys

sys.path.insert(0, ".")
import aw

img = aw.load(sys.argv[1])
for c in range(3):
    img.d[c::4] = bytes(v // 2 for v in img.d[c::4])
aw.save_png(img, sys.argv[2])
python aw.py setfit screen_new.png --refs series/screen1.png,series/screen2.png,series/screen4.png,series/screen5.png --width 160 --treat "python darken.py {in} {out}" --treated-out treated

The end of what it prints:

    "treated_mean": {
     "value": 0.2806,
     "set_min": 0.2613,
     "set_max": 0.3002,
     "position": 0.5,
     "pass": true
    }
   },
   "treated": "treated/screen_new.treated.png"

degrade

Deterministic damage, for making test pairs where one side is known to be worse.

Kind What it does
saturate saturation times 1.9 plus 0.1 and value times 1.15, capped
crush every channel pulled to a third of its distance from 96: flat, low contrast
crop the bottom-right 55% of the image scaled back up to full size
jpeg each 8 x 8 block pulled three quarters of the way to its mean colour, then rounded down to steps of 24
orphans stray pixels (at least 8, or one per 400 pixels) in colours sampled from the image
mixels a region a third of the size, downsampled 3x and blown back up
blur a 5 x 5 box blur
warm-shadows (0.2.0) shadows take the lights' colour: no cool fill
glow-shadows (0.2.0) shadow chroma tripled: the glowing-shadow look
uniform-sat (0.2.0) every pixel at the median chroma
midgrey (0.2.0) values pressed toward L* 50
haze-off (0.2.0) the far third made at least 1.3 times crisper and more saturated
hotspot (0.2.0) a white patch near a border
tangent (0.2.0) a line kissing a border
noise (0.6.0) Gaussian grain, SD --amount (default 8)
band (0.6.0) L* posterised to --amount levels (default 16): false contours
alias (0.6.0) a nearest-neighbour shrink by 2.7 and back up
banding (0.7.0) a ring in the second-darkest colour two pixels inside the silhouette
pillow (0.7.0) colours reassigned by distance from the silhouette, dark rim to light middle

The seven kinds from 0.2.0 and the three from 0.6.0 exist to test the measures on Light, colour and composition, which shows which of them trip their flag on the test image; banding and pillow test the pixel craft numbers (pixel). --seed (default 1) changes the result of orphans, mixels, hotspot, tangent (which border, and where) and noise; the others ignore it. --amount (0.6.0) is read by noise and band only.

python aw.py degrade renders/render_01.png -o renders/render_01_copy.png --kind jpeg

prints:

{
 "out": "renders/render_01_copy.png",
 "kind": "jpeg",
 "seed": 1,
 "size": [
  128,
  128
 ],
 "ok": true
}

Clone this wiki locally