Skip to content

Light Colour and Composition

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

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).

What a flag is

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.

light

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.png

prints:

{
 "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.38

prints:

{
 "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.

gamut

Where the image's colour sits on the CIELAB ab plane.

python aw.py gamut dusk.png --overlay gamut.png

prints:

{
 "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

accents

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.png

prints:

{
 "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.

depth

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.png

prints:

{
 "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.

comp

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 masses

prints:

{
 "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.

Text bands

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.14

The 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.

Grain and the noise floor

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.png

The 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.

Testing the flags with degrade

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.png

The 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.

Blind spots

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.

Clone this wiki locally