Repository navigation
Commands
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.
-
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 thataw.pycaught ({"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.pyruns. The usage text goes to stderr, nothing goes to stdout, and the exit code is 2. See Errors and edge cases. -
--versionprints 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,.tifand.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". Theverdict.jsonfile 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.pyprints:
{
"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.
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.
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.jpgprints:
{
"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.
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 400prints:
{
"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,150prints:
{
"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:4prints:
{
"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 512prints:
{
"out": "sprite_big.png",
"size": [
512,
512
],
"tokens": {
"standard": {
"tokens": 361,
"read_at": [
512,
512
]
},
"high": {
"tokens": 361,
"read_at": [
512,
512
]
}
},
"ok": true
}
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 256prints:
{
"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.
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 notanprints:
{
"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
|
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).
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 5prints:
{
"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:
.hexor.txtwith six-digit hex colours, or a GIMP.gplfile; - 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.hexprints:
{
"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,854c30gives:
{
"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-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.pngprints:
{
"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 16prints:
{
"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 --framesThe 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.pngThe 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"
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 1The 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 1stairs.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 --tileThe 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).
- 0.4.1: with a stated
--scale 1, blend pixels are reported intransitional_pixelsbut 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 reportstransitional_pixels0.29 and staysclean_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.
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.pngprints:
{
"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.
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 rendersprints:
{
"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 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.pngprints:
{
"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 readprints:
{
"pair": "7313d500b577",
"resolved": {
"status": "waiting",
"need_order": "2"
},
"ok": true
}
python aw.py compare record 7313d500b577 2 1 --confidence medium --criteria value readgives:
{
"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/pairsartwright:
matches.jsonl
pairs
pairs.jsonl
artwright/pairs:
7313d500b577-order1.png
7313d500b577-order2.png
Fits a Bradley-Terry model to every resolved pair in the work folder and orders the files by strength.
python aw.py rankprints:
{
"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.
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-5prints (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-modelprints:
{
"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-5gives 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 1The 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 agives:
{
"stored": "/home/you/.artwright/calibration/labels.jsonl",
"labels": 3,
"ok": true
}
After the three put calls above and three labels:
python aw.py cache statsprints:
{
"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 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.
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-edgesprints:
{
"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-edgesThe 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
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 housesprints:
{
"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.
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 160screen_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 treatedThe 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"
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 jpegprints:
{
"out": "renders/render_01_copy.png",
"kind": "jpeg",
"seed": 1,
"size": [
128,
128
],
"ok": true
}
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