Repository navigation
Light Colour and Composition
Five aw.py commands, added in 0.2.0, that describe how an image handles light, colour and composition: light,
gamut, accents, depth and comp. The ideas come from James Gurney (Color and Light), Marcos Mateu-Mestre
(Framed Ink) and Tony Roberts (Mastering Composition); the repository's
cli.md is the reference list.
The code lives in aw_measure.py, beside aw.py, and runs through it: python aw.py light IMG.
Every example below ran on dusk.png or noon.png, the 2000 x 1200 snowy landscapes described on Home:
a sky gradient, a sun disc on the left, two mountain bands, snow, and a small orange fox at about (0.62, 0.78). Like
stats, each command measures a copy shrunk to a 256 px long edge (--edge N changes that). Since 0.6.0 that copy
averages every source pixel (Pillow's box filter, or 2x2 halving without Pillow), so fine stripes no longer read as
texture; the numbers without Pillow differ slightly (Errors and edge cases).
Each command returns numbers, readings (the numbers in words) and flags. A flag says where to look. It is never
a grade and never a reason on its own: the skill opens the region, confirms it on the image, and then writes the
finding with the region named. Every output carries the same reminder in its note.
The thresholds were set on 2026-10-22 from how often each flag fired on 7 museum works and 48 AI stills: a flag that fires on most images says nothing. No owner labels were used, so the repository calls them uncalibrated. The share of the AI stills each flag fired on is in brackets.
| Flag | Fires when | Means |
|---|---|---|
warm_shadows (light) |
lights' b* above 5 and shadows within 1 of them (6%) | no cool fill from the sky; right under artificial or alien light |
glowing_shadows (light) |
shadow chroma over 1.15 times the lights' (13%) | the glowing-shadow look |
low_value_gap (light) |
light and shadow medians under 2 steps apart (21%) | overcast or over-filled; wrong only when the brief says sun |
fruit_salad (gamut) |
seven or more hue sectors and median chroma over 30 (0%) | no colour family leads |
competing_accent (accents) |
an accent of 0.2% or more, 0.35 from the focal cell, accents under 5% in all (4%) | an off-hue spot pulls the eye off the subject |
no_recession (depth) |
far band's contrast and chroma both at least 95% of the middle band's (15%) | the distance does not recede (the band proxy is weak) |
warm_distance (depth) |
far band's b* 3 above the middle's (4%) | right only toward a low sun |
mid_dominant (comp) |
the middle of three value masses covers over 60% (0%) | middle-value mumbling |
fragmented (comp) |
over 12 masses above 1%, or the largest three under half the area (13%) | a busy design with no big shapes |
competing_focal (comp) |
the focal peak beats the strongest cell outside its neighbours by under 3% (23%) | two places claim the eye |
focal_off_subject (comp) |
the peak falls outside --subject
|
the eye lands off the subject |
edge_pull (comp) |
edges or chroma in a 5% border band or 12% corner over twice the image mean (6%) | the border pulls the eye out |
frame_tangent (comp) |
a contour runs parallel to a side, within 2% of it, for over 20% of its length (13%) | a contour kisses the frame |
horizon_split (comp) |
a horizontal edge crossing 60% of the width sits between 45% and 55% of the height (6%) | the frame is cut in half |
horizon_through_head (comp) |
that line crosses the top quarter of --subject
|
the horizon runs through the head |
blocked_entrance (comp) |
one horizontal edge crosses over 85% of the width in the bottom 12% (4%) | a bar across the way in |
uniform_sharpness (comp) |
the sharpest cell's Laplacian variance under 3 times the median cell's, after grain's floor (20 times the noise variance) comes off every cell (6%; 3 of 60 AI stills before and after the floor, 0.6.0) | everything equally in focus; grain alone no longer fires it |
hot_spot_near_border (comp) |
a region of 0.05% to 1%, at least 12 L* and 2.5 local SD above its surroundings, within 10% of a border and outside --subject (17%) |
a bright spot at the edge pulls the eye out |
busy_text_band (comp, 0.5.0) |
a --text-band has edge_pull or contrast_pull of 1.0 or more: as busy as the frame or busier (3 of 10 top and bottom 15% bands on one five-screen game series) |
the text will fight the picture there |
weak_figure_ground (comp) |
under 10 L* between --subject and a ring around it |
the subject does not separate |
Boxes (light --box, comp --subject) are x,y,w,h as fractions of the width and height, from the top left.
Splits the pixels into a light family and a shadow family (Otsu's threshold on CIE L*, after dropping the darkest and lightest 1%) and compares them.
python aw.py light dusk.pngprints:
{
"file": "dusk.png",
"alpha": {
"transparent_share": 0.0,
"excluded": false
},
"proxy": "global (one surface with --box is more trustworthy)",
"threshold_L": 63.0,
"separation": 0.87,
"lights": {
"share": 0.377,
"L_median": 91.8,
"a_mean": -0.32,
"b_mean": -3.69,
"C_median": 4.61,
"hue": "azure"
},
"shadows": {
"share": 0.616,
"L_median": 33.7,
"a_mean": 19.05,
"b_mean": 5.38,
"C_median": 23.58,
"hue": "pink"
},
"value_gap": 58.1,
"value_gap_steps": 5.8,
"temp_shift_b": -9.07,
"tint_shift_a": -19.37,
"chroma_ratio": 5.11,
"flags": [
"glowing_shadows"
],
"readings": [
"lights cooler than shadows by 9.1 b*",
"value gap 5.8 steps of ten",
"shadows more saturated than lights: the glowing-shadow look"
],
"note": "flags are uncalibrated heuristics (references/cli.md): confirm each by looking before writing it down",
"ok": true
}
| Field | Meaning |
|---|---|
threshold_L |
the L* that splits lights from shadows |
separation |
(0.6.0) Otsu's between-class share of the L* variance, 0 to 1: how cleanly the picture splits into two value families. Under 0.5 a reading says it is one family more than two and the light and shadow numbers are weak. dusk.png gives 0.87; the 16 x 16 colour gradient drawn at 4x gives 0.742 |
lights, shadows
|
each family's share of pixels, median L*, mean a* and b*, median chroma, and hue name (neutral when its mean colour is within 3 of grey) |
value_gap, value_gap_steps
|
the difference of the two median L* values, and the same in steps of ten |
temp_shift_b |
lights' b* minus shadows' b*: negative when the lights are cooler (bluer) than the shadows |
tint_shift_a |
the same on a* (green to magenta) |
chroma_ratio |
shadows' median chroma over the lights'; null when the lights are nearly grey |
Here the whole frame mixes the snow with the sky and mountains, so the result says more about the picture's
parts than about one lit surface; the proxy line says so. --box measures one surface, here the snow (the
bottom 38%):
python aw.py light dusk.png --box 0,0.62,1,0.38prints:
{
"file": "dusk.png",
"alpha": {
"transparent_share": 0.0,
"excluded": false
},
"proxy": "box",
"threshold_L": 72.2,
"separation": 0.969,
"lights": {
"share": 0.966,
"L_median": 91.8,
"a_mean": -0.5,
"b_mean": -4.56,
"C_median": 4.61,
"hue": "azure"
},
"shadows": {
"share": 0.034,
"L_median": 57.0,
"a_mean": 20.81,
"b_mean": 19.66,
"C_median": 25.7,
"hue": "red"
},
"value_gap": 34.8,
"value_gap_steps": 3.5,
"temp_shift_b": -24.22,
"tint_shift_a": -21.31,
"chroma_ratio": 5.57,
"flags": [
"glowing_shadows"
],
"readings": [
"lights cooler than shadows by 24.2 b*",
"value gap 3.5 steps of ten",
"shadows more saturated than lights: the glowing-shadow look"
],
"note": "flags are uncalibrated heuristics (references/cli.md): confirm each by looking before writing it down",
"ok": true
}
The shadow family in the box (3.4% of it) is mostly the fox, with the foot of the mountains along the box's top
edge, so glowing_shadows here points at the fox's orange against white snow, not at a shadow. That is the kind of check a flag asks for before it becomes a finding.
Where the image's colour sits on the CIELAB ab plane.
python aw.py gamut dusk.png --overlay gamut.pngprints:
{
"file": "dusk.png",
"alpha": {
"transparent_share": 0.0,
"excluded": false
},
"cast": {
"a": 11.71,
"b": 1.75,
"C": 11.84,
"hue": "pink"
},
"chromatic_share": 0.63,
"shape": "analogous",
"hue_sectors": 5,
"sectors": [
0.121,
0.541,
0.015,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.142,
0.133,
0.048
],
"area_fraction": 0.057,
"chroma_median": 23.7,
"families": [
{
"mass": 0.985,
"hue": "pink"
}
],
"flags": [],
"readings": [
"everything sits in a pink cast"
],
"note": "flags are uncalibrated heuristics (references/cli.md): confirm each by looking before writing it down; sectors are 30-degree CIELAB hue bins from 0 (a* axis)",
"overlay": "gamut.png",
"ok": true
}
| Field | Meaning |
|---|---|
cast |
the mean a* and b* of every pixel, its chroma C and hue name; a C over 10 adds the reading "everything sits in a ... cast" |
chromatic_share |
share of pixels with chroma over 8 |
sectors |
the chroma mass of the chromatic pixels in twelve 30-degree hue sectors, starting at the +a* axis |
hue_sectors |
how many sectors hold at least 2% of that mass |
shape |
mono, analogous, complementary, triad, mood+accent, multi, full, or neutral when almost nothing is chromatic |
area_fraction |
the hull of the chromatic pixels as a share of sRGB's footprint on the ab plane |
chroma_median |
median chroma of the chromatic pixels |
families |
the colour families found, with their mass and hue |
overlay |
with --overlay OUT: a PNG of the ab plane with the hull drawn |
High-chroma regions whose hue is more than 90 degrees from the dominant one: the spots of a contrasting colour.
dusk.png has none, and prints "readings": ["no accent: the image is one colour family"]. On noon.png the
orange fox stands against a blue scene:
python aw.py accents noon.pngprints:
{
"file": "noon.png",
"alpha": {
"transparent_share": 0.0,
"excluded": false
},
"focal_cell": [
0.188,
0.562
],
"accents": [
{
"share": 0.0062,
"hue": "orange",
"centre": [
0.618,
0.777
],
"focal_distance": 0.481
}
],
"note": "flags are uncalibrated heuristics (references/cli.md): confirm each by looking before writing it down",
"dominant_hue": "azure",
"dominance": 0.926,
"accent_share": 0.0062,
"flags": [
"competing_accent"
],
"readings": [
"orange accent at (0.62, 0.78), away from the focal cell: it may pull the eye off the subject"
],
"ok": true
}
focal_cell is the same cell stats reports (the highest local contrast on an 8 x 8 grid), and focal_distance
is how far the accent's centre is from it, in fractions of the frame. Here the accent is the fox itself, the
subject. The flag fires because the focal cell sits on the left, where the sun meets the ridge. Looking settles
it: the fox is meant to be seen. What is left to check is whether the eye goes to the sun first, which comp
answers below.
Compares contrast, the darkest darks, chroma and b* in near, middle and far bands. In atmospheric perspective,
contrast and chroma fade and the darks lighten with distance; depth checks whether they do.
python aw.py depth dusk.pngprints:
{
"file": "dusk.png",
"alpha": {
"transparent_share": 0.0,
"excluded": false
},
"proxy": "bands (weak: assumes distance rises up the frame, as in a landscape; pass --depth)",
"near": {
"contrast": 5.65,
"dark_p5": 91.8,
"C_median": 4.61,
"b_mean": -3.51
},
"middle": {
"contrast": 20.62,
"dark_p5": 17.3,
"C_median": 32.67,
"b_mean": 14.44
},
"far": {
"contrast": 8.07,
"dark_p5": 14.6,
"C_median": 20.59,
"b_mean": -5.59
},
"flags": [],
"readings": [
"contrast near 5.7, middle 20.6, far 8.1",
"chroma near 4.6, middle 32.7, far 20.6",
"darks get darker into the distance"
],
"note": "flags are uncalibrated heuristics (references/cli.md): confirm each by looking before writing it down",
"ok": true
}
Without a depth map the bands are the bottom, middle and top thirds of the frame, which assumes distance rises up
the picture, as in a landscape; the proxy line calls that weak. --depth MAP takes a depth image in the Depth
Anything convention (bright is near); --far-bright reads a map where bright is far, such as Blender's Mist pass.
With a vertical gradient drawn as the map for dusk.png, the proxy became "depth map (bright = near)" and the
numbers moved slightly (middle contrast 21.09 instead of 20.62).
The near band here is snow, so its contrast is low; the far band (sky) has less contrast and chroma than the middle
band (mountains and sunset), so no_recession stays quiet.
Composition: value masses, the focal peak, the pull of the frame's edges and corners, contours that touch the
frame, the horizon, a bar across the bottom, where the sharpest detail is, small bright spots, and how the subject
separates from its surroundings. --subject gives the intended subject, here the fox; --out DIR writes
masses3.png and masses4.png, the value-mass maps.
python aw.py comp dusk.png --subject 0.56,0.745,0.12,0.07 --out massesprints:
{
"file": "dusk.png",
"measured_at": [
256,
154
],
"margin_trimmed": null,
"alpha": {
"transparent_share": 0.0,
"excluded": false
},
"masses": {
"3": {
"centres_L": [
24.6,
48.1,
91.6
],
"shares": [
0.35,
0.283,
0.367
],
"masses_over_1pct": 4,
"largest3_share": 0.928,
"map": "masses/masses3.png"
},
"4": {
"centres_L": [
21.7,
38.1,
52.4,
91.6
],
"shares": [
0.265,
0.194,
0.174,
0.366
],
"masses_over_1pct": 5,
"largest3_share": 0.756,
"map": "masses/masses4.png"
}
},
"focal": {
"peak": [
0.281,
0.531
],
"runner_up": [
0.15625,
0.59375
],
"margin": 0.052
},
"border_pull": {
"zones": {
"top": [
0.48,
1.44
],
"bottom": [
0.0,
0.24
],
"left": [
0.56,
1.13
],
"right": [
0.89,
1.05
],
"top_left": [
0.49,
1.26
],
"top_right": [
0.49,
1.26
],
"bottom_left": [
0.0,
0.24
],
"bottom_right": [
0.0,
0.24
]
},
"columns": [
"edges",
"chroma"
],
"note": "1.0 is the image's mean"
},
"frame_tangents": {},
"horizon": {
"found": true,
"coverage": 0.992,
"y": 0.636,
"band": "between"
},
"bottom_barrier": 0.0,
"sharpness": {
"sharpest_cell": [
0.1875,
0.5625
],
"ratio_to_median": 999,
"noise_sigma": 0.0,
"noise_floor": 0.0,
"focal_distance": 0.099
},
"hot_spots": {
"count": 0,
"items": []
},
"figure_ground": {
"delta_L": -30.7,
"delta_E": 34.8,
"background_brighter": true
},
"flags": [
"focal_off_subject"
],
"note": "flags are uncalibrated heuristics (references/cli.md): confirm each by looking before writing it down; not measured here: armature lines, intervals, interior tangents (ask a vision model)",
"ok": true
}
| Field | Meaning |
|---|---|
margin_trimmed |
when a light mat or backdrop round a photographed work was trimmed before measuring, the trimmed box; otherwise null
|
masses |
k-means on L* at 64 px, with three and four groups: each group's centre L*, its share, the number of connected masses over 1% of the area, and the share of the largest three |
focal |
the cell that most draws the eye (peak), the strongest cell outside its neighbours (runner_up), and the peak's margin over it |
border_pull |
for each border band and corner, its edge density and its chroma against the image's mean (1.0 is the mean) |
frame_tangents |
each side where a contour runs along the frame for over 20% of its length, with that length |
horizon |
the strongest horizontal edge: found when it crosses 60% of the width, its height y and the band it falls in (upper third, middle, lower third or between) |
bottom_barrier |
the widest horizontal edge in the bottom 12%, as a share of the width |
sharpness |
the cell with the most fine detail (variance of the Laplacian), its ratio to the median cell (capped at 999), and its distance from the focal peak |
hot_spots |
small bright regions and whether each is near a border |
figure_ground |
with --subject: the L* and colour difference (delta E 2000) between the subject and a ring round it, and whether the background is brighter |
Read against the picture: the focal peak (0.281, 0.531) is where the sun disc meets the ridge on the left, not the
fox, so focal_off_subject fires, and the rubric makes the judge answer it: either the sun is meant to lead, or the
image loses for it. ratio_to_median hits its cap because the median cell, flat sky or snow, has almost no fine
detail.
Since 0.5.0, comp --text-band x,y,w,h (repeatable, on the untrimmed frame) says how busy the place where a title
or a caption will sit is. edge_pull is the band's mean Sobel edge strength on L* over the frame's, and
contrast_pull its L* standard deviation over the frame's; either at 1.0 or more marks the band busy and adds
busy_text_band. The test picture title.png (160 x 96) has a flat sky in its top 14% and dense black and white
speckle in its bottom 14%:
python aw.py comp title.png --text-band 0,0,1,0.14 --text-band 0,0.86,1,0.14The end of what it prints:
"text_bands": [
{
"band": "0,0,1,0.14",
"edge_pull": 0.0,
"contrast_pull": 0.0,
"mean_L": 72.4,
"busy": false
},
{
"band": "0,0.86,1,0.14",
"edge_pull": 4.32,
"contrast_pull": 2.36,
"mean_L": 49.4,
"busy": true
}
],
"flags": [
"fragmented",
"edge_pull",
"busy_text_band"
],
Without --text-band the output has no text_bands key. The 1.0 threshold was set on one five-screen game series
(cli.md); it is not calibrated beyond that.
Since 0.6.0, stats reports noise: the standard deviation of grain in L* units, from the flattest tenth of a
16 x 16 grid of the measuring copy (Immerkaer's estimator), read as clean under 1, fine grain under 3, otherwise
noisy. comp takes the same estimate (noise_sigma) and removes a floor of 20 times its variance from each
cell's sharpness before it decides uniform_sharpness, so grain alone no longer fires that flag.
The estimate is made on the 256 px copy, so it depends on the image's size. The same Gaussian grain (degrade --kind noise --amount 16) added to dusk.png at 2000 x 1200 reads as clean, because the reduction averages it away:
python aw.py degrade dusk.png -o dusk_noise.png --kind noise --amount 16
python aw.py stats dusk_noise.pngThe noise part of what stats prints:
"noise": {
"sigma_L": 0.5,
"reading": "clean",
"proxy": "flattest tenth of 16x16 cells",
"note": "grain, not texture: SD in L* units"
},
Added to a 256 px copy of the same picture, it reads as noise:
python aw.py fit dusk.png -o dusk_256.png --long 256
python aw.py degrade dusk_256.png -o dusk_256_noise.png --kind noise --amount 16
python aw.py stats dusk_256_noise.png "noise": {
"sigma_L": 3.93,
"reading": "noisy",
comp dusk_256_noise.png then gave noise_sigma 3.93, noise_floor 0.03089 and a sharpness ratio of 5.18, with
no flags. Since 0.7.2, cli.md states that the estimate is taken on the measuring copy, whose long edge is at
most --edge (default 256 px). Read noise as grain at that size, not at full size.
aw.py degrade has seven kinds, added with the measures in 0.2.0, that damage an image in a known way so a flag can be
checked against a known answer (Commands lists them). On a real image each is built to trip its
measure. On the drawn dusk.png some did and some did not:
| Kind | Measured with | Result on dusk.png
|
|---|---|---|
glow-shadows |
light |
glowing_shadows, shadow chroma 69.38 against 23.58 before, chroma_ratio 15.05 |
tangent |
comp |
frame_tangent ("bottom": 0.5) and edge_pull (bottom band edges 5.54 times the mean); competing_focal fired too, with a focal margin of 0.015 (with Pillow; 0.042 and no flag without) |
hotspot --seed 2 |
comp --subject ... |
hot_spot_near_border, one spot of 0.09% at (0.177, 0.056) |
hotspot --seed 1, 3 or 4
|
comp |
no hot spot found: the patch landed where it did not stand out |
warm-shadows |
light |
shadows took the lights' colour (temp_shift_b -0.12, was -9.07), but no flag: warm_shadows needs warm lights, and the snow is cool |
haze-off |
depth |
far contrast 10.49 (was 8.07) and chroma 26.32 (was 20.59), still under the middle band, so no no_recession
|
midgrey |
comp |
the three mass centres moved to L* 41.0, 49.3 and 64.5 (were 24.6, 48.1 and 91.6), but the middle mass covers 28%, under the 60% for mid_dominant
|
uniform-sat |
gamut |
median chroma 17.8 (was 23.7), no flag; nothing in gamut flags flat saturation |
The glowing-shadow case in full:
python aw.py degrade dusk.png -o dusk_glow.png --kind glow-shadows
python aw.py light dusk_glow.pngThe second command prints:
{
"file": "dusk_glow.png",
"alpha": {
"transparent_share": 0.0,
"excluded": false
},
"proxy": "global (one surface with --box is more trustworthy)",
"threshold_L": 63.0,
"separation": 0.881,
"lights": {
"share": 0.377,
"L_median": 91.8,
"a_mean": -0.27,
"b_mean": -3.73,
"C_median": 4.61,
"hue": "azure"
},
"shadows": {
"share": 0.616,
"L_median": 33.5,
"a_mean": 54.56,
"b_mean": 7.84,
"C_median": 69.38,
"hue": "pink"
},
"value_gap": 58.3,
"value_gap_steps": 5.8,
"temp_shift_b": -11.57,
"tint_shift_a": -54.83,
"chroma_ratio": 15.05,
"flags": [
"glowing_shadows"
],
"readings": [
"lights cooler than shadows by 11.6 b*",
"value gap 5.8 steps of ten",
"shadows more saturated than lights: the glowing-shadow look"
],
"note": "flags are uncalibrated heuristics (references/cli.md): confirm each by looking before writing it down",
"ok": true
}
Three more kinds came in 0.6.0: noise (Gaussian grain, SD --amount, default 8), band (L* posterised to
--amount levels, default 16: false contours) and alias (a nearest-neighbour shrink by 2.7 and back). Each prints
the same short result as the others:
python aw.py degrade dusk.png -o dusk_band.png --kind band --amount 8{
"out": "dusk_band.png",
"kind": "band",
"seed": 1,
"size": [
2000,
1200
],
"ok": true
}
A flag that stays quiet on a test image is not broken: the thresholds were set on real paintings and AI stills, and a drawing of flat colour bands sits outside that range. Test a measure on images like the ones it will judge.
From cli.md. A photographed sheet with a wide paper margin keeps it, because the trim needs every side's band under
an eighth of the side; its edges then read as tangents. Designs built on lines to the edge (Mondrian) flag
edge_pull and frame_tangent by intent. The depth band proxy assumes distance rises up the frame. Not
measured: armature lines, intervals, interior tangents; the skill asks a vision model and checks its answer against
comp.
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