Ground in masses: a short course by The ground student

The working thread

Friday 25 September 2026, 23:58 (PDT), for 2 h 59 min. 280 steps, 121 images looked at.

This is the student's own thread: the brief it was given, what it wrote, each step it took, and every image it opened, shown as it saw it (possibly downscaled). Some of these images were later overwritten in the study folder, so this is the only place they survive. The raw record is the transcript.

The brief

You're a student learning to paint one thing beautifully, in the painterly pixel-art language of Mark Ferrari: the ground itself. That means earth, turf, gravel, forest floor and meadow floor as big, designed value masses, the stage everything else stands on.

Why this matters. A studio of students has been painting elements (cobbles, driftwood, willow, water, grass) in ~/work/pixelart-studies/. After 13 rounds of close comparison, their critic found one weakness common to all of them: the ground is texture everywhere at a single scale, with no big value masses. Uniform speckle under beautiful objects. Ferrari's ground is never like that. It's built from a few large shapes: shadowed banks, lit ledges, pools of light and dark, slopes turning. Texture lives inside those masses and follows them. The driftwood student proposed copying his near-ground masses (the ledges in Mountain Stream, V19; the banks in Mirror Pond, V16) as a starting point. The critic's full read is in ~/work/pixelart-studies/READY.md and elements/*/CRITIQUE.md.

The studio around you (read freely):

  • READY.md: techniques judged ready, and the recurring failures.
  • The pedagogy students' intermediate studies, their best work: pedagogy/master-copy/out/cmp_c2.png and cmp_c3.png (Ferrari's grass mound and bank: form classes across a mound), pedagogy/atelier/stage1–3 (value planning and form), and pedagogy/analysis/img (his value structure analysed: notans, value groups).
  • exemplars/: two student paintings with real magic.
  • Ferrari's indexed scenes: pedagogy/master-copy/ref/lw/ and aerial/refs/ferrari_lw/. The index maps are form maps, so you can read his ground's structure directly.
  • SYNTHESIS.md: notes on what earlier students found. Read it as inspiration, not a spec.

How the best students worked (take what helps; you own your approach):

  • Built up from the smallest form, one stage at a time, staying at each stage until it was truly convincing.
  • Started from a master copy of the closest Ferrari passage, reproduced by discovering the procedure and compared at 8×. Then moved toward their own subject a step at a time.
  • Close-read real photos: ground at 2 m, 10 m and 50 m, in sun and shade. Use python3 ~/work/pixelart-studies/tools/imgfetch.py "<query>" -n 12 -o <dir> --sheet.
  • Put work side by side at 8×: photo | theirs | Ferrari.

A useful outcome for the whole studio:

  • A way to lay down the big masses of any ground: value shapes driven by landform, light, shadow and moisture.
  • How each material's texture lives inside those masses: size, density and contrast following the mass.
  • How the result holds across distance.

The bar, stated plainly: ground that could sit beside Ferrari's at 8× and at a glance. "Improved" isn't done. Expect many iterations per stage, and honest "not there yet, here's why" notes are valued. Time and compute are generous.

Deliverables in ~/work/pixelart-studies/elements/ground/:

  • LESSONS.md with stage images and honest comparisons;
  • the technique as code with parameters (ground type, landform, light and hour, moisture, distance, seed);
  • labelled sheets;
  • a short "how other students can use this" note at the end. The cobbles, driftwood, willow and channel techniques are the obvious first users; if you can, show one of them sitting on your ground.

Read anywhere under ~/work/pixelart-studies/, write only in your folder. Resources: nice -n 10, at most 4 threads, OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 for numpy. If anything would help, say so in LESSONS.md.

The thread

Bash: Survey the studio directory and READY.md
cd ~/work/pixelart-studies && ls; echo; cat READY.md | head -150; ls elements pedagogy exemplars tools; ls elements/ground 2>/dev/null

# output:
NIGHT_LOG.md
READY.md
SYNTHESIS.md
aerial
braid
elements
exemplars
forest-floor
grass
massing
moodboards
pedagogy
rock
shared
sky
snow
tools
transfer
water

# Ready to use: techniques from the studio (2026-09-25 night)

Techniques that the studio critic, after 13 rounds of 4–10× comparison against photos and Ferrari's own pixels, judged Ferrari-grade or ready. Each links to the student's work, which has the code and the lessons. Offered to the voxsim painting lanes as a resource, not a spec.

**Palette as light** (all students). One index map, many hours or seasons: swapping the palette relights the scene, as Ferrari's own files do.

- elements/cobbles (hour palettes)
- elements/willow (season × hour sheet)
- elements/channel (midday, overcast, dusk)

**Water** (elements/channel; LESSONS.md, src/technique.py; best image: out/s5_low_dusk_4x.png)

- Water is the sky's own ramp, indexed by reflected elevation, with checker as the water's material.
- Near edge light, far edge dark, with stepped bites.
- Reflections copy the painted pixels, then compress the values.
- A sheen band under the far bank.
- Far channels are one-row runs; detail comes from the row footprint.
- A "milky vs clear" setting: glacial water is nearly opaque, and a milky mirror palette stops midday water going swimming-pool blue.

**Stones and gravel** (elements/cobbles; cobbles_api.py; best: img/sheet_A_hour_x_distance.png)

- Darkness goes only in the pockets between stones, never as an outline.
- Stones are lopsided, faceted 3-D shapes, not ellipses.
- A stone → tick → grain distance ladder with no pop.
- The Ferrari boulder rules: a Bayer 2×2 lit face, a solid crown, a dark crescent ~20% of the minor size, and reflected-light edge pixels against dark neighbours.

**Cast shadows** (elements/willow, elements/driftwood). The ground itself, two steps darker, with its texture kept inside.

**Contact** (elements/willow). Grass overlapping the foot of a bush or stone.

**Willow leaves** (elements/willow). A narrow leaf stroke at willow scale, and silver undersides as pale flecks on dark. David thinks the willow may exceed the existing trees.

**Wood** (elements/driftwood; src/driftwood.py)

- A five-class cylinder shading order proven against Ferrari's own V09 trunk, with the lit band kept free of checker.
- Backlit rims.
- Grain, checks and knots drawn as clean runs.
- Piles separated by value.
- Wet wood.

**Grass zoom** (elements/grass-lod). A 64-frame zoom with no pops; contrast compresses steadily with distance.

**Far and iso** (aerial/; lib/tech_*.py; measure/). Measured canopy rules (crown spacing 2.2–2.9 lit-cap radii, lit share 0.35–0.56, conifers still read as upright spires), rivers from above as dark mirrors, and haze that lifts the black point and shrinks the value range.

## Not ready (from the critic)

- Cast shadows across stones.
- Riffles (a field of marks, not a few jets with plain mirror between).
- Braid bars from above (cut paper, not lobes).
- Evenly spaced grass f
Bash: Check ground folder, read synthesis and exemplars notes
cd ~/work/pixelart-studies && ls -la elements/ground; cat SYNTHESIS.md | head -200; cat exemplars/README.md

# output:
total 0
drwxr-xr-x   2 user  staff   64 Sep 25 23:57 .
drwxr-xr-x  15 user  staff  480 Sep 25 23:57 ..
# What earlier students discovered: notes on their craft

These are notes on the painstaking work of the students who came before you:

- four who each spent hours learning Ferrari's language on one small scene (`pedagogy/{atelier,master-copy,analysis,observation}`);
- seven who studied single topics.

They copied his pixels crop by crop, measured his grass against real grass, built a boulder up from a lit sphere, and diagrammed his picture before daring to paint it. What they found is here to inspire you and save you time. It isn't a specification. You'll see things they didn't, and some of what they wrote will turn out wrong or incomplete for your subject. Follow your own eyes. Their best work is in their intermediate studies (e.g. `master-copy/out/cmp_c*.png`, `atelier/stage2/`, `observation/studies/translate_*.png`, `analysis/img/`); those are worth looking at more than their final painters.

The tags below say how each finding came about:

- **[shown]**: a student built it and it worked.
- **[measured]**: a student measured it in Ferrari's pixels or in photos.
- **[mine]**: the integrator's reading, not yet tried by anyone.

Striking to us: the four students, working apart, found much the same underlying ideas.

## The convergence

Working alone, all four arms landed on the same architecture:

- **Paint indices, not colours.**
  - Master copy found that Ferrari's index map is literally a form map: indices are bins of surface direction, and "the palette is the light". The same index image serves morning and afternoon. [measured]
  - Atelier paints in "step space" along one material ramp. [shown]
  - Sky found that two of Ferrari's hour pairs are identical pixels. [measured]
  - Analysis found that value is the structure and colour is temperature and distance. [measured]
- **Two light families that never touch.** Atelier's terminator rule [shown], analysis's three-value picture plan with every mass kept inside its group [measured], and rock's lit and shadow groups [shown] are the same idea at two scales.
- **Dither mixes only adjacent steps of one ramp.** All four arms use it. Ordered 2×2 dither suits hard or precise things (rock, sky seams, water rows); random or diffusion dither suits organic texture (lawns, foam). Master copy measured this [measured]; analysis cites Ferrari's GDC talk for the same split.
- **The medium's own geometry.** Contours are clean runs (1:1, 2:1, 4-4-4 flats), measured on Ferrari's crests by analysis and atelier [measured]. Atelier redraws every major contour by snapping edges to clean ratios [shown]. Masses meet level ground or water in a dead-flat run with a 1-px darker contact line [measured: analysis, observation].
- **Clean runs are for narrow forms; mass boundaries are where texture ends.** David's correction (2026-09-25): the clean-run rule came from the *grass strokes*. It applies to anything n
Bash: List element folders and Ferrari references
cd ~/work/pixelart-studies && ls elements/*/; ls elements/_critic; ls pedagogy/master-copy pedagogy/master-copy/ref pedagogy/master-copy/ref/lw | head -80; ls aerial/refs/ferrari_lw | head -50

# output:
elements/_critic/:
LOG.md
cal_V09_2x.png
cal_V09_bush_7x.png
cal_V09_trunk_7x.png
cal_V19_2x.png
cal_V19_rocks_7x.png
r10
r11
r11_grass_f08-11.png
r11_grass_f23-26.png
r11_grass_f40-43.png
r12
r13
r1_drift_s1_left_8x.png
r2
r3
r3_cobbles_nestled_8x.png
r4
r5
r6
r7
r8
r9
wait.sh
zoom.py

elements/broadleaf/:
__pycache__
bark.py
bl.py
glyphs.py
lw.py
moss.py
palette.py
refs
render.py
s0_copy.py
s1_clump.py
stage0
stage1
tmp
tree.py

elements/channel/:
CRITIQUE.md
LESSONS.md
out
photos
src
study

elements/cobbles/:
CRITIQUE.md
LESSONS.md
__pycache__
bar.py
cobble.py
cobbles_api.py
compare_sheets.py
edges.py
img
materials.py
palette.py
refs
run.sh
scene.py
scratch
stage0_copy.py
stage1.py
stage2.py
stage3.py
stage4.py
stage5.py
stage6.py
stage7.py
stone.py
util.py
water.py

elements/conifers/:
__pycache__
cmp.py
grow.py
lw.py
paint.py
palette.py
refs
render.py
s0_copy.py
s0_tune.py
s1_branch.py
s1_col.py
s1_grey.py
s1_near.py
s2_ages.py
stage0
stage1
stage2
stats.py
t0.py
tgrey.py
tmp
tree.py

elements/deer-elk/:
refs
sheets
src
stages

elements/driftwood/:
CRITIQUE.md
LESSONS.md
refs
scratch
sheets
src
stages

elements/grass-lod/:
CRITIQUE.md
cache
img
photos
ref
src

elements/ground/:

elements/people/:
refs
sheets
src
stages

elements/steppe/:
__pycache__
lw.py
refs
s0_tufts.py
sp.py
stage0

elements/understory/:
__pycache__
fern.py
leaves.py
lwload.py
refs
stage0
stage0_leaf.py
ucore.py
work

elements/willow/:
CRITIQUE.md
LESSONS.md
__pycache__
bush.py
context.py
copyharness.py
foliage.py
glyphstats.py
lw.py
palette.py
refs
s1_cal.py
s1_form.py
s1_tex.py
s_try.py
search0.py
search_c2.py
sheets
sheets.py
stage0
stage0_copy.py
stage1
stage1.py
stage2
stage2.py
stage3
stage3.py
stage4
stage4.py
stage5
stage5.py
transfer.py
willow.py
wpaint.py
LOG.md
cal_V09_2x.png
cal_V09_bush_7x.png
cal_V09_trunk_7x.png
cal_V19_2x.png
cal_V19_rocks_7x.png
r10
r11
r11_grass_f08-11.png
r11_grass_f23-26.png
r11_grass_f40-43.png
r12
r13
r1_drift_s1_left_8x.png
r2
r3
r3_cobbles_nestled_8x.png
r4
r5
r6
r7
r8
r9
wait.sh
zoom.py
pedagogy/master-copy:
LESSONS.md
crops
out
ref
src

pedagogy/master-copy/ref:
lw
native.png

pedagogy/master-copy/ref/lw:
V01.json
V01.png
V02.json
V02.png
V09.json
V09.png
V11AM.json
V11AM.png
V13.json
V13.png
V14.json
V14.png
V16.json
V16.png
V16PM.json
V16PM.png
V19.json
V19.png
V19PM.json
V19PM.png
V29PM.json
V29PM.png
V30.json
V30.png
CORAL.json
CORAL.png
V01.json
V01.png
V02.json
V02.png
V03.json
V03.png
V04.json
V04.png
V05HAUNT.json
V05HAUNT.png
V05RAIN.json
V05RAIN.png
V07.json
V07.png
V08.json
V08.png
V08AM.json
V08AM.png
V09.json
V09.png
V10.json
V10.png
V11AM.json
V11AM.png
V13.json
V13.png
V14.json
V14.png
V15.json
V15.png
V16.json
V16.png
V16PM.json
V16PM.png
V17.json
V17.png
V19.json
V19.png
V19PM.json
V19PM.png
V20.json
V20.png
V25HEAT.json
V25HEAT.png
V26SNOW.json
V26SNOW.png
V27.json
V27.png
Bash: Read the critic log tail
cd ~/work/pixelart-studies && tail -120 elements/_critic/LOG.md; ls elements/_critic/r13

# output:
# Critic log

Viewing tool: `_critic/zoom.py` (nearest-neighbour crops). Calibration crops of Ferrari natives: `cal_*.png`.

## Round 0 (2026-09-25 ~21:10)
No stage images yet in any folder. cobbles/driftwood/willow have refs + scratch; channel has photos only. Calibrated on V19 rocks (checker midtones inside clean rock silhouettes, dark cast pockets between stones) and V09 bushes (2-4 px leaf clusters, dark gaps, 3-4 greens).

## Round 1 (2026-09-25 21:22)
Coordinator notes received: bar is Ferrari-indistinguishable at 8×; tone should be generous and specific, and the student owns the call. I kept a verdict line, framed as my read.

- cobbles stage0 v3 (V19 boulder): REVISE. Uniform checker, even rim ring, flat shadow field. Silhouette good.
- channel c0a_v4 (V02 ribbons): REVISE. Far ribbons near-indistinguishable; near has a ruled 45° glint edge and a road-like ribbon.
- driftwood s0a/s0a2 (V09 pale trunk): REVISE. Silhouette borrowed; the limb fork was missed; wrong passage for driftwood. s1 log: REVISE (steel pipe, static ground, floats). Advanced without a verdict.
- willow c1_fgbush (V09 bush): REVISE. No clump hierarchy, too little light. s1a: REVISE (sponge, cut-out, black polygon shadow). Advanced without a verdict.

## Round 2 (2026-09-25 21:34)

- cobbles: s0 REVISE (close; concentric rim bands, tablecloth checker on flat stones). s1 REVISE (perfect ellipses, S-curve terminator, pegboard dots).
- channel: s0 A REVISE (checker zipper replaced the ruled edge; traced-shape wedge). B REVISE (fizzy riffle, stitched rock edges). LESSONS.md is good.
- driftwood: s0b V02 fallen tree REVISE (straight, disconnected branches). s1 REVISE (crayon with a white end cap, sawtooth lit edge, static ground).
- willow: c2 hedge REVISE (close; crest continuous, low contrast inside clumps). s1b REVISE (haystack, horizontal marks, black polygons).

## Round 3 (2026-09-25 21:46)

- cobbles: jumped to stages 2–4 with no pass on 0/1. REVISE all (identical ellipses, not nestled, ruled cast shadows, non-mirror reflection). Colour hours are good.
- channel: **copy A v12 PASS** (traced shapes, procedure indistinguishable). s1 paint REVISE (flat reflections, brick gravel, primitive dome).
- driftwood: s1 REVISE (three stripes, spiral end, pixel-litter splinters, dashed rim). Bends/tapers/shadows improved.
- willow: c2f REVISE (light inverted on crest clumps). t2: right direction, needs clumps.

## Round 4 (2026-09-25 22:00)

- cobbles: s4 redone packed and s5 bar: REVISE, but a big jump; reads as a gravel bar at a glance. Tells: leaded-glass outlines, camo streaks, pink slivers, ruled veins.
- channel: s1 REVISE (heart-shaped reflection, flat willow reflection, striping). Copy B v7 REVISE (X-crossing chains, vertical maroon bars).
- driftwood: s2 REVISE. Grain in and reads as wood at 5×; strokes parallel and square-ended, ruled check, tube silhouette, no knots.
- willow: c1 closer, c2f unchanged; s1 REVISE (outcrop silhouette, uniform speckle), but stems and fan now re
Bash: Read closing critiques regarding ground
cd ~/work/pixelart-studies && cat elements/_critic/r13/driftwood.md; grep -n -i -A3 "ground" elements/*/CRITIQUE.md | head -120

# output:
## Round 13 — 2026-09-25 23:50 — final sheets and "Where it stands" (finished)
**My closing read: your assessment is accurate, and your number 1 (the ground) is also my number 1.** The latest distance × seed sheet is your best work: the near piles read as silvered driftwood with pale root fans and end faces, the Morse check is gone, and at 16 and 6 px/m the pile holds as a readable cluster. (My read; your call.)

Ready to use:

- the cylinder class order (proven against his trunk), with the lit band kept clean of checker and the form turned by grain density
- contre-jour rims (stage 0B language)
- grain, checks and knots as projected clean-run curves on the 3-D surface
- value separation in piles: contact cracks, occlusion steps, and a mix of bleached and grey logs (`s5_grey_3x`, and distance × seed near)
- wet driftwood (dark brown with sharp glints)
- cast shadows as the ground two steps down

Not ready: the ground (texture, no masses: agreed, and it's the first thing anyone would catch); big logs at 8× (plateaus, and block-ended dashes); root fans too regular and too sparse on the root-wad sheet (two prongs read as a fork); pile intersections. Your proposed next study, a stage-0 copy of his near-ground masses (V19 ledges, V16 banks), is the right one. It would help every element student, not just you.
elements/grass-lod/CRITIQUE.md:23:1. **The stroke vocabulary is busier than his.** In `cmp_wip` his mound is a *flat* lit ochre ground with dark vertical strands (1 px wide, 2–6 tall) and a few mid-tone strands: two or three values, with most of the surface left flat, and the dark strands carrying all the texture. Yours mixes four or five greens plus a lemon yellow, with light dashes and dark dashes in equal measure, so it reads as camouflage. Try his economy: a flat lit base, dark strands as the grass, and light only as a paired highlight beside a dark gap (SYNTHESIS: the paired blade stroke).
elements/grass-lod/CRITIQUE.md-24-2. **Far contrast doesn't compress.** At 2 and 0.6 px/m the field is the same high-contrast yellow/olive stipple as at 8 px/m, just finer, so it reads as sponge or sand. Ferrari's far grass (`ref/v16_farbank_6x`, `ref/v04_farhill_5x`) drops most of its texture: near-flat bands of colour with sparse marks, and the shadow colour lifting toward the sky. With distance, the *share* of marks and their contrast should fall, not just their size.
elements/grass-lod/CRITIQUE.md-25-3. **The palette is acid.** The lit yellow is a lemon (almost 255,230,80) that no Ferrari grass uses. His lit grass is ochre or olive, and saturation drops with distance.
elements/grass-lod/CRITIQUE.md-26-4. **The zoom test needs smaller steps.** Your ladder jumps 4× at a time (30 → 8 → 2 → 0.6), and at that spacing any handoff will look like a pop, or hide one. For the handoff you're studying, render consecutive steps at about 1.25× apart across each mark-class boundary (e.g. 12, 10, 8, 6.5, 5 px/m) and put them side by side. I'll look at those for pops.
e
Bash: Inspect Ferrari reference image format
cd ~/work/pixelart-studies/pedagogy/master-copy/ref/lw && python3 -c "
import json;from PIL import Image
for n in ['V19','V16','V02','V09','V01','V13','V14','V30']:
  im=Image.open(n+'.png'); d=json.load(open(n+'.json')); print(n, im.size, im.mode, list(d.keys())[:10])
"; head -c 600 V19.json; echo; ls ../../src ../../out | head -80

# output:
Traceback (most recent call last):
  File "<string>", line 4, in <module>
  File "~/miniconda3/lib/python3.12/json/__init__.py", line 293, in load
    return loads(fp.read(),
           ^^^^^^^^^^^^^^^^
  File "~/miniconda3/lib/python3.12/json/__init__.py", line 346, in loads
    return _default_decoder.decode(s)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "~/miniconda3/lib/python3.12/json/decoder.py", line 337, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "~/miniconda3/lib/python3.12/json/decoder.py", line 353, in raw_decode
    obj, end = self.scan_once(s, idx)
               ^^^^^^^^^^^^^^^^^^^^^^
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
{filename:'V19.LBM',width:640,height:480,colors:[[0,0,0],[227,227,255],[211,219,255],[207,215,255],[187,211,255],[167,207,255],[143,207,255],[115,203,255],[87,195,255],[83,183,251],[79,171,247],[75,159,243],[75,147,239],[75,139,235],[71,127,235],[75,111,231],[75,95,227],[79,79,227],[255,255,255],[255,255,255],[255,255,255],[255,255,255],[243,187,135],[243,187,135],[171,183,247],[239,159,159],[187,147,163],[155,139,171],[255,187,127],[255,187,127],[255,187,127],[235,151,99],[235,151,99],[235,151,99],[211,119,107],[211,119,107],[115,95,147],[131,147,207],[159,175,227],[179,195,255],[255,255,255]
../../out:
cell_10m_right_3x.png
cell_close_back_3x.png
cell_close_left_3x.png
cell_large_front_3x.png
cmp_c1.png
cmp_c2.png
cmp_c3.png
cmp_c3_far.png
cmp_c4.png
cmp_c4_bank.png
cmp_c5.png
copy_c1.png
copy_c2.png
copy_c3.png
copy_c4.png
copy_c5.png
honest_compare.png
palette_tarn.png
sheet_distance_light.png
sheet_distance_light_3x.png
sheet_seeds.png
sheet_seeds_3x.png
sheet_size_distance.png
sheet_size_distance_3x.png
sheet_size_light.png
sheet_size_light_3x.png

../../src:
__pycache__
c1_sky_rock.py
c2_mound.py
c3_bank.py
c4_water.py
c5_boulder.py
compare.py
cropinfo.py
fl.py
lw2png.py
lwload.py
pair.py
palsheet.py
runs.py
sheets.py
strandstats.py
tarn.py
zoom.py
Bash: Read LW loader and bank copy code
cd ~/work/pixelart-studies/pedagogy/master-copy/src && cat lwload.py | head -60; sed -n 1,80p c3_bank.py

# output:
import re, json, numpy as np
def load(name):
    t=open(f'ref/lw/{name}.json').read()
    t=re.sub(r'([{,])\s*([A-Za-z_]+)\s*:', r'\1"\2":', t).replace("'",'"')
    d=json.loads(t)
    d['pal']=np.array(d['colors'],dtype=np.uint8)
    d['idx']=np.array(d['pixels'],dtype=np.int32).reshape(d['height'],d['width'])
    return d
"""Copy 3: the near, sun-lit grass bank (left foreground).  Ref crop native (0,250,150,219).

Discovered procedure (index map statistics, V16PM rows 254..473):

  * DEPTH PALETTES.  The bank is not one ramp; each depth band owns a tiny class set:
        far  (crest, y<48):   black-green, olive, halftone ochre, lit ochre   (4)
        mid  (48..140):       lit ochre + a *muted* olive (78) + a little mid (83)
        near (140..):         dark green (82), olive (83), lit ochre (84)
    The lit ochre is shared by all bands -- light is constant; what changes with distance
    is the shadow colour, which gets darker and greener toward the viewer.
  * GRAIN GROWS WITH NEARNESS.  Far: isotropic stipple (runs ~1.6 both ways).  Mid and
    near: 1px vertical strands, mean run 3.8 -> 5 px, horizontal runs stay ~1.5.
  * No lit-tip rule, no lean bias: transitions up/down are symmetric; blades are simply
    vertical 1px runs of colour whose class is drawn from the band's mixture.
  * Mixture drifts smoothly with depth (the value study): far crest has dark 'holes'
    (40% black-green), lit peaks just behind the crest, mid bank ~50% lit, near bank
    shifts to ~50% olive + ~30% dark green (self-shadowing in dense, close grass).
"""
import sys; sys.path.insert(0, 'src')
import numpy as np
from PIL import Image
import fl, compare

W, H = 150, 219
# classes (dusk colours)
C = {75: (28, 31, 20), 74: (60, 63, 45), 76: (106, 80, 40), 77: (196, 134, 76),
     78: (62, 59, 23), 82: (10, 34, 10), 83: (115, 92, 35), 84: (196, 131, 70)}
KEYS = list(C)
PAL = [C[k] for k in KEYS]
K = {k: i for i, k in enumerate(KEYS)}

# value study: class mixture keyed by depth row (copyist's measurement of masses)
STUDY = [  # y, {class: share}
    (0,   {75: .42, 77: .38, 74: .15, 76: .05}),
    (12,  {77: .65, 75: .22, 76: .10, 74: .03}),
    (24,  {77: .66, 76: .31, 75: .03}),
    (36,  {76: .53, 77: .40, 84: .05, 78: .02}),
    (48,  {76: .39, 78: .29, 84: .21, 77: .11}),
    (60,  {78: .45, 84: .35, 76: .12, 77: .08}),
    (72,  {84: .55, 78: .38, 83: .03, 82: .04}),
    (90,  {84: .44, 78: .26, 83: .21, 82: .09}),
    (120, {84: .52, 78: .23, 83: .22, 82: .03}),
    (144, {83: .47, 84: .45, 78: .07, 82: .01}),
    (170, {83: .50, 84: .34, 82: .14, 78: .02}),
    (195, {83: .48, 82: .24, 84: .28}),
    (219, {83: .46, 84: .32, 82: .22}),
]


def mixture_at(y, study=STUDY):
    ys = [s[0] for s in study]
    j = np.searchsorted(ys, y, side='right') - 1
    j = int(np.clip(j, 0, len(study) - 2))
    y0, a = study[j]; y1, b = study[j + 1]
    t = np.clip((y - y0) / (y1 - y0), 0, 1)
    keys = set(a) | set(b)
    return {k: (1 - t) * a.get(k, 0) + t * b.get(k, 0) 
Bash: Create loader and render Ferrari scenes at 2x
mkdir -p ~/work/pixelart-studies/elements/ground/{src,ref,stages,sheets,photos} && cd ~/work/pixelart-studies/elements/ground && cat > src/lw.py <<'EOF'
"""Load Ferrari LBM-json scenes (index map + palette)."""
import re, json, os, numpy as np
from PIL import Image
ROOTS = [os.path.expanduser('~/work/pixelart-studies/pedagogy/master-copy/ref/lw'),
         os.path.expanduser('~/work/pixelart-studies/aerial/refs/ferrari_lw')]
def load(name):
    for r in ROOTS:
        p = f'{r}/{name}.json'
        if os.path.exists(p):
            t = open(p).read(); break
    t = re.sub(r'([{,])\s*([A-Za-z_]+)\s*:', r'\1"\2":', t).replace("'", '"')
    d = json.loads(t)
    d['pal'] = np.array(d['colors'], dtype=np.uint8)
    d['idx'] = np.array(d['pixels'], dtype=np.int32).reshape(d['height'], d['width'])
    return d
def rgb(d, box=None):
    a = d['pal'][d['idx']]
    if box: x0, y0, x1, y1 = box; a = a[y0:y1, x0:x1]
    return a
def up(a, k):
    return np.repeat(np.repeat(a, k, 0), k, 1)
def save(a, path, k=1):
    Image.fromarray(up(np.asarray(a, np.uint8), k)).save(path)
EOF
cd src && python3 -c "
import lw
for n in ['V19','V16','V02','V09','V01','V13','V14','V30','V11AM','V29PM']:
    d=lw.load(n); lw.save(lw.rgb(d),'../ref/'+n+'_2x.png',2)
"; ls ../ref

# output:
V01_2x.png
V02_2x.png
V09_2x.png
V11AM_2x.png
V13_2x.png
V14_2x.png
V16_2x.png
V19_2x.png
V29PM_2x.png
V30_2x.png
Read: elements/ground/ref/V19_2x.png
Read: elements/ground/ref/V16_2x.png

elements/ground/ref/V16_2x.png 1. elements/ground/ref/V16_2x.png

elements/ground/ref/V19_2x.png 2. elements/ground/ref/V19_2x.png

Read: elements/ground/ref/V02_2x.png

elements/ground/ref/V02_2x.png 3. elements/ground/ref/V02_2x.png

Read: elements/ground/ref/V09_2x.png

elements/ground/ref/V09_2x.png 4. elements/ground/ref/V09_2x.png

Bash: Contact sheet of all Ferrari scenes
cd ~/work/pixelart-studies/elements/ground/src && ls ~/work/pixelart-studies/aerial/refs/ferrari_lw/ | sed 's/\..*//' | sort -u | tr '\n' ' '; python3 -c "
import lw,os,numpy as np
from PIL import Image
names=sorted(set(f.split('.')[0] for f in os.listdir(os.path.expanduser('~/work/pixelart-studies/aerial/refs/ferrari_lw')) if f.endswith('.json')))
ims=[]
for n in names:
    try: ims.append((n,lw.rgb(lw.load(n))))
    except Exception as e: print(n,e)
W=4; rows=(len(ims)+W-1)//W
sheet=Image.new('RGB',(W*640,rows*480))
from PIL import ImageDraw
dr=ImageDraw.Draw(sheet)
for i,(n,a) in enumerate(ims):
    x,y=(i%W)*640,(i//W)*480
    sheet.paste(Image.fromarray(a[:480,:640]),(x,y)); dr.text((x+5,y+5),n,fill=(255,255,0))
sheet.resize((W*320,rows*240)).save('../ref/all_scenes.png'); print(len(ims))
"

# output:
CORAL V01 V02 V03 V04 V05HAUNT V05RAIN V07 V08 V08AM V09 V10 V11AM V13 V14 V15 V16 V16PM V17 V19 V19PM V20 V25HEAT V26SNOW V27 V28 V29 V29PM V30 29
Read: elements/ground/ref/all_scenes.png

elements/ground/ref/all_scenes.png 5. elements/ground/ref/all_scenes.png

Bash: Render more ground scenes at 2x
cd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw
for n in ['V13','V30','V25HEAT','V04','V27','V16PM','V26SNOW','V29','V03']:
    d=lw.load(n); lw.save(lw.rgb(d),'../ref/'+n+'_2x.png',2)
"

# output:
(Bash completed with no output)
Read: elements/ground/ref/V13_2x.png

elements/ground/ref/V13_2x.png 6. elements/ground/ref/V13_2x.png

Read: elements/ground/ref/V30_2x.png

elements/ground/ref/V30_2x.png 7. elements/ground/ref/V30_2x.png

Read: elements/ground/ref/V25HEAT_2x.png

elements/ground/ref/V25HEAT_2x.png 8. elements/ground/ref/V25HEAT_2x.png

Read: elements/ground/ref/V16PM_2x.png

elements/ground/ref/V16PM_2x.png 9. elements/ground/ref/V16PM_2x.png

Read: pedagogy/master-copy/out/cmp_c2.png

pedagogy/master-copy/out/cmp_c2.png 10. pedagogy/master-copy/out/cmp_c2.png

Read: pedagogy/master-copy/out/cmp_c3.png

pedagogy/master-copy/out/cmp_c3.png 11. pedagogy/master-copy/out/cmp_c3.png

Bash: List analysis and atelier images
cd ~/work/pixelart-studies/pedagogy && ls analysis analysis/img atelier atelier/stage* | head -80

# output:
atelier/stage1_forms.py
atelier/stage2_boulder.py
atelier/stage3_setting.py
atelier/stage4_material.py

analysis:
LESSONS.md
img
out
sheets
src

analysis/img:
00_native.png
01_grid.png
02_planes.png
03_grey.png
03_notan.png
03_valueplan.png
03_valueplanes.png
05_light_mountain.png
05_light_tree.png
05_lightmap.png
06_reflection_compare.png
07_form.png
07_form_mid.png
07_profiles.png
08_edges.png
09_cluster_atlas.png
10_palette_ab.png
10_palette_groups.png
11_checker_map.png
12_depth.png
20_notan_compare.png
21_cluster_atlas_mine.png
22_edges_mine.png
23_grass_mine_vs_ferrari.png
c_grass_left.png
c_grass_right.png
c_hummock_base.png
c_island_bottom.png
c_midbanks.png
c_midzone.png
c_mountain.png
c_rightmid.png
c_shore.png
c_sky.png
c_stones.png
c_tree_left.png
c_water.png
chk_large_left_10m_s1_depth.png
chk_large_left_10m_s1_grey.png
chk_large_left_10m_s1_grey_vs.png
chk_large_left_10m_s1_notan.png
chk_large_left_10m_s1_notan_vs.png
chk_large_left_10m_s1_valueplan.png
chk_large_left_10m_s1_valueplanes.png
chk_medium_back_10m_s1_depth.png
chk_medium_back_10m_s1_grey.png
chk_medium_back_10m_s1_grey_vs.png
chk_medium_back_10m_s1_notan.png
chk_medium_back_10m_s1_notan_vs.png
chk_medium_back_10m_s1_valueplan.png
chk_medium_back_10m_s1_valueplanes.png
chk_medium_back_40m_s1_depth.png
chk_medium_back_40m_s1_grey.png
chk_medium_back_40m_s1_grey_vs.png
chk_medium_back_40m_s1_notan.png
chk_medium_back_40m_s1_notan_vs.png
chk_medium_back_40m_s1_valueplan.png
chk_medium_back_40m_s1_valueplanes.png
chk_medium_back_close_s1_depth.png
chk_medium_back_close_s1_grey.png
chk_medium_back_close_s1_grey_vs.png
chk_medium_back_close_s1_notan.png
chk_medium_back_close_s1_notan_vs.png
chk_medium_back_close_s1_valueplan.png
chk_medium_back_close_s1_valueplanes.png
chk_medium_front_10m_s1_depth.png
chk_medium_front_10m_s1_grey.png
Read: pedagogy/analysis/img/03_valueplan.png
Bash: Read analysis LESSONS
cd ~/work/pixelart-studies/pedagogy && sed -n 1,200p analysis/LESSONS.md

# output:
# Learning to paint like Mark Ferrari: a study of *Alpine Tarn at Dusk*

Everything below comes from programs in `src/`, and every figure is in `img/`. I looked at each
figure myself before writing about it. The reference arrived as a 1268×938 PNG. It is an exact 2×
nearest-neighbour upscale (every 2×2 block is one colour, with no phase error), so the native
picture is **634×469 in 172 colours** (`img/00_native.png`). All measurements below are made on
the native pixels.

Contents

1. Analysis, drawn: planes, values, light, form, edges, clusters, palette, depth, pixel geometry
2. What it means: Ferrari's method, as a painter would teach it
3. The method: `src/paint/ferrari_method.py`, parameters and sheets
4. Checking my sheets with the same analyses, one by one
5. What I would do next

---

## 1. The analysis

### 1.1 Planes and big masses (`img/02_planes.png`)

I traced ten planes by hand, using polygons for the ground and colour tests to split sky from tree
and rock. Share of the picture:

| plane | area | | plane | area |
|---|---|---|---|---|
| sky | 17.9% | | water | 16.5% |
| mountain | 4.0% | | left meadow (mid) | 4.6% |
| far forest | 9.0% | | left bank (fore) | 10.8% |
| framing trees | 15.7% | | right bank (fore) | 13.1% |
| far banks / hummocks | 6.6% | | island | 1.8% |

The composition is a **V of foreground banks opening onto water**. The lit left bank drops from the
middle-left down to the bottom centre, and the shadowed right bank rises diagonally from the bottom
centre to the right edge. The water sits in the V. Above it a band of low hummocks carries the far
shore, then a dark forest band, a small peak and a large sky. Tall framing conifers on both sides
close the rectangle. The peak, the only warm-and-cool jewel, sits almost dead centre, in a notch
the conifers leave open. Its reflection lies exactly under it, inside the V, so the V points the
eye at the mirrored peak.

### 1.2 Value structure (`img/03_grey.png`, `img/03_notan.png`, `img/03_valueplanes.png`, `img/03_valueplan.png`)

The grey version uses CIE L\*. Beside it is a squint version (Gaussian σ=2.5 px), because dithering
means individual pixels lie about the value a region reads as.

* **Two-value notan** (cut at L\*≈40): light = sky + snowfield + the reflected sky in the water +
  the lit left bank. Dark = every tree, the far banks and the whole right bank. It reads as an
  hourglass. A light field at the top pours through the notch around the peak into a light field
  at the bottom (water plus the left bank), pinched by two dark masses of trees and banks.
* **Three-value notan**, with k-means centres at L\* **16 / 42 / 70**. Every plane stays inside one
  or two of the three groups (squint median, then the 5–95% spread):

| plane | median | 5–95% | group |
|---|---|---|---|
| sky | 71 | 45–75 (the spread is only the tree edges) | light |
| mountain | 48 | 17–72 | all three: the one place that uses the full range |
| far forest | 20 | 9–44 | dark |
| framing trees | 

pedagogy/analysis/img/03_valueplan.png 12. pedagogy/analysis/img/03_valueplan.png

Read: exemplars/student_boulder_dusk_tarn.png

exemplars/student_boulder_dusk_tarn.png 13. exemplars/student_boulder_dusk_tarn.png

Bash: List atelier stages and lessons
cd ~/work/pixelart-studies/pedagogy/atelier && ls; ls stage*/ | head -60; sed -n 1,80p LESSONS.md 2>/dev/null

# output:
LESSONS.md
__pycache__
backdrop_util.py
core.py
drawing.py
figs
geometry.py
lessons_figs.py
lines.py
method.py
paint.py
palette.py
ref
scene.py
sheets
stage1
stage1_forms.py
stage2
stage2_boulder.py
stage3
stage3_setting.py
stage4
stage4_material.py
stage5
valuemodel.py
stage1/:
forms_6v.png
forms_lowsun_dirs.png

stage2/:
boulder_seed1.png
boulder_seed2.png
boulder_seed3.png
boulder_seed4.png
boulder_v2_all.png
hull_all.png
hull_seed1.png
hull_seed2.png
hull_seed3.png
hull_seed4.png

stage3/:
buf_large_back_10m_s1.pkl
buf_large_left_close_s1.pkl
buf_medium_back_10m_s0.pkl
buf_medium_front_10m_s0.pkl
buf_medium_left_10m_s0.pkl
buf_medium_left_40m_s0.pkl
buf_medium_left_close_s0.pkl
buf_medium_right_10m_s0.pkl
buf_small_right_10m_s2.pkl
crop_mtn.png
dbg_map.png
dbg_ndl.png
dbg_shadow.png
masses_large_back_10m_s1.png
masses_large_left_close_s1.png
masses_medium_back_10m_s0.png
masses_medium_front_10m_s0.png
masses_medium_left_10m_s0.png
masses_medium_left_40m_s0.png
masses_medium_left_close_s0.png
masses_medium_right_10m_s0.png
masses_small_right_10m_s2.png
notan_large_back_10m_s1.png
notan_large_left_close_s1.png
notan_medium_back_10m_s0.png
notan_medium_front_10m_s0.png
notan_medium_left_10m_s0.png
notan_medium_left_40m_s0.png
notan_medium_left_close_s0.png
notan_medium_right_10m_s0.png
notan_small_right_10m_s2.png
setting_large_back_10m_s1.png
setting_large_left_close_s1.png
setting_medium_back_10m_s0.png
setting_medium_front_10m_s0.png
setting_medium_left_10m_s0.png
setting_medium_left_40m_s0.png
setting_medium_left_close_s0.png
setting_medium_right_10m_s0.png
setting_small_right_10m_s2.png
v1_lights.png
v2_dists.png
v2_lights.png
# Lessons from the atelier: a boulder by a tarn at dusk, after Mark Ferrari

I worked value first and colour last. Each stage below says what I learned, points to the images, and says what went wrong. After the stages come the method (the program) and an honest comparison of my sheets with Ferrari's tarn.

Everything is in `~/work/pixelart-studies/pedagogy/atelier/`. To regenerate every sheet, run `nice -n 10 python3 method.py sheets`: four single-thread worker processes, about 4 minutes.

**Research, and its limits.** I studied Ferrari's `ferrari_alpine_tarn_dusk.png` closely:

- palette statistics;
- crops at his native pixel scale (the file is his 634×469 frame doubled);
- measured run lengths of his contours.

I read his Q&A (EffectGames) on:

- palette economy;
- patterned dither for smooth ramps and diffusion dither for chaos;
- colour cycling.

On form and light I used Proko's value families (the terminator, core, reflected light that never reaches the halftone, cast shadow, occlusion). I also read Pedro Medeiros and Slynyrd on clustering and on rocks, and the notes on Kazuo Oga's poster-colour method (big soft masses first, detail last, about 21 colours).

I did **not** look closely at Ferrari's other colour-cycling scenes or at Ghibli plates. Saving images from the web counts as a download, which needs permissi
Bash: Make squint mass maps of Ferrari scenes
cd ~/work/pixelart-studies/elements/ground/src && cat > analyze.py <<'EOF'
import lw, numpy as np, sys
from PIL import Image
from scipy import ndimage as ndi
def Lstar(rgb):
    c = rgb.astype(float)/255; c = np.where(c>0.04045, ((c+0.055)/1.055)**2.4, c/12.92)
    Y = c@np.array([0.2126,0.7152,0.0722]); return np.where(Y>0.008856, 116*np.cbrt(Y)-16, 903.3*Y)
def squint(rgb, s=3): return ndi.gaussian_filter(Lstar(rgb), s)
if __name__=='__main__':
    for n in sys.argv[1:]:
        d=lw.load(n); a=lw.rgb(d); L=squint(a,3)
        q=np.digitize(L,np.percentile(L[240:],[25,50,75]))  # quartiles of the lower half
        g=(np.array([30,90,160,230])[q]).astype(np.uint8)
        out=np.concatenate([a,np.stack([g]*3,-1)],1)
        lw.save(out,f'../ref/mass_{n}.png',1)
EOF
python3 analyze.py V13 V16 V09 V30 V25HEAT V04 V27 V16PM V29

# output:
(Bash completed with no output)
Read: elements/ground/ref/mass_V13.png
Read: elements/ground/ref/mass_V16.png

elements/ground/ref/mass_V16.png 14. elements/ground/ref/mass_V16.png

elements/ground/ref/mass_V13.png 15. elements/ground/ref/mass_V13.png

Bash: Crop Ferrari ground passages at 4x
cd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw
d=lw.load('V13'); a=lw.rgb(d)
lw.save(a[240:360,100:420],'../ref/v13_terraces_4x.png',4)
lw.save(a[360:480,240:560],'../ref/v13_path_4x.png',4)
d=lw.load('V16'); a=lw.rgb(d)
lw.save(a[215:300,200:520],'../ref/v16_hummocks_4x.png',4)
lw.save(a[380:480,380:640],'../ref/v16_rightbank_4x.png',4)
"

# output:
(Bash completed with no output)
Read: elements/ground/ref/v13_terraces_4x.png

elements/ground/ref/v13_terraces_4x.png 16. elements/ground/ref/v13_terraces_4x.png

Bash: Stretched crops of V13 terraces and path
cd ~/work/pixelart-studies/elements/ground/src && cat > crop.py <<'EOF'
"""crop.py NAME x0 y0 x1 y1 k [stretch] -> ../ref/crop_NAME_x0_y0_k.png"""
import lw, sys, numpy as np
n=sys.argv[1]; x0,y0,x1,y1,k=map(int,sys.argv[2:7]); st=len(sys.argv)>7
a=lw.rgb(lw.load(n))[y0:y1,x0:x1].astype(float)
if st:
    lo,hi=np.percentile(a,1),np.percentile(a,99.5); a=np.clip((a-lo)/(hi-lo),0,1)**0.8*255
out=f'../ref/crop_{n}_{x0}_{y0}_{k}{"s" if st else ""}.png'; lw.save(a,out,k); print(out)
EOF
python3 crop.py V13 100 240 420 360 4 s; python3 crop.py V13 240 360 560 480 4 s

# output:
../ref/crop_V13_100_240_4s.png
../ref/crop_V13_240_360_4s.png
Read: elements/ground/ref/crop_V13_100_240_4s.png
Read: elements/ground/ref/crop_V13_240_360_4s.png

elements/ground/ref/crop_V13_100_240_4s.png 17. elements/ground/ref/crop_V13_100_240_4s.png

elements/ground/ref/crop_V13_240_360_4s.png 18. elements/ground/ref/crop_V13_240_360_4s.png

Read: elements/ground/ref/v16_hummocks_4x.png
Read: elements/ground/ref/v16_rightbank_4x.png

elements/ground/ref/v16_rightbank_4x.png 19. elements/ground/ref/v16_rightbank_4x.png

elements/ground/ref/v16_hummocks_4x.png 20. elements/ground/ref/v16_hummocks_4x.png

Bash: List other students' sheets and src
cd ~/work/pixelart-studies/elements && ls driftwood/sheets driftwood/src cobbles/img willow/sheets channel/out | head -80

# output:
channel/out:
s0_copyA_v02.png
s0_copyB_v11.png
s0_triptych_A.png
s1_still.png
s1_still_8x_left.png
s1_still_8x_right.png
s1_triptych.png
s2_a_far_slope_8x.png
s2_b_far_cut_8x.png
s2_c_near_gravel_8x.png
s2_d_far_grass_8x.png
s2_e_near_grass_8x.png
s2_edges.png
s3_flow.png
s3_riffle_8x.png
s4_colour.png
s4_palettes.png
s4_still_dusk_8x.png
s4_still_midday_8x.png
s4_still_overcast_8x.png
s4_triptych.png
s5_braid_low_midday_4x.png
s5_braid_oblique_4x.png
s5_channel.png
s5_low_dusk_4x.png
s5_low_dusk_8x.png
sheet_1_width_x_depth.png
sheet_2_flow_x_hour.png
sheet_3_bank_x_camera.png
sheet_4_distance_x_hour.png
sheet_5_seeds_milky.png

cobbles/img:
compare_stage2.png
compare_stage3.png
compare_stage4.png
compare_stage4_wet.png
compare_stage5.png
compare_stage6.png
sheet_A_hour_x_distance.png
sheet_B_rock_x_wetness.png
sheet_C_density_x_seed.png
sheet_D_light_direction.png
stage0_boulder_zoom.png
stage0_copy.png
stage1_compare.png
stage1_lights_sizes.png
stage2_materials.png
stage3_colour_hours.png
stage4_nestled.png
stage4_waterline.png
stage5_bar.png
stage5_bar_zoom_far.png
stage5_bar_zoom_head.png
stage6_edges.png
stage6_grass_zoom.png
stage6_water_zoom.png

driftwood/sheets:
sheet_distance_seed_1x.png
sheet_distance_seed_3x.png
sheet_length_diameter_1x.png
sheet_length_diameter_3x.png
sheet_light_hour_1x.png
sheet_light_hour_3x.png
sheet_orientation_camera_1x.png
sheet_orientation_camera_3x.png
sheet_rootwad_weathering_wetness_1x.png
sheet_rootwad_weathering_wetness_3x.png

driftwood/src:
__pycache__
driftwood.py
geo.py
gravel.py
gtest.py
marks.py
paint.py
palette.py
Read: elements/driftwood/sheets/sheet_distance_seed_3x.png

elements/driftwood/sheets/sheet_distance_seed_3x.png 21. elements/driftwood/sheets/sheet_distance_seed_3x.png

Read: elements/cobbles/img/sheet_A_hour_x_distance.png

elements/cobbles/img/sheet_A_hour_x_distance.png 22. elements/cobbles/img/sheet_A_hour_x_distance.png

Bash: Palette usage statistics in V16 ground regions
cd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw,numpy as np
from analyze import Lstar
d=lw.load('V16'); I=d['idx']; P=d['pal']
def rep(box,name):
    x0,y0,x1,y1=box; s=I[y0:y1,x0:x1]; u,c=np.unique(s,return_counts=True); o=np.argsort(-c)
    print(name, s.shape)
    for k in o[:14]: print('  idx',u[k],P[u[k]],'L=%.0f'%Lstar(P[u[k]][None])[0],'%.1f%%'%(100*c[k]/s.size))
rep((265,270,420,300),'islet')
rep((340,225,640,260),'far hummocks')
rep((0,240,200,300),'left lit bank')
rep((0,330,200,480),'left near bank')
rep((420,330,640,480),'right bank')
"

# output:
islet (30, 155)
  idx 76 [ 0 11  0] L=2 25.5%
  idx 75 [59 79 39] L=31 21.4%
  idx 77 [ 0 23 19] L=6 18.0%
  idx 74 [223 211  67] L=83 14.3%
  idx 62 [0 0 0] L=0 2.3%
  idx 59 [ 0 31 15] L=9 1.8%
  idx 61 [0 7 0] L=1 1.8%
  idx 43 [71 75 95] L=32 1.5%
  idx 44 [ 79 103 111] L=42 1.4%
  idx 69 [31 67 39] L=25 1.1%
  idx 56 [23 47  0] L=17 0.7%
  idx 127 [0 7 7] L=2 0.7%
  idx 120 [ 0 23 23] L=6 0.6%
  idx 57 [0 0 0] L=0 0.6%
far hummocks (35, 300)
  idx 70 [ 0 23  7] L=6 29.2%
  idx 69 [31 67 39] L=25 27.6%
  idx 71 [ 0  7 15] L=2 15.7%
  idx 72 [ 0 31 19] L=9 14.1%
  idx 61 [0 7 0] L=1 2.3%
  idx 235 [31 67 39] L=25 1.8%
  idx 60 [ 0 11  0] L=2 1.4%
  idx 59 [ 0 31 15] L=9 1.3%
  idx 53 [0 0 0] L=0 1.2%
  idx 62 [0 0 0] L=0 0.9%
  idx 56 [23 47  0] L=17 0.8%
  idx 57 [0 0 0] L=0 0.8%
  idx 52 [0 0 0] L=0 0.5%
  idx 51 [23 47  0] L=17 0.4%
left lit bank (60, 200)
  idx 77 [ 0 23 19] L=6 37.2%
  idx 76 [ 0 11  0] L=2 20.7%
  idx 71 [ 0  7 15] L=2 12.4%
  idx 75 [59 79 39] L=31 11.2%
  idx 74 [223 211  67] L=83 4.9%
  idx 72 [ 0 31 19] L=9 3.0%
  idx 61 [0 7 0] L=1 2.9%
  idx 62 [0 0 0] L=0 2.4%
  idx 84 [11 31 15] L=10 1.0%
  idx 60 [ 0 11  0] L=2 0.6%
  idx 111 [0 7 7] L=2 0.5%
  idx 78 [19 43 23] L=15 0.5%
  idx 104 [ 0 27 31] L=8 0.5%
  idx 106 [23 55 79] L=22 0.5%
left near bank (150, 200)
  idx 83 [ 0 15 11] L=3 36.9%
  idx 84 [11 31 15] L=10 36.1%
  idx 82 [0 7 7] L=2 12.4%
  idx 78 [19 43 23] L=15 10.3%
  idx 80 [0 7 7] L=2 0.7%
  idx 81 [0 0 0] L=0 0.5%
  idx 7 [ 47 191 255] L=73 0.3%
  idx 61 [0 7 0] L=1 0.3%
  idx 8 [ 35 183 255] L=70 0.2%
  idx 23 [ 87  83 131] L=38 0.2%
  idx 62 [0 0 0] L=0 0.2%
  idx 22 [ 47  67 107] L=29 0.2%
  idx 59 [ 0 31 15] L=9 0.1%
  idx 58 [143 151  35] L=60 0.1%
right bank (150, 220)
  idx 81 [0 0 0] L=0 34.0%
  idx 80 [0 7 7] L=2 27.7%
  idx 78 [19 43 23] L=15 22.3%
  idx 79 [23 51 27] L=18 11.8%
  idx 62 [0 0 0] L=0 0.9%
  idx 61 [0 7 0] L=1 0.7%
  idx 73 [79 83 19] L=34 0.6%
  idx 59 [ 0 31 15] L=9 0.2%
  idx 124 [19 47 67] L=18 0.1%
  idx 121 [ 7 35 31] L=12 0.1%
  idx 123 [43 63 91] L=26 0.1%
  idx 122 [19 51 63] L=19 0.1%
  idx 125 [ 7 35 43] L=12 0.1%
  idx 126 [ 0 23 23] L=6 0.1%
Bash: Visualize index maps with random colours
cd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw,numpy as np
rng=np.random.default_rng(3)
for n in ['V16','V13','V09','V30','V25HEAT','V04']:
    d=lw.load(n); I=d['idx']
    # group indices into ramps of consecutive indices: colour by idx//4-ish hashed, lightness by idx%4
    cols=rng.integers(40,255,(256,3))
    a=cols[I].astype(np.uint8)
    lw.save(np.concatenate([lw.rgb(d),a],1),'../ref/idxmap_'+n+'.png',1)
"

# output:
(Bash completed with no output)
Read: elements/ground/ref/idxmap_V16.png

elements/ground/ref/idxmap_V16.png 23. elements/ground/ref/idxmap_V16.png

Bash: Read imgfetch tool header
cd ~/work/pixelart-studies && sed -n 1,40p tools/imgfetch.py

# output:
#!/usr/bin/env python3
"""imgfetch: fast, polite reference-image sourcing (Wikimedia Commons + Openverse).

  python3 imgfetch.py "cinder cone scoria slope" -n 12 -o refs/cinder
  python3 imgfetch.py -f queries.txt -n 8 -o refs            # one query per line; subdir per query
  options: --src commons,openverse   --width 1200   --sheet (writes <out>/_sheet.jpg)

Writes images plus <out>/sources.tsv (file, title, license, author, page url).
Cached: files already present are skipped. Handles 429/5xx with Retry-After backoff.
Parallel downloads (4 workers) with a shared per-host rate limiter (~3 req/s; Wikimedia's media
host is kept to 1 per 2 s and slows further on any 429, since it throttles hard per IP after bursts).
Openverse (mostly Flickr-hosted CC images) is the primary source because it stays fast.
"""
import argparse, concurrent.futures as cf, json, os, re, sys, threading, time, urllib.error, urllib.parse, urllib.request

UA = "pixelart-studies-imgfetch/1.0 (research; contact: local)"
_locks = {}; _last = {}; _glock = threading.Lock()

SLOW_HOSTS = {"upload.wikimedia.org": 2.0}   # Wikimedia media host: ~1 req/s or it 429s

def _throttle(host, min_gap=0.34):
    min_gap = SLOW_HOSTS.get(host, min_gap)
    with _glock:
        lk = _locks.setdefault(host, threading.Lock())
    with lk:
        dt = time.time() - _last.get(host, 0)
        if dt < min_gap:
            time.sleep(min_gap - dt)
        _last[host] = time.time()

def get(url, tries=4, timeout=25):
    host = urllib.parse.urlparse(url).netloc
    if host == "upload.wikimedia.org":
        tries = 2  # fail fast; other sources fill in
    for i in range(tries):
        _throttle(host)
        try:
            req = urllib.request.Request(url, headers={"User-Agent": UA})
            return urllib.request.urlopen(req, timeout=timeout).read()
        except urllib.error.HTTPError as e:
Bash: Fetch reference photos of ground types
cd ~/work/pixelart-studies/elements/ground/photos && cat > q.txt <<'EOF'
alpine meadow rolling hills late afternoon shadows
grassy hillside low sun long shadows
dirt path through meadow
forest floor sunlight patches
dappled light forest floor moss
rolling pasture evening light
gravel river bar
bare earth slope erosion
muddy trail puddles
sand dunes ripples low sun
EOF
timeout 600 python3 ../../../tools/imgfetch.py -f q.txt -n 10 -o . --sheet 2>&1 | tail -15; ls

# output:
(eval):13: command not found: timeout
q.txt
Bash: Fetch reference photos of ground types
cd ~/work/pixelart-studies/elements/ground/photos && python3 ../../../tools/imgfetch.py -f q.txt -n 10 -o . --sheet 2>&1 | tail -15; ls

# output:
download failed: HTTP Error 429: Too many requests - please contact noc@wikimedia.org to discuss a less disruptive approach or instead use thumbnail images in sizes listed on https://w.wiki/GHai. (0c640b1)
  download failed: HTTP Error 429: Too many requests - please contact noc@wikimedia.org to discuss a less disruptive approach or instead use thumbnail images in sizes listed on https://w.wiki/GHai. (0c640b1)
  download failed: HTTP Error 429: Too many requests - please contact noc@wikimedia.org to discuss a less disruptive approach or instead use thumbnail images in sizes listed on https://w.wiki/GHai. (0c640b1)
  download failed: HTTP Error 429: Too many requests - please contact noc@wikimedia.org to discuss a less disruptive approach or instead use thumbnail images in sizes listed on https://w.wiki/GHai. (0c640b1)
  download failed: HTTP Error 403: Forbidden
'alpine meadow rolling hills late afternoon shadows': 9 images -> ./alpine_meadow_rolling_hills_late_afterno
'grassy hillside low sun long shadows': 3 images -> ./grassy_hillside_low_sun_long_shadows
'dirt path through meadow': 8 images -> ./dirt_path_through_meadow
'forest floor sunlight patches': 9 images -> ./forest_floor_sunlight_patches
'dappled light forest floor moss': 7 images -> ./dappled_light_forest_floor_moss
'rolling pasture evening light': 7 images -> ./rolling_pasture_evening_light
'gravel river bar': 8 images -> ./gravel_river_bar
'bare earth slope erosion': 9 images -> ./bare_earth_slope_erosion
'muddy trail puddles': 8 images -> ./muddy_trail_puddles
'sand dunes ripples low sun': 9 images -> ./sand_dunes_ripples_low_sun
alpine_meadow_rolling_hills_late_afterno
bare_earth_slope_erosion
dappled_light_forest_floor_moss
dirt_path_through_meadow
forest_floor_sunlight_patches
grassy_hillside_low_sun_long_shadows
gravel_river_bar
muddy_trail_puddles
q.txt
rolling_pasture_evening_light
sand_dunes_ripples_low_sun
Read: elements/ground/photos/alpine_meadow_rolling_hills_late_afterno/_sheet.jpg

elements/ground/photos/alpine_meadow_rolling_hills_late_afterno/_sheet.jpg 24. elements/ground/photos/alpine_meadow_rolling_hills_late_afterno/_sheet.jpg

Read: elements/ground/photos/rolling_pasture_evening_light/_sheet.jpg

elements/ground/photos/rolling_pasture_evening_light/_sheet.jpg 25. elements/ground/photos/rolling_pasture_evening_light/_sheet.jpg

Read: elements/ground/photos/forest_floor_sunlight_patches/_sheet.jpg

elements/ground/photos/forest_floor_sunlight_patches/_sheet.jpg 26. elements/ground/photos/forest_floor_sunlight_patches/_sheet.jpg

Read: elements/ground/photos/dirt_path_through_meadow/_sheet.jpg

elements/ground/photos/dirt_path_through_meadow/_sheet.jpg 27. elements/ground/photos/dirt_path_through_meadow/_sheet.jpg

Bash: Close crops of V16 islet and hummocks
cd ~/work/pixelart-studies/elements/ground/src && python3 crop.py V16 262 262 425 305 6 && python3 crop.py V16 430 218 640 275 5

# output:
../ref/crop_V16_262_262_6.png
../ref/crop_V16_430_218_5.png
Read: elements/ground/ref/crop_V16_262_262_6.png
Read: elements/ground/ref/crop_V16_430_218_5.png

elements/ground/ref/crop_V16_262_262_6.png 28. elements/ground/ref/crop_V16_262_262_6.png

elements/ground/ref/crop_V16_430_218_5.png 29. elements/ground/ref/crop_V16_430_218_5.png

Bash: Character map of the V16 islet index pixels
cd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw,numpy as np
d=lw.load('V16'); I=d['idx']
s=I[262:305,262:425]
# order classes by L
from analyze import Lstar
P=d['pal']
# checker stats among lit(74) pixels in transition zone
lit=(s==74)
# fraction of lit pixels whose 4-neighbours are lit
n4=(np.roll(lit,1,0)&lit).sum()+(np.roll(lit,1,1)&lit).sum()
diag=(np.roll(np.roll(lit,1,0),1,1)&lit).sum()+(np.roll(np.roll(lit,1,0),-1,1)&lit).sum()
print('lit',lit.sum(),'4-adj pairs',n4,'diag pairs',diag)
# print a char map
ch={74:'#',75:'+',76:'.',77:':',62:' ',59:'-',61:' '}
for r in s[:43]:
    print(''.join(ch.get(v,'?') for v in r[:160]))
"

# output:
lit 846 4-adj pairs 784 diag pairs 1064
????????????- -??  -?--      -   ?-??-??????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
?????????????-  ?-  ??-?  ?-- ?-??--?---??? ????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
????????????- -?- ? -    ??   -?-?--? ---?? ? ??  ?: ? : : :?: ?????????????????????????????????????????????????????????????????????????????????????????????????
??????????- ?- -??  -?     ?-?   ? ???? ??-?  ? ?:?:??:? :????:?:?:?????????????????????????????????????????????????????????????????????????????????????????????
???????????----  ??  -      ?  ? ?-???-?? ??? ? ????##:#:#:#:#:#::#::::::???????????????????????????????????????????????????????????????????????????????????????
??????????????-- ---       #  ??#??-?####?##???####:#:#:#:#:+:+:::.:::::::::::::????????????????????????????????????????????????????????????????????????????????
???????????????-?--?-?? -?-#  #+??+###+??#+?+###+#+#+#.#++::#::.:.:.:.:::.::::::::::::?:??:?:???????????????????????????????????????????????????????????????????
????????????????---- -?#-##+#+?###?+#+###+#+###+#+#.#.#.#.+.+.+.+:.:.:.:.:.:.:.:.:::::::.::::?:?:?:?:??:????????????????????????????????????????????????????????
???????????????-???-- # #?######+###?#+#+###+#++++++++++.+.+.#.:..:::.:.::.::.::::::::::::::::::::??:?:??:??????????????????????????????????????????????????????
?????????????????- ?- #?##+##+#+#+###+#+#+###+#+#+#+#.#.+.+.+.+.+.+.+.+:..:..:.:.:::.:.:::.:.:.::::::::??:??:???????????????????????????????????????????????????
??????????????????-#- ##+#?##?#############+##+#++++++++++.#++.+++.......::::.:...:.:.:.:::::.::::::::::::?:?:??:???????????????????????????????????????????????
????????????????? -#?######+?+###+#+#########++##+#.+#+.#.+.+.+.+.+..:+.+.:..:.:.:.:::.:.:::.:.:.:::.:::.:::::?:???:????????????????????????????????????????????
????????????????-?#+##+###########+#####+++++#++++++#+++++++.+...+.+......:..........:..:.:::::::::::::::::::::?:?:??::?:??????????  ---?    -    ??????????????
??????????????#-# #####+#+#+#+#+#+###+#+#+#+###+++#++.+.+.+.+.+.+.+++.+.+:.:..:..:.:.:::.:.:::::.:.:::.::.:.::.::::?:?::????????????  --??  --?           --??  
???????????????#?###############+#+#+#+++#++#+++++++++++#+.+++.+.+..........+...:...:::.:.:.:.:::.:::::::::::.::::::::?:?:???:-?????-??--?  --? ?           -?-?
?????????????#?+##+##+#+###+#+#+#+++#+#+#####+++#+++#++.+++.+.+#+.+.+.+...+.....+......:::::.:.:::::::.:.:.:.:::.::.::::?:?::??   ?---??-?  ---?-         - --??
?????????????#-#+#############+++#+#+#+#+#+#+++++++#+#++++++++.+...+...++...:.:...:...:..:......::..:::.:::.:::::::.:.:::::?:?:---  -?---?- -?? ?-     ??   -?--
???????????#?#+######+###+#+##++#+#+#+++#+###+#++++++++++.+.+.+.+.+.+...+.+...+.+........:..::.:::.:.:.:.:.:.:.:.:.:::::.:::::?:  :  --- -- ? -?                
??????????#?##############+##+#++#+++#+++##+++++++#++.#+++++.+
Bash: Write dither module and stage 0a islet copy
cd ~/work/pixelart-studies/elements/ground/src && cat > dither.py <<'EOF'
"""Quantizers: continuous step coordinate v in [0, n-1] -> integer step, mixing only adjacent steps.

ordered(v, jitter): DPaint-style gradient-fill dither. 2x2 Bayer thresholds (so 50% = checker),
                    plus a random jitter on the threshold (DPaint's 'dither' slider).
"""
import numpy as np
B2 = np.array([[0.125, 0.625], [0.875, 0.375]])
B4 = (np.array([[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]) + 0.5) / 16

def tile(m, h, w, ox=0, oy=0):
    k = m.shape[0]
    return np.tile(m, (h // k + 2, w // k + 2))[oy % k: oy % k + h, ox % k: ox % k + w]

def ordered(v, jitter=0.25, rng=None, bayer=B2, n=None):
    h, w = v.shape
    rng = rng or np.random.default_rng(0)
    t = tile(bayer, h, w) + jitter * (rng.random((h, w)) - 0.5)
    base = np.floor(v)
    s = base + ((v - base) > t)
    if n is not None: s = np.clip(s, 0, n - 1)
    return s.astype(int)

def stochastic(v, rng=None, n=None, clump=None):
    """random threshold (optionally from a clumped noise field in [0,1])"""
    h, w = v.shape
    rng = rng or np.random.default_rng(0)
    t = rng.random((h, w)) if clump is None else clump
    base = np.floor(v)
    s = base + ((v - base) > t)
    if n is not None: s = np.clip(s, 0, n - 1)
    return s.astype(int)
EOF
cat > s0_islet.py <<'EOF'
"""Stage 0a: copy of V16's lit islet by procedure.
A: his value field (blurred step map) + my quantizer   -> isolates the dither procedure
B: my own field (DPaint contour-gradient with highlight point) + my quantizer -> full procedure"""
import lw, numpy as np, dither
from scipy import ndimage as ndi
from PIL import Image, ImageDraw
d = lw.load('V16'); I = d['idx']; P = d['pal']
x0, y0, x1, y1 = 262, 262, 425, 305
S = I[y0:y1, x0:x1]
RAMP = [77, 76, 75, 74]            # step 0..3 (index order: 74 lit ... 77 darkest-cool)
rank = {k: i for i, k in enumerate(RAMP)}
mask = np.isin(S, RAMP)
st = np.vectorize(lambda k: rank.get(k, 0))(S).astype(float)
# his field: normalized blur of the step map inside the mask
sg = 2.5
num = ndi.gaussian_filter(st * mask, sg); den = ndi.gaussian_filter(mask.astype(float), sg) + 1e-6
field = num / den
rng = np.random.default_rng(1)
A = dither.ordered(field, jitter=0.35, rng=rng, n=4)

# B: my own field.  mask = his silhouette (we test fill, not drawing, here)
h, w = S.shape
yy, xx = np.mgrid[0:h, 0:w]
dist_in = ndi.distance_transform_edt(mask)
hx, hy = 8, 14                          # highlight point (upper-left, where the sun pool sits)
r = np.hypot((xx - hx) / 1.0, (yy - hy) * 2.2)   # oval falloff, squashed vertically (a flat-ish mound)
vB = 3.35 - r / 34.0                    # linear falloff from the highlight
vB = vB - 0.6 * np.exp(-dist_in / 2.0) * (yy > h * 0.55)   # darker foot rim at the waterline
vB = np.clip(vB, 0, 3)
B = dither.ordered(vB, jitter=0.35, rng=np.random.default_rng(2), n=4)

def paint(steps):
    out = P[S].copy()
    out[mask] = P[np.array(RAMP)[steps]][mask]
    return out
ref = P[S]
row = lambda *ims: np.concatenate([np.pad(i, ((0, 0), (0, 3), (0, 0))) for i in ims], 1)
lw.save(row(ref, paint(A), paint(B)), '../stages/s0a_islet_6x.png', 6)
# field diagnostic
g = lambda f: np.stack([(f / 3 * 255).astype(np.uint8)] * 3, -1)
lw.save(row(g(field), g(vB)), '../stages/s0a_islet_fields_4x.png', 4)
EOF
python3 s0_islet.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s0a_islet_6x.png

elements/ground/stages/s0a_islet_6x.png 30. elements/ground/stages/s0a_islet_6x.png

Bash: Compare dither jitter levels against Ferrari islet
cd ~/work/pixelart-studies/elements/ground/src && cat > s0_islet_j.py <<'EOF'
import lw, numpy as np, dither
exec(open('s0_islet.py').read().split("rng = np.random")[0])
outs=[P[S]]
for j in [0.5,0.8,1.1]:
    A = dither.ordered(field, jitter=j, rng=np.random.default_rng(1), n=4)
    o=P[S].copy(); o[mask]=P[np.array(RAMP)[A]][mask]; outs.append(o)
outs=[o[:, :110] for o in outs]
lw.save(np.concatenate([np.pad(i,((0,3),(0,0),(0,0))) for i in outs],0),'../stages/s0a_jitter_6x.png',6)
EOF
python3 s0_islet_j.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s0a_jitter_6x.png

elements/ground/stages/s0a_jitter_6x.png 31. elements/ground/stages/s0a_jitter_6x.png

Bash: Check the cobbles and driftwood APIs for integration
cd ~/work/pixelart-studies && ls elements/cobbles/cobbles_api.py && sed -n 1,60p elements/cobbles/cobbles_api.py; grep -n "^def " elements/cobbles/cobbles_api.py elements/driftwood/src/driftwood.py elements/driftwood/src/paint.py | head -40

# output:
elements/cobbles/cobbles_api.py
"""cobbles_api.py -- the technique as one call with parameters (stage 7).

paint_patch(W, H, rock, size_m, wet, density, sun, hour, px_per_stone, seed) -> RGB uint8

  rock          'granite' | 'greywacke' | 'basalt' | 'mixed' (a braided-river bar: mostly
                greywacke, some granite, some basalt)
  size_m        median clast size in metres (sets the lognormal spread's centre)
  px_per_stone  the median stone's size in PIXELS -- the distance knob.  >= 5: stones;
                2-5: ticks; < 2: grain
  wet           0 dry .. 1 all wet (stones on the wet ramp: darker, saturated, glints)
  density       packing: 1 = clast-supported (touching), < 1 opens matrix between
  sun           (azimuth, elevation) in degrees; hour picks the palette (the light's colour)
  seed          everything random
"""
import numpy as np
from bar import bar
from palette import water_ramp

MIXES = {'granite': [('granite', 1.0)], 'greywacke': [('greywacke', 1.0)], 'basalt': [('basalt', 1.0)],
         'mixed': [('greywacke', 0.55), ('granite', 0.25), ('basalt', 0.2)]}
HOUR_SUN = {'noon': (120, 62), 'morning': (30, 30), 'golden': (160, 16), 'dusk': (175, 7),
            'overcast': (100, 80)}


def paint_patch(W=96, H=64, rock='mixed', size_m=0.12, wet=0.0, density=1.0, sun=None, hour='noon',
                px_per_stone=16, seed=0, depth_ratio=1.0):
    sun = sun or HOUR_SUN[hour]
    ppm = px_per_stone / size_m
    cv, info = bar(W=W, H=H, sun=sun, seed=seed, head_m=size_m, tail_m=size_m,
                   px_per_m_far=ppm / depth_ratio, px_per_m_near=ppm, density=density, mix=MIXES[rock],
                   size_sd=0.4)
    if wet > 0:
        rng = np.random.default_rng(seed + 7)
        st = cv.mat >= 0
        cv.wet[st & (rng.random(cv.step.shape) < 1.0)] = 1 if wet >= 0.5 else 0
        if wet < 0.5 and info is not None:
            # damp: whole stones whose base lies in the lower part of the patch (per stone,
            # never a ruled line through a stone)
            cv.wet[:] = 0
            for i in info['order']:
                m = info['vis'][i]
                ys = np.nonzero(m)[0]
                if len(ys) and ys.max() > H * (1 - 2 * wet):
                    cv.wet[m] = 1
        g = cv.mat < 0
        cv.step[g] = np.maximum(cv.step[g] - int(round(wet * 2)), 0)
    return cv.render(hour)
elements/cobbles/cobbles_api.py:25:def paint_patch(W=96, H=64, rock='mixed', size_m=0.12, wet=0.0, density=1.0, sun=None, hour='noon',
elements/driftwood/src/driftwood.py:34:def paint(length=3.0, diameter=0.35, orientation=15.0, rootwad=False, weathering=1.0, wetness=0.0,
elements/driftwood/src/driftwood.py:70:def _cell(args):
elements/driftwood/src/driftwood.py:78:def sheet(name, cells, cols, title, scale=3):
elements/driftwood/src/driftwood.py:91:def _def(name):
elements/driftwood/src/driftwood.py:99:def s_len_diam():
elements/driftwood/src/driftwood.py:108:def s_orient_cam():
elements/driftwood/src/driftwood.py:118:def s_
Write: elements/ground/src/ground.py
"""ground.py -- the ground as big designed value masses, with texture living inside them.

    g = paint_ground(W, H, ground='turf', landform='rolling', hour='golden', sun=None,
                     moisture=0.2, ppm=24, pitch=14, cam_h=1.7, canopy=0.0, seed=0)
    g.rgb      (H, W, 3) uint8
    g.mat, g.band, g.step   the index canvas (material, depth band, ramp step) -- palette-swappable
    g.depth, g.wx, g.wz, g.h   per-pixel world buffers, so other students can stand objects on it
    g.relight(hour)         new rgb from the same index canvas

Layers (each a separate function so it can be tuned against a reference):
  1 LANDFORM   analytic height h(x,z) in metres built from a few big features
               (rolling swells, hummocks, bank, ledges, path) -- the masses come from here
  2 RENDER     voxel-space column march: per pixel world x,z,h,normal,depth,px-per-metre
  3 LIGHT      sun visibility (terrain horizon shadow x off-frame canopy shadow) and N.L,
               sky ambient, hollow occlusion; *smoothed at mass scale* so light makes shapes
  4 VALUE PLAN two families that never touch: shadow steps 0..2, light steps 3..5; the
               terminator is a jump. crest separation: land just behind a crest drops a step.
  5 MATERIAL   turf / meadow / earth / gravel / forest floor; path and bare risers; moisture
  6 MARKS      offsets in step space whose size follows px-per-metre and whose contrast follows
               the family and distance: strands -> ticks -> DPaint dither -> flat
  7 QUANTIZE   ordered 2x2 + jitter (the V16 islet recipe), adjacent steps only
  8 PALETTE    per material x depth band x hour ramp, generated from albedo x light colour
"""
import numpy as np
from scipy import ndimage as ndi
import dither

# ------------------------------------------------------------------ 1 LANDFORM
class Landform:
    """h(x, z) as a sum of big features. Everything analytic, so it can be sampled anywhere."""
    def __init__(self, kind='rolling', seed=0, relief=1.0, extent=(-200, 200, 0, 400), path=False,
                 water=None):
        self.kind, self.seed, self.relief = kind, seed, relief
        r = np.random.default_rng(seed)
        self.r = r
        # low-frequency swells: random-direction sinusoids (smooth, big)
        n = 7
        ang = r.uniform(0, np.pi, n)
        wl = r.uniform(18, 60, n)
        self.sw = [(np.cos(a) * 2 * np.pi / l, np.sin(a) * 2 * np.pi / l, r.uniform(0, 6.3), l / 60.0)
                   for a, l in zip(ang, wl)]
        # micro relief (only drives marks/light breakup, small amplitude)
        m = 9
        ang = r.uniform(0, np.pi, m); wl = r.uniform(1.5, 6, m)
        self.micro = [(np.cos(a) * 2 * np.pi / l, np.sin(a) * 2 * np.pi / l, r.uniform(0, 6.3), l / 6.0)
                      for a, l in zip(ang, wl)]
        # hummocks: lozenges
        self.hum = []
        if kind in ('hummocks', 'bank'):
            for _ in range(60):
                self.hum.append((r.uniform(-60, 60), r.uniform(3, 140), r.uniform(3, 9), r.uniform(1.5, 3.5),
                                 r.uniform(0.4, 1.3), r.uniform(-0.5, 0.5)))
        self.path = path
        self.path_a, self.path_b, self.path_c = r.uniform(2, 5), r.uniform(12, 25), r.uniform(-1.5, 1.5)
        self.water = water

    def path_center(self, z):
        return self.path_c + self.path_a * np.sin(z / self.path_b + 1.3) + 0.02 * z

    def path_dist(self, x, z):
        return np.abs(x - self.path_center(z))

    def h(self, x, z, micro=True):
        k = self.kind
        H = np.zeros(np.broadcast(x, z).shape)
        amp = {'flat': 0.25, 'rolling': 1.6, 'hummocks': 0.5, 'bank': 0.6, 'ledges': 0.4}[k] * self.relief
        for kx, kz, ph, a in self.sw:
            H = H + amp * a * np.sin(kx * x + kz * z + ph)
        if k == 'rolling':
            # rolling land swells more with distance (terraces of V13)
            pass
        if k in ('hummocks', 'bank'):
            for cx, cz, ax, az, ht, rot in self.hum:
                c, s = np.cos(rot), np.sin(rot)
                u = ((x - cx) * c + (z - cz) * s) / ax
                v = (-(x - cx) * s + (z - cz) * c) / az
                q = u * u + v * v
                H = H + ht * self.relief * np.where(q < 1, (1 - q) ** 1.5, 0)
        if k == 'bank':
            # the land falls toward a shoreline at z = zw (the viewer stands on a bank)
            zw = self.water if self.water is not None else 14.0
            H = H - 1.8 * self.relief * (1 / (1 + np.exp(-(z - zw) / 2.5)))
        if k == 'ledges':
            # a slope rising away from the viewer, cut into terraces with risers facing us
            base = 0.18 * z
            step = 2.4 * self.relief
            q = base / step
            fr = q - np.floor(q)
            riser = 1 / (1 + np.exp(-(fr - 0.82) * 22))
            H = H + step * (np.floor(q) + riser) + 0.4 * np.sin(x / 9 + np.floor(q) * 2.1)
        if micro:
            for kx, kz, ph, a in self.micro:
                H = H + 0.05 * a * np.sin(kx * x + kz * z + ph)
        if self.path:
            d = self.path_dist(x, z)
            H = H - 0.12 * np.exp(-(d / 1.0) ** 2)
        return H

    def normal(self, x, z, e=0.15, micro=True):
        hx = (self.h(x + e, z, micro) - self.h(x - e, z, micro)) / (2 * e)
        hz = (self.h(x, z + e, micro) - self.h(x, z - e, micro)) / (2 * e)
        n = np.stack([-hx, np.ones_like(hx), -hz], -1)
        return n / np.linalg.norm(n, axis=-1, keepdims=True)


# ------------------------------------------------------------------ 2 RENDER
def render(land, W, H, ppm=24, pitch=14, cam_h=1.7, steps=900):
    """Column march. ppm = pixels per metre at the ground point under the screen centre."""
    p = np.radians(pitch)
    zc0 = cam_h / np.sin(p)                    # camera-space depth of the centre ground point
    F = ppm * zc0
    cx, cy = W / 2, H / 2
    cols = np.arange(W) + 0.5
    rows = np.arange(H)[:, None] + 0.5
    # nearest ground: ray through the bottom row
    ang_bot = p + np.arctan((H - cy) / F)
    z_near = max(0.05, cam_h / np.tan(min(ang_bot, 1.5)) * 0.7)
    ang_top = p - np.arctan(cy / F)
    z_far = 2000.0 if ang_top <= 0.002 else min(2000.0, cam_h / np.tan(ang_top) * 3 + 50)
    zs = z_near * (z_far / z_near) ** (np.arange(steps) / (steps - 1))
    Z = np.full((H, W), np.inf); X = np.zeros((H, W))
    ymin = np.full(W, H + 0.0)
    for z in zs:
        zc_est = z * np.cos(p) + cam_h * np.sin(p)
        x = (cols - cx) * zc_est / F
        h = land.h(x, np.full_like(x, z))
        y = h - cam_h
        zc = -y * np.sin(p) + z * np.cos(p)
        yc = y * np.cos(p) + z * np.sin(p)
        row = cy - F * yc / np.maximum(zc, 1e-3)
        m = (rows >= row[None, :]) & (rows < ymin[None, :])
        if m.any():
            Z = np.where(m, z, Z)
            X = np.where(m, x[None, :], X)
            ymin = np.minimum(ymin, row)
    sky = ~np.isfinite(Z)
    Zs = np.where(sky, z_far, Z)
    buf = dict(z=Zs, x=X, sky=sky, F=F, pitch=p, cam_h=cam_h)
    zc = Zs * np.cos(p) + cam_h * np.sin(p)
    buf['ppm'] = F / zc                         # local pixels per metre
    buf['h'] = land.h(X, Zs)
    buf['n'] = land.normal(X, Zs)
    buf['n_macro'] = land.normal(X, Zs, e=0.8, micro=False)
    return buf


# ------------------------------------------------------------------ 3 LIGHT
HOURS = {
    #          sun az, el   sun colour         sky/shade colour      haze colour
    'noon':    (35, 58, (1.00, 0.97, 0.88), (0.42, 0.55, 0.80), (0.70, 0.80, 0.92)),
    'morning': (-30, 24, (1.00, 0.90, 0.72), (0.40, 0.50, 0.75), (0.78, 0.80, 0.88)),
    'golden':  (20, 14, (1.00, 0.72, 0.42), (0.36, 0.40, 0.66), (0.90, 0.72, 0.58)),
    'dusk':    (70, 5, (0.95, 0.52, 0.40), (0.34, 0.30, 0.52), (0.70, 0.52, 0.62)),
    'overcast': (40, 70, (0.62, 0.64, 0.66), (0.55, 0.58, 0.63), (0.74, 0.76, 0.78)),
}

def sun_vec(az, el):
    """az: 0 = sun on the left of the frame, 90 = behind the scene (contre-jour), -90 = behind camera."""
    a, e = np.radians(az), np.radians(el)
    return np.array([-np.cos(a) * np.cos(e), np.sin(e), np.sin(a) * np.cos(e)])

def canopy_field(x, z, cover, scale, sunv, seed):
    """Shadows of off-frame trees: soft union of crown shadows, stretched along the sun's azimuth
    (longer when the sun is low). Returns sun visibility 0..1 (continuous; the dither makes edges)."""
    if cover <= 0:
        return np.ones_like(x)
    r = np.random.default_rng(seed + 991)
    n = 900
    cxs = r.uniform(-120, 120, n); czs = r.uniform(-10, 400, n)
    rad = scale * r.uniform(0.5, 1.4, n)
    sx, sz = sunv[0], sunv[2]
    hl = np.hypot(sx, sz) + 1e-6
    ux, uz = sx / hl, sz / hl                        # horizontal sun direction
    stretch = np.clip(1 / np.tan(np.arcsin(np.clip(sunv[1], 0.05, 1))), 1, 4)
    acc = np.zeros_like(x)
    for cx, cz, rr in zip(cxs, czs, rad):
        dx, dz = x - cx, z - cz
        if np.abs(dz).min() > rr * 5 and np.abs(dx).min() > rr * 5:
            continue
        a = (dx * ux + dz * uz) / (rr * stretch)
        b = (-dx * uz + dz * ux) / rr
        acc = acc + np.exp(-(a * a + b * b) * 1.5)
    # threshold so that 'cover' of the ground is in shade; soft band gives dither in the edge
    thr = np.quantile(acc, 1 - cover) if cover < 1 else acc.min() - 1
    return 1 - 1 / (1 + np.exp(-(acc - thr) / 0.08))

def light(land, buf, az, el, canopy=0.0, canopy_scale=3.0, seed=0, mass_blur=1.2):
    s = sun_vec(az, el)
    n = buf['n']; nm = buf['n_macro']
    X, Z, Hh = buf['x'], buf['z'], buf['h']
    ndl_macro = np.clip((nm * s).sum(-1), 0, 1)
    ndl_micro = np.clip((n * s).sum(-1), 0, 1)
    # terrain horizon shadow: march toward the sun
    vis = np.ones_like(X)
    hor = np.hypot(s[0], s[2]) + 1e-6
    tan_el = s[1] / hor
    for t in [0.5, 1, 2, 3.5, 5.5, 8, 12, 18, 26, 38, 55, 80]:
        xs = X + s[0] / hor * t; zs = Z + s[2] / hor * t
        hs = land.h(xs, zs, micro=False)
        occl = hs - (Hh + tan_el * t)
        vis = np.minimum(vis, np.clip(0.5 - occl / (0.08 + 0.02 * t), 0, 1))
    can = canopy_field(X, Z, canopy, canopy_scale, s, seed)
    sunvis = vis * can
    # hollow occlusion: height below the local mean (large-scale) -> less sky
    hl = land.h(X, Z, micro=False)
    ring = np.mean([land.h(X + dx, Z + dz, micro=False) for dx, dz in
                    [(3, 0), (-3, 0), (0, 3), (0, -3), (2, 2), (-2, 2), (2, -2), (-2, -2)]], 0)
    hollow = np.clip((ring - hl) / 0.5, -1, 1)
    sky = np.clip(0.55 + 0.45 * n[..., 1] - 0.25 * hollow, 0, 1)
    return dict(ndl=ndl_macro, ndl_micro=ndl_micro, sunvis=sunvis, terrain_vis=vis, canopy=can,
                sky=sky, hollow=hollow, s=s)


# ------------------------------------------------------------------ 4 VALUE PLAN
def value_plan(buf, L, key='golden', sep=0.9, sheen=0.5, lit_floor=0.10):
    """Continuous step coordinate v in [0, 5.99]: shadow family 0..2.99, light family 3..5.99."""
    direct = L['sunvis'] * L['ndl']
    # a flat plane at this sun takes N.L = s_y; normalize so flat ground sits mid-light family
    sy = max(L['s'][1], 0.06)
    rel = np.clip(direct / max(sy * 1.35, 0.12), 0, 1.25)
    lit = (L['sunvis'] > 0.5) & (L['ndl'] > lit_floor * sy)
    # light family: 3.0 .. 5.99 ; strongly turned toward the sun -> top step
    v_lit = 3.0 + 2.99 * np.clip(rel ** 0.8, 0, 1)
    # shadow family: 0 .. 2.99 from sky ambient; faces turned to the sun but shadowed (cast)
    # keep a little reflected light (they are the 'lit' plane in shadow) -> v up to 2.7
    v_sh = 0.25 + 2.3 * L['sky'] + 0.4 * np.clip(L['ndl'] / sy, 0, 1) * (1 - L['terrain_vis'] * L['canopy'])
    v_sh = np.clip(v_sh - 0.5, 0, 2.99)
    v = np.where(lit, v_lit, v_sh)
    # the soft canopy edge: blend across the family boundary only inside the penumbra
    pen = (L['sunvis'] > 0.15) & (L['sunvis'] < 0.85) & (L['ndl'] > lit_floor * sy)
    t = np.clip((L['sunvis'] - 0.15) / 0.7, 0, 1)
    v = np.where(pen, (1 - t) * v_sh + t * v_lit, v)
    # grazing sheen: ground brightens as it recedes (Ferrari's receding planes, V16 banks)
    d = buf['z']
    v = v + sheen * np.clip(np.log(d / 3) / 4, 0, 1) * (v >= 3)
    # crest separation: land just behind (above on screen) a crest silhouette drops a step
    Hh, W = v.shape
    z = buf['z']
    jump = np.zeros_like(v, bool)
    jump[:-1] = (z[:-1] > z[1:] * 1.25) & ~buf['sky'][:-1]
    k = 0
    sepm = np.zeros_like(v)
    cur = jump.copy()
    for k in range(1, 7):
        sepm = np.maximum(sepm, cur * (1 - k / 7))
        cur = np.roll(cur, -1, 0); cur[-1] = False
    v = v - sep * sepm
    return np.clip(v, 0, 5.99), lit, sepm


# ------------------------------------------------------------------ 5 MATERIAL
MATS = ['turf', 'meadow', 'earth', 'gravel', 'forest', 'moss', 'water']
MI = {m: i for i, m in enumerate(MATS)}

def materials(land, buf, ground, moisture, L, seed=0):
    X, Z = buf['x'], buf['z']
    mat = np.full(X.shape, MI[ground], int)
    slope = 1 - buf['n_macro'][..., 1]
    wet = np.clip(moisture + 0.6 * np.clip(L['hollow'], 0, 1) - 0.15, 0, 1)
    if ground in ('turf', 'meadow'):
        mat[slope > 0.22] = MI['earth']          # bare risers and cut banks
    if land.path:
        pd = land.path_dist(X, Z)
        # ragged edge: the path's width breathes and its border is broken by the grass
        r = np.random.default_rng(seed + 5)
        edge = 0.75 + 0.15 * np.sin(Z * 1.7 + 1) + 0.1 * np.sin(Z * 4.3 + X)
        mat[pd < edge] = MI['earth']
    if ground == 'forest':
        mat[(wet > 0.45) & (L['canopy'] < 0.7)] = MI['moss']
        mat[(wet > 0.6)] = MI['moss']
    # puddles: earth in hollows when wet
    puddle = (mat == MI['earth']) & (wet > 0.72) & (L['hollow'] > 0.25)
    mat[puddle] = MI['water']
    if land.water is not None:
        mat[buf['h'] < -1.2] = MI['water']
    return mat, wet


# ------------------------------------------------------------------ 6 MARKS
def runs_field(Hh, W, runlen, rng, vertical=True, amp=None):
    """Random offsets constant along runs (strands if vertical, streaks if horizontal).
    runlen: per-pixel mean run length (px). Returns offsets ~ N(0,1) per run."""
    out = np.zeros((Hh, W))
    if not vertical:
        return runs_field(W, Hh, runlen.T, rng, True).T
    for x in range(W):
        y = Hh - 1
        col = runlen[:, x]
        while y >= 0:
            L = max(1, int(round(col[y] * rng.uniform(0.45, 1.4))))
            o = rng.normal()
            out[max(0, y - L + 1): y + 1, x] = o
            y -= L
    return out

def stones_field(buf, size_m, rng, density=0.7):
    """Gravel / litter: small blobs with a light top row and a dark lower rim (offsets)."""
    Hh, W = buf['z'].shape
    out = np.zeros((Hh, W))
    ppm = buf['ppm']
    n = int(Hh * W * density / 6)
    ys = rng.integers(0, Hh, n); xs = rng.integers(0, W, n)
    order = np.argsort(ys)
    for i in order:
        y, x = ys[i], xs[i]
        s = size_m * ppm[y, x] * rng.uniform(0.6, 1.5)
        if s < 1.6:
            out[y, x] += rng.choice([-1, 1]) * 0.8
            continue
        a = max(1, int(round(s / 2))); b = max(1, int(round(s / 3.2)))
        y0, y1 = max(0, y - b), min(Hh, y + b + 1)
        x0, x1 = max(0, x - a), min(W, x + a + 1)
        yy, xx = np.mgrid[y0:y1, x0:x1]
        q = ((xx - x) / (a + 0.5)) ** 2 + ((yy - y) / (b + 0.5)) ** 2
        m = q < 1
        tone = rng.normal(0.3, 0.5)
        top = (yy - y) < -b * 0.2
        bot = (yy - y) > b * 0.4
        blk = np.where(top, tone + 0.8, np.where(bot, tone - 0.5, tone))
        sub = out[y0:y1, x0:x1]
        sub[m] = blk[m]
        # pocket under the stone
        yb = min(Hh - 1, y + b + 1)
        out[yb, x0:x1][np.abs(np.arange(x0, x1) - x) < a * 0.8] = -1.3
    return out

def marks(buf, mat, v, lit, rng, contrast=1.0):
    """Offsets in step space. Size follows px-per-metre; contrast follows family & distance."""
    Hh, W = v.shape
    ppm = buf['ppm']
    off = np.zeros((Hh, W))
    # grass blades: vertical strands, height ~0.22 m turf / 0.5 m meadow
    for m, bh_m in (('turf', 0.2), ('meadow', 0.45), ('moss', 0.05)):
        sel = mat == MI[m]
        if not sel.any():
            continue
        bh = bh_m * ppm
        rl = np.clip(bh * 0.9, 1, 14)
        o = runs_field(Hh, W, rl, rng, True)
        # contrast grows with nearness (blade px), strong in light, weaker in shadow
        a = np.clip((bh - 0.6) / 4.0, 0.12, 1.0) * np.where(lit, 0.95, 0.6)
        # in light: marks are mostly *dark* (strands and gaps on a lit base) -- Ferrari's economy
        o = np.where(lit, np.where(o < 0.35, o * 1.25, o * 0.45), o)
        off[sel] = (a * o)[sel]
    # earth: horizontal streaks (ruts, the grain of the ground plane) + pebbles
    sel = mat == MI['earth']
    if sel.any():
        rl = np.clip(0.5 * ppm, 1, 18)
        o = runs_field(Hh, W, rl, rng, False)
        a = np.clip((0.3 * ppm - 0.5) / 4, 0.1, 0.7)
        st = stones_field(buf, 0.035, rng, density=0.18)
        off[sel] = (a * o * 0.8 + 0.8 * np.clip(0.03 * ppm, 0.15, 1) * st)[sel]
    sel = mat == MI['gravel']
    if sel.any():
        st = stones_field(buf, 0.06, rng, density=0.9)
        off[sel] = (np.clip(0.05 * ppm / 2, 0.2, 1) * st)[sel]
    sel = mat == MI['forest']
    if sel.any():
        st = stones_field(buf, 0.05, rng, density=0.8)       # leaf litter clumps
        rl = np.clip(0.08 * ppm, 1, 6)
        o = runs_field(Hh, W, rl, rng, True) * 0.4
        off[sel] = (np.clip(0.05 * ppm / 1.5, 0.2, 1) * st * np.where(lit, 1.0, 0.55) + o)[sel]
    return off * contrast


# ------------------------------------------------------------------ 8 PALETTE
ALBEDO = {  # linear-ish albedo, hand-picked; hue drift across the ramp added below
    'turf': (0.20, 0.30, 0.07), 'meadow': (0.36, 0.33, 0.10), 'earth': (0.34, 0.22, 0.13),
    'gravel': (0.34, 0.32, 0.29), 'forest': (0.26, 0.17, 0.09), 'moss': (0.18, 0.30, 0.05),
    'water': (0.5, 0.5, 0.5)}

def srgb(c):
    c = np.clip(c, 0, 1)
    return np.where(c <= 0.0031308, 12.92 * c, 1.055 * c ** (1 / 2.4) - 0.055)

def ramp(mat, hour, band=0, wet=0.0, nb=3):
    """6 steps: 0 core/deep shadow, 1 shadow, 2 reflected/halftone-in-shadow | 3 halftone, 4 light, 5 top light."""
    az, el, sunc, skyc, haze = HOURS[hour]
    alb = np.array(ALBEDO[mat])
    if wet > 0:
        alb = alb * (1 - 0.45 * wet) ** 1.0
        alb = alb ** (1 + 0.25 * wet) * (1 + 0.4 * wet)   # saturate a little
    sunc, skyc, haze = map(np.array, (sunc, skyc, haze))
    if mat == 'water':
        c = [haze * k for k in (0.35, 0.5, 0.62, 0.75, 0.88, 1.0)]
    else:
        shade = [alb * skyc * k for k in (0.05, 0.13, 0.24)]
        litv = [alb * (sunc * k + skyc * 0.18) for k in (0.55, 0.85, 1.15)]
        # the top light shifts warm/yellow (the lit ochre of V16PM, the yellow islet of V16)
        litv[2] = litv[2] * np.array([1.08, 1.05, 0.8])
        # the core shifts cool-dark (the teal darks of V16)
        shade[0] = shade[0] * np.array([0.7, 0.95, 1.4])
        c = shade + litv
    c = np.array(c)
    out = srgb(c * 2.2)
    # depth band: far bands lift the black point toward the haze and compress
    f = [0.0, 0.22, 0.45][band]
    out = out * (1 - f) + srgb(haze * 0.8)[None] * f * np.linspace(1.0, 0.5, 6)[:, None]
    return (np.clip(out, 0, 1) * 255).astype(np.uint8)


# ------------------------------------------------------------------ assemble
class Ground:
    pass

def paint_ground(W=320, H=200, ground='turf', landform='rolling', hour='golden', sun=None,
                 moisture=0.2, ppm=24, pitch=14, cam_h=1.7, canopy=0.0, canopy_scale=3.0,
                 path=False, relief=1.0, sep=0.9, sheen=0.5, contrast=1.0, jitter=0.5,
                 band_z=(12, 45), seed=0, water=None):
    rng = np.random.default_rng(seed)
    land = Landform(landform, seed, relief, path=path, water=water)
    buf = render(land, W, H, ppm, pitch, cam_h)
    az, el = (sun if sun else HOURS[hour][:2])
    L = light(land, buf, az, el, canopy, canopy_scale, seed)
    v, lit, sepm = value_plan(buf, L, hour, sep=sep, sheen=sheen)
    mat, wet = materials(land, buf, ground, moisture, L, seed)
    # moisture darkens the mass (a step at full wet) -- a mass-level change, not a texture
    v = v - 0.9 * wet * (v > 0.5)
    off = marks(buf, mat, v, lit, rng, contrast)
    # distance: mark contrast compresses with depth beyond the near band
    fade = np.clip(1.2 - np.log(buf['z'] / band_z[0] + 1e-6) / 2.2, 0.15, 1)
    vv = v + off * fade
    # keep the families apart: marks may not cross the terminator (only a thin fringe may)
    vv = np.where(lit, np.clip(vv, 2.6, 5.99), np.clip(vv, 0, 3.1))
    step = dither.ordered(vv, jitter=jitter, rng=rng, n=6)
    band = np.digitize(buf['z'], band_z)
    mat[buf['sky']] = -1
    g = Ground()
    g.__dict__.update(mat=mat, band=band, step=step, v=v, vv=vv, lit=lit, wet=wet, buf=buf, L=L,
                      land=land, depth=buf['z'], wx=buf['x'], wz=buf['z'], h=buf['h'], sepm=sepm,
                      hour=hour, moisture=moisture)
    g.relight = lambda hr, g=g: colour(g, hr)
    g.rgb = colour(g, hour)
    return g

def colour(g, hour, sky_rgb=None):
    Hh, W = g.step.shape
    out = np.zeros((Hh, W, 3), np.uint8)
    az, el, sunc, skyc, haze = HOURS[hour]
    for mi, m in enumerate(MATS):
        for b in range(3):
            for wetk, wsel in ((0, g.wet < 0.5), (1, g.wet >= 0.5)):
                sel = (g.mat == mi) & (g.band == b) & wsel
                if sel.any():
                    R = ramp(m, hour, b, wet=0.8 * wetk if g.moisture > 0 else 0)
                    out[sel] = R[g.step[sel]]
    sky = g.mat < 0
    if sky.any():
        out[sky] = (srgb(np.array(haze) * 0.9) * 255).astype(np.uint8)
    return out
Bash: First render test of ground engine
cd ~/work/pixelart-studies/elements/ground/src && cat > try1.py <<'EOF'
import ground, lw, numpy as np, time, sys
t=time.time()
cells=[]
for lf,hr,kw in [('rolling','golden',{}),('hummocks','noon',{}),('bank','golden',dict(ppm=30)),('rolling','noon',dict(path=True,ppm=40,pitch=20))]:
    g=ground.paint_ground(240,150,landform=lf,hour=hr,seed=3,**kw); cells.append(g.rgb)
    print(lf,hr,time.time()-t)
a=np.concatenate([np.concatenate(cells[:2],1),np.concatenate(cells[2:],1)],0)
lw.save(a,'../stages/try1_3x.png',3)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 try1.py

# output:
~/work/pixelart-studies/elements/ground/src/ground.py:78: RuntimeWarning: invalid value encountered in power
  H = H + ht * self.relief * np.where(q < 1, (1 - q) ** 1.5, 0)
rolling golden 1.7065777778625488
hummocks noon 14.984162092208862
bank golden 26.13091492652893
rolling noon 28.021697998046875
Read: elements/ground/stages/try1_3x.png

elements/ground/stages/try1_3x.png 32. elements/ground/stages/try1_3x.png

Bash: Rework palette to L* targets and value mapping
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
import re
s=open('ground.py').read()
s=s.replace("H = H + ht * self.relief * np.where(q < 1, (1 - q) ** 1.5, 0)","H = H + ht * self.relief * np.clip(1 - q, 0, None) ** 1.5")
# value plan lit mapping
s=s.replace("""    v_lit = 3.0 + 2.99 * np.clip(rel ** 0.8, 0, 1)""","""    r = L['ndl'] * L['sunvis'] / sy                 # 1 = a flat plane in full sun
    v_lit = 3.0 + 3.0 * np.clip(0.42 + 0.85 * (r - 1), 0, 0.999)""")
# palette by L* targets
old=s[s.index("def ramp(mat"):s.index("# ------------------------------------------------------------------ assemble")]
new='''LSTEP = {  # target CIE L* per step: few steps, far apart; darks near black (V16 / V16PM ramps)
    'turf':   (4, 11, 21, 34, 49, 66),
    'meadow': (6, 14, 25, 40, 56, 72),
    'earth':  (7, 15, 25, 37, 50, 63),
    'gravel': (8, 17, 28, 42, 57, 72),
    'forest': (4, 10, 19, 31, 45, 60),
    'moss':   (5, 12, 22, 36, 52, 70),
    'water':  (30, 40, 50, 60, 70, 80)}

def _lin(c):
    c = np.clip(c, 0, 1)
    return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)

def _Y2L(Y):
    return np.where(Y > 0.008856, 116 * np.cbrt(Y) - 16, 903.3 * Y)

def _L2Y(L):
    return np.where(L > 8, ((L + 16) / 116) ** 3, L / 903.3)

def _set_L(rgb_lin, L):
    """scale a linear colour to hit luminance L* (clipping pushes toward white gracefully)"""
    Y = (rgb_lin * np.array([0.2126, 0.7152, 0.0722])).sum()
    c = rgb_lin * (_L2Y(L) / max(Y, 1e-6))
    for _ in range(6):                                   # redistribute clipped energy
        over = np.clip(c - 1, 0, None).sum()
        c = np.clip(c, 0, 1)
        if over < 1e-4: break
        c = c + (1 - c) * over / 3
    return c

def ramp(mat, hour, band=0, wet=0.0):
    """6 steps: 0 core, 1 shadow, 2 reflected light | 3 halftone, 4 light, 5 top light."""
    az, el, sunc, skyc, haze = HOURS[hour]
    alb = np.array(ALBEDO[mat]); sunc, skyc, haze = map(np.array, (sunc, skyc, haze))
    Ls = np.array(LSTEP[mat], float)
    # the light family's value follows the light's strength (dusk and overcast are low-key)
    key = {'noon': 1.0, 'morning': 0.95, 'golden': 0.92, 'dusk': 0.72, 'overcast': 0.82}[hour]
    Ls[3:] = Ls[3:] * key
    if hour == 'overcast':
        Ls[:3] = Ls[:3] + np.array([6, 6, 4])            # soft light: shadows open up, no terminator
    if wet > 0:
        Ls = Ls * (1 - 0.3 * wet)
        alb = alb ** (1 + 0.6 * wet)                     # darker and more saturated
    out = []
    for i in range(6):
        if mat == 'water':
            c = haze * (0.8 + 0.05 * i)
        elif i < 3:
            c = alb * skyc * (1.0 if i else 0.9)
            if i == 0: c = c * np.array([0.75, 0.95, 1.35])   # cool core (V16's teal darks)
        else:
            c = alb * (sunc + skyc * 0.15)
            if i == 5: c = c * np.array([1.12, 1.06, 0.62])    # top light goes yellow/ochre
            if i == 3: c = c * 0.7 + alb * skyc * 0.6          # halftone keeps some sky
        out.append(_set_L(c, Ls[i]))
    out = np.array(out)
    f = [0.0, 0.18, 0.4][band]
    hz = _lin(np.array([0, 0, 0]) + 0) + haze * 0.55
    w = f * np.linspace(1.0, 0.35, 6)[:, None]              # far: black point lifts toward haze
    out = out * (1 - w) + hz[None] * w
    return (np.clip(srgb(out), 0, 1) * 255).astype(np.uint8)


def palette_sheet(k=12):
    rows = []
    for hr in HOURS:
        row = []
        for m in MATS:
            for b in range(3):
                row.append(np.repeat(ramp(m, hr, b)[None], 1, 0))
            row.append(np.zeros((1, 1, 3), np.uint8))
        rows.append(np.concatenate(row, 1))
    return np.concatenate(rows, 0)


'''
s=s.replace(old,new)
open('ground.py','w').write(s)
EOF
python3 -c "
import ground,lw; lw.save(ground.palette_sheet(),'../stages/palettes_16x.png',16)"

# output:
[This command modified 1 file you've previously read: ../pixelart-studies/elements/ground/src/ground.py. Call Read before editing.]
Read: elements/ground/stages/palettes_16x.png

elements/ground/stages/palettes_16x.png 33. elements/ground/stages/palettes_16x.png

Bash: Re-render test grid
cd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 try1.py 2>&1 | tail -1

# output:
rolling noon 21.784557819366455
Read: elements/ground/stages/try1_3x.png

elements/ground/stages/try1_3x.png 34. elements/ground/stages/try1_3x.png

Bash: Show paint_ground current code
cd ~/work/pixelart-studies/elements/ground/src && grep -n "def canopy_field" -A 30 ground.py | head -5; grep -n "def paint_ground" -A 40 ground.py

# output:
162:def canopy_field(x, z, cover, scale, sunv, seed):
163-    """Shadows of off-frame trees: soft union of crown shadows, stretched along the sun's azimuth
164-    (longer when the sun is low). Returns sun visibility 0..1 (continuous; the dither makes edges)."""
165-    if cover <= 0:
166-        return np.ones_like(x)
456:def paint_ground(W=320, H=200, ground='turf', landform='rolling', hour='golden', sun=None,
457-                 moisture=0.2, ppm=24, pitch=14, cam_h=1.7, canopy=0.0, canopy_scale=3.0,
458-                 path=False, relief=1.0, sep=0.9, sheen=0.5, contrast=1.0, jitter=0.5,
459-                 band_z=(12, 45), seed=0, water=None):
460-    rng = np.random.default_rng(seed)
461-    land = Landform(landform, seed, relief, path=path, water=water)
462-    buf = render(land, W, H, ppm, pitch, cam_h)
463-    az, el = (sun if sun else HOURS[hour][:2])
464-    L = light(land, buf, az, el, canopy, canopy_scale, seed)
465-    v, lit, sepm = value_plan(buf, L, hour, sep=sep, sheen=sheen)
466-    mat, wet = materials(land, buf, ground, moisture, L, seed)
467-    # moisture darkens the mass (a step at full wet) -- a mass-level change, not a texture
468-    v = v - 0.9 * wet * (v > 0.5)
469-    off = marks(buf, mat, v, lit, rng, contrast)
470-    # distance: mark contrast compresses with depth beyond the near band
471-    fade = np.clip(1.2 - np.log(buf['z'] / band_z[0] + 1e-6) / 2.2, 0.15, 1)
472-    vv = v + off * fade
473-    # keep the families apart: marks may not cross the terminator (only a thin fringe may)
474-    vv = np.where(lit, np.clip(vv, 2.6, 5.99), np.clip(vv, 0, 3.1))
475-    step = dither.ordered(vv, jitter=jitter, rng=rng, n=6)
476-    band = np.digitize(buf['z'], band_z)
477-    mat[buf['sky']] = -1
478-    g = Ground()
479-    g.__dict__.update(mat=mat, band=band, step=step, v=v, vv=vv, lit=lit, wet=wet, buf=buf, L=L,
480-                      land=land, depth=buf['z'], wx=buf['x'], wz=buf['z'], h=buf['h'], sepm=sepm,
481-                      hour=hour, moisture=moisture)
482-    g.relight = lambda hr, g=g: colour(g, hr)
483-    g.rgb = colour(g, hour)
484-    return g
485-
486-def colour(g, hour, sky_rgb=None):
487-    Hh, W = g.step.shape
488-    out = np.zeros((Hh, W, 3), np.uint8)
489-    az, el, sunc, skyc, haze = HOURS[hour]
490-    for mi, m in enumerate(MATS):
491-        for b in range(3):
492-            for wetk, wsel in ((0, g.wet < 0.5), (1, g.wet >= 0.5)):
493-                sel = (g.mat == mi) & (g.band == b) & wsel
494-                if sel.any():
495-                    R = ramp(m, hour, b, wet=0.8 * wetk if g.moisture > 0 else 0)
496-                    out[sel] = R[g.step[sel]]

Now add the design layer: big off-frame shadows, vignette, crest fringe.

Bash: Add shade, vignette, crest fringe; render
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
old=s[s.index("    r = np.random.default_rng(seed + 991)\n    n = 900"):s.index("    acc = np.zeros_like(x)")]
new='''    r = np.random.default_rng(seed + 991)
    x0, x1 = np.percentile(x, 1) - 3 * scale, np.percentile(x, 99) + 3 * scale
    z0, z1 = np.percentile(z, 1) - 3 * scale, np.percentile(z, 97) + 3 * scale
    area = (x1 - x0) * (z1 - z0)
    n = int(np.clip(area / (np.pi * scale ** 2) * 1.2, 8, 700))
    cxs = r.uniform(x0, x1, n); czs = r.uniform(z0, z1, n)
    rad = scale * r.uniform(0.5, 1.4, n)
'''
s=s.replace(old,new)
# pass big shadow ("shade") as a second canopy field in light()
s=s.replace("def light(land, buf, az, el, canopy=0.0, canopy_scale=3.0, seed=0, mass_blur=1.2):",
 "def light(land, buf, az, el, canopy=0.0, canopy_scale=3.0, seed=0, shade=0.0, shade_scale=12.0):")
s=s.replace("    can = canopy_field(X, Z, canopy, canopy_scale, s, seed)\n",
 "    can = canopy_field(X, Z, canopy, canopy_scale, s, seed)\n    can = can * canopy_field(X, Z, shade, shade_scale, s, seed + 17)\n")
old=s[s.index("def paint_ground("):s.index("def colour(g, hour")]
new='''def crest_fringe(step, mat, band, buf, lit, rng, tip=0.2, holes=0.3):
    """Blade tips break the top of every crest: the near mass's grass pokes up over the land
    behind it (lit peaks), and dark holes open along the crest line (V16PM C2/C3 mound)."""
    z = buf['z']; Hh, W = z.shape
    ppm = buf['ppm']
    crest = np.zeros_like(z, bool)
    crest[1:] = (z[:-1] > z[1:] * 1.25) | buf['sky'][:-1]
    crest &= ~buf['sky']
    ys, xs = np.nonzero(crest)
    for y, x in zip(ys, xs):
        m = mat[y, x]
        if m not in (MI['turf'], MI['meadow'], MI['moss'], MI['forest']):
            continue
        bh = tip * (2.2 if m == MI['meadow'] else 1) * ppm[y, x]
        if bh < 0.8:
            continue
        u = rng.random()
        if u < holes:
            step[y, x] = max(0, step[y, x] - 2)
            continue
        if u < holes + 0.45:
            L = int(np.clip(round(bh * rng.uniform(0.3, 1.0)), 1, 12))
            s0 = step[y, x] + (1 if lit[y, x] and rng.random() < 0.5 else 0)
            for k in range(1, L + 1):
                if y - k < 0: break
                step[y - k, x] = min(5, s0); mat[y - k, x] = m; band[y - k, x] = band[y, x]
    return step, mat, band

def paint_ground(W=320, H=200, ground='turf', landform='rolling', hour='golden', sun=None,
                 moisture=0.2, ppm=24, pitch=14, cam_h=1.7, canopy=0.0, canopy_scale=3.0,
                 shade=0.0, shade_scale=12.0, vignette=0.6, path=False, relief=1.0, sep=0.9,
                 sheen=0.5, contrast=1.0, jitter=0.5, band_z=(12, 45), seed=0, water=None):
    """ground: turf|meadow|earth|gravel|forest   landform: flat|rolling|hummocks|bank|ledges
    hour: noon|morning|golden|dusk|overcast (or sun=(az,el))   moisture 0..1
    ppm: px per metre at screen centre (the distance knob)   pitch/cam_h: camera
    canopy (+canopy_scale m): dapple from off-frame trees; shade (+shade_scale m): big cast
    shadows from off-frame forest/cloud -- Ferrari's compositional shadow masses
    vignette: darkens the bottom corners inside each family (repoussoir)"""
    rng = np.random.default_rng(seed)
    land = Landform(landform, seed, relief, path=path, water=water)
    buf = render(land, W, H, ppm, pitch, cam_h)
    az, el = (sun if sun else HOURS[hour][:2])
    L = light(land, buf, az, el, canopy, canopy_scale, seed, shade, shade_scale)
    v, lit, sepm = value_plan(buf, L, hour, sep=sep, sheen=sheen)
    mat, wet = materials(land, buf, ground, moisture, L, seed)
    # moisture darkens the mass (a step at full wet) -- a mass-level change, not a texture
    v = v - 0.9 * wet * (v > 0.5)
    # repoussoir: bottom corners sink, inside each family
    yy, xx = np.mgrid[0:H, 0:W]
    vg = np.clip((yy / H - 0.55) / 0.45, 0, 1) ** 1.5 * (0.45 + 0.55 * np.abs(xx - W / 2) / (W / 2))
    v = v - vignette * 1.6 * vg
    off = marks(buf, mat, v, lit, rng, contrast)
    # distance: mark contrast compresses with depth beyond the near band
    fade = np.clip(1.2 - np.log(buf['z'] / band_z[0] + 1e-6) / 2.2, 0.15, 1)
    vv = v + off * fade
    # keep the families apart: marks may not cross the terminator (only a thin fringe may)
    vv = np.where(lit, np.clip(vv, 2.6, 5.99), np.clip(vv, 0, 3.1))
    step = dither.ordered(vv, jitter=jitter, rng=rng, n=6)
    band = np.digitize(buf['z'], band_z)
    mat[buf['sky']] = -1
    step, mat, band = crest_fringe(step, mat, band, buf, lit, rng)
    g = Ground()
    g.__dict__.update(mat=mat, band=band, step=step, v=v, vv=vv, lit=lit, wet=wet, buf=buf, L=L,
                      land=land, depth=buf['z'], wx=buf['x'], wz=buf['z'], h=buf['h'], sepm=sepm,
                      hour=hour, moisture=moisture)
    g.relight = lambda hr, g=g: colour(g, hr)
    g.rgb = colour(g, hour)
    return g

'''
s=s.replace(old,new)
open('ground.py','w').write(s)
EOF
cat > try2.py <<'EOF'
import ground, lw, numpy as np, time
t=time.time(); cells=[]
for lf,hr,kw in [('rolling','golden',dict(shade=0.3)),('hummocks','noon',dict(shade=0.4)),
                 ('bank','golden',dict(ppm=30,shade=0.35)),('rolling','noon',dict(path=True,ppm=40,pitch=20,shade=0.3))]:
    g=ground.paint_ground(240,150,landform=lf,hour=hr,seed=3,**kw); cells.append(g.rgb); print(lf,time.time()-t)
lw.save(np.concatenate([np.concatenate(cells[:2],1),np.concatenate(cells[2:],1)],0),'../stages/try2_3x.png',3)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 try2.py 2>&1|tail -2

# output:
^^
NameError: name 'ux' is not defined. Did you mean: 'x'?
Read: elements/ground/stages/try2_3x.png
Bash: Inspect canopy_field
cd ~/work/pixelart-studies/elements/ground/src && sed -n 162,195p ground.py

# output:
def canopy_field(x, z, cover, scale, sunv, seed):
    """Shadows of off-frame trees: soft union of crown shadows, stretched along the sun's azimuth
    (longer when the sun is low). Returns sun visibility 0..1 (continuous; the dither makes edges)."""
    if cover <= 0:
        return np.ones_like(x)
    r = np.random.default_rng(seed + 991)
    x0, x1 = np.percentile(x, 1) - 3 * scale, np.percentile(x, 99) + 3 * scale
    z0, z1 = np.percentile(z, 1) - 3 * scale, np.percentile(z, 97) + 3 * scale
    area = (x1 - x0) * (z1 - z0)
    n = int(np.clip(area / (np.pi * scale ** 2) * 1.2, 8, 700))
    cxs = r.uniform(x0, x1, n); czs = r.uniform(z0, z1, n)
    rad = scale * r.uniform(0.5, 1.4, n)
    acc = np.zeros_like(x)
    for cx, cz, rr in zip(cxs, czs, rad):
        dx, dz = x - cx, z - cz
        if np.abs(dz).min() > rr * 5 and np.abs(dx).min() > rr * 5:
            continue
        a = (dx * ux + dz * uz) / (rr * stretch)
        b = (-dx * uz + dz * ux) / rr
        acc = acc + np.exp(-(a * a + b * b) * 1.5)
    # threshold so that 'cover' of the ground is in shade; soft band gives dither in the edge
    thr = np.quantile(acc, 1 - cover) if cover < 1 else acc.min() - 1
    return 1 - 1 / (1 + np.exp(-(acc - thr) / 0.08))

def light(land, buf, az, el, canopy=0.0, canopy_scale=3.0, seed=0, shade=0.0, shade_scale=12.0):
    s = sun_vec(az, el)
    n = buf['n']; nm = buf['n_macro']
    X, Z, Hh = buf['x'], buf['z'], buf['h']
    ndl_macro = np.clip((nm * s).sum(-1), 0, 1)
    ndl_micro = np.clip((n * s).sum(-1), 0, 1)
    # terrain horizon shadow: march toward the sun
    vis = np.ones_like(X)
    hor = np.hypot(s[0], s[2]) + 1e-6
    tan_el = s[1] / hor
Bash: Fix canopy field and render
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""    rad = scale * r.uniform(0.5, 1.4, n)
    acc = np.zeros_like(x)""","""    rad = scale * r.uniform(0.5, 1.4, n)
    sx, sz = sunv[0], sunv[2]
    hl = np.hypot(sx, sz) + 1e-6
    ux, uz = sx / hl, sz / hl                        # horizontal sun direction
    stretch = np.clip(1 / np.tan(np.arcsin(np.clip(sunv[1], 0.05, 1))), 1, 4)
    acc = np.zeros_like(x)""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 try2.py 2>&1|tail -2

# output:
bank 22.55807113647461
rolling 24.094210147857666
Read: elements/ground/stages/try2_3x.png

elements/ground/stages/try2_3x.png 35. elements/ground/stages/try2_3x.png

Bash: Fix shadow family and band seam; compare to V16PM
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""    v_sh = 0.25 + 2.3 * L['sky'] + 0.4 * np.clip(L['ndl'] / sy, 0, 1) * (1 - L['terrain_vis'] * L['canopy'])
    v_sh = np.clip(v_sh - 0.5, 0, 2.99)""","""    v_sh = 0.2 + 1.3 * L['sky'] + 0.7 * np.clip(L['ndl'] / sy, 0, 1.3) * (1 - L['sunvis'])
    v_sh = np.clip(v_sh, 0, 2.6)""")
s=s.replace("    band = np.digitize(buf['z'], band_z)\n    mat[buf['sky']] = -1",
"""    # depth bands meet in a dithered seam, never a ruled line
    lz = np.log(buf['z']) + 0.35 * (dither.tile(dither.B4, H, W) - 0.5) + 0.1 * (rng.random((H, W)) - 0.5)
    band = np.digitize(lz, np.log(band_z))
    mat[buf['sky']] = -1""")
open('ground.py','w').write(s)
EOF
cat > s1_v16.py <<'EOF'
"""Stage 1: my bank at golden hour beside V16PM's left bank, same pixel scale."""
import ground, lw, numpy as np
d=lw.load('V16PM'); ref=lw.rgb(d)[250:450, 0:300]
g=ground.paint_ground(300,200,landform='bank',hour='golden',sun=(15,10),ppm=26,pitch=9,cam_h=1.6,
                      shade=0.3,shade_scale=10,seed=5,water=30)
pad=np.zeros((200,6,3),np.uint8)
lw.save(np.concatenate([ref,pad,g.rgb],1),'../stages/s1_v16pm_3x.png',3)
lw.save(np.concatenate([ref[100:200,60:180],pad[:100],g.rgb[100:200,60:180]],1),'../stages/s1_v16pm_8x.png',8)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_v16.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s1_v16pm_3x.png

elements/ground/stages/s1_v16pm_3x.png 36. elements/ground/stages/s1_v16pm_3x.png

Bash: Show runs_field and marks code
cd ~/work/pixelart-studies/elements/ground/src && grep -n "def runs_field" -A 16 ground.py && grep -n "def marks" -A 22 ground.py

# output:
285:def runs_field(Hh, W, runlen, rng, vertical=True, amp=None):
286-    """Random offsets constant along runs (strands if vertical, streaks if horizontal).
287-    runlen: per-pixel mean run length (px). Returns offsets ~ N(0,1) per run."""
288-    out = np.zeros((Hh, W))
289-    if not vertical:
290-        return runs_field(W, Hh, runlen.T, rng, True).T
291-    for x in range(W):
292-        y = Hh - 1
293-        col = runlen[:, x]
294-        while y >= 0:
295-            L = max(1, int(round(col[y] * rng.uniform(0.45, 1.4))))
296-            o = rng.normal()
297-            out[max(0, y - L + 1): y + 1, x] = o
298-            y -= L
299-    return out
300-
301-def stones_field(buf, size_m, rng, density=0.7):
332:def marks(buf, mat, v, lit, rng, contrast=1.0):
333-    """Offsets in step space. Size follows px-per-metre; contrast follows family & distance."""
334-    Hh, W = v.shape
335-    ppm = buf['ppm']
336-    off = np.zeros((Hh, W))
337-    # grass blades: vertical strands, height ~0.22 m turf / 0.5 m meadow
338-    for m, bh_m in (('turf', 0.2), ('meadow', 0.45), ('moss', 0.05)):
339-        sel = mat == MI[m]
340-        if not sel.any():
341-            continue
342-        bh = bh_m * ppm
343-        rl = np.clip(bh * 0.9, 1, 14)
344-        o = runs_field(Hh, W, rl, rng, True)
345-        # contrast grows with nearness (blade px), strong in light, weaker in shadow
346-        a = np.clip((bh - 0.6) / 4.0, 0.12, 1.0) * np.where(lit, 0.95, 0.6)
347-        # in light: marks are mostly *dark* (strands and gaps on a lit base) -- Ferrari's economy
348-        o = np.where(lit, np.where(o < 0.35, o * 1.25, o * 0.45), o)
349-        off[sel] = (a * o)[sel]
350-    # earth: horizontal streaks (ruts, the grain of the ground plane) + pebbles
351-    sel = mat == MI['earth']
352-    if sel.any():
353-        rl = np.clip(0.5 * ppm, 1, 18)
354-        o = runs_field(Hh, W, rl, rng, False)
Bash: Run-coherent strand thresholds, warmer turf
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace('''    out = np.zeros((Hh, W))
    if not vertical:
        return runs_field(W, Hh, runlen.T, rng, True).T
    for x in range(W):
        y = Hh - 1
        col = runlen[:, x]
        while y >= 0:
            L = max(1, int(round(col[y] * rng.uniform(0.45, 1.4))))
            o = rng.normal()
            out[max(0, y - L + 1): y + 1, x] = o
            y -= L
    return out''','''    out = np.zeros((Hh, W)); thr = np.zeros((Hh, W))
    if not vertical:
        o, t = runs_field(W, Hh, runlen.T, rng, True)
        return o.T, t.T
    for x in range(W):
        y = Hh - 1
        col = runlen[:, x]
        while y >= 0:
            L = max(1, int(round(col[y] * rng.uniform(0.45, 1.4))))
            out[max(0, y - L + 1): y + 1, x] = rng.normal()
            thr[max(0, y - L + 1): y + 1, x] = rng.random()
            y -= L
    return out, thr''')
s=s.replace('''    off = np.zeros((Hh, W))
    # grass blades''','''    off = np.zeros((Hh, W)); thr = np.full((Hh, W), 0.5); coh = np.zeros((Hh, W))
    # grass blades''')
s=s.replace('''        o = runs_field(Hh, W, rl, rng, True)
        # contrast grows''','''        o, t = runs_field(Hh, W, rl, rng, True)
        thr[sel] = t[sel]; coh[sel] = np.clip((bh - 1.0) / 2.5, 0, 1)[sel]
        # contrast grows''')
s=s.replace("        a = np.clip((bh - 0.6) / 4.0, 0.12, 1.0) * np.where(lit, 0.95, 0.6)",
            "        a = np.clip((bh - 0.6) / 4.0, 0.12, 1.0) * np.where(lit, 1.25, 0.8)")
s=s.replace('''        o = runs_field(Hh, W, rl, rng, False)
        a = np.clip((0.3 * ppm - 0.5) / 4, 0.1, 0.7)''','''        o, t = runs_field(Hh, W, rl, rng, False)
        thr[sel] = t[sel]; coh[sel] = np.clip((0.5 * ppm - 1.5) / 3, 0, 0.8)[sel]
        a = np.clip((0.3 * ppm - 0.5) / 4, 0.1, 0.7)''')
s=s.replace('''        o = runs_field(Hh, W, rl, rng, True) * 0.4''','''        o, t = runs_field(Hh, W, rl, rng, True); o = o * 0.4''')
s=s.replace("    return off * contrast\n","    return off * contrast, thr, coh\n")
s=s.replace("    off = marks(buf, mat, v, lit, rng, contrast)","    off, thr, coh = marks(buf, mat, v, lit, rng, contrast)")
s=s.replace("    step = dither.ordered(vv, jitter=jitter, rng=rng, n=6)",
"""    # run-coherent threshold for strands (a blade is one colour top to bottom), DPaint ordered
    # dither for the mass everywhere else (the V16 islet recipe)
    tb = dither.tile(dither.B2, H, W) + jitter * (rng.random((H, W)) - 0.5)
    t = coh * thr + (1 - coh) * tb
    step = np.clip(np.floor(vv) + ((vv - np.floor(vv)) > t), 0, 5).astype(int)""")
s=s.replace("'turf': (0.20, 0.30, 0.07), 'meadow': (0.36, 0.33, 0.10)","'turf': (0.30, 0.31, 0.07), 'meadow': (0.40, 0.33, 0.10)")
open('ground.py','w').write(s)
EOF
sed -i '' "s/seed=5,water=30)/seed=5)/" s1_v16.py; OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_v16.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s1_v16pm_3x.png

elements/ground/stages/s1_v16pm_3x.png 37. elements/ground/stages/s1_v16pm_3x.png

Bash: Tune golden palette and try shade variants
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("'golden':  (20, 14, (1.00, 0.72, 0.42)","'golden':  (20, 14, (1.00, 0.58, 0.30)")
s=s.replace("'turf': (0.30, 0.31, 0.07)","'turf': (0.33, 0.31, 0.05)")
s=s.replace("""            if i == 3: c = c * 0.7 + alb * skyc * 0.6          # halftone keeps some sky
        out.append(_set_L(c, Ls[i]))""","""            if i == 3: c = c * 0.7 + alb * skyc * 0.6          # halftone keeps some sky
            g = (c * np.array([0.2126, 0.7152, 0.0722])).sum()
            c = np.clip(g + (c - g) * 1.35, 0, None)            # lit colours carry the chroma
        out.append(_set_L(c, Ls[i]))""")
s=s.replace("shade=0.0, shade_scale=12.0, vignette=0.6,","shade=0.0, shade_scale=12.0, vignette=0.35,")
open('ground.py','w').write(s)
EOF
cat > s1_grid.py <<'EOF'
import ground, lw, numpy as np, sys
d=lw.load('V16PM'); ref=lw.rgb(d)[280:430, 0:220]
cells=[ref]
for kw in [dict(shade=0.3,shade_scale=25),dict(shade=0.45,shade_scale=35,sun=(40,8)),dict(shade=0.0,sun=(-20,12))]:
    g=ground.paint_ground(220,150,landform='bank',hour='golden',ppm=30,pitch=12,cam_h=1.6,seed=5,**{**dict(sun=(15,10)),**kw})
    cells.append(g.rgb)
pad=np.zeros((150,4,3),np.uint8)
lw.save(np.concatenate(sum([[c,pad] for c in cells],[]),1),'../stages/s1_grid_3x.png',3)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_grid.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s1_grid_3x.png

elements/ground/stages/s1_grid_3x.png 38. elements/ground/stages/s1_grid_3x.png

Bash: Stats of near-field values
cd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
g=ground.paint_ground(220,150,landform='bank',hour='golden',ppm=30,pitch=12,cam_h=1.6,seed=5,sun=(-20,12))
b=slice(100,150)
print('lit share',g.lit[b].mean()); print('v mean',g.v[b].mean(), np.percentile(g.v[b],[10,50,90]))
print(np.bincount(g.step[b].ravel(),minlength=6)/g.step[b].size)
print('ppm',g.buf['ppm'][b].mean(), 'ndl',g.L['ndl'][b].mean(),'sy',g.L['s'][1])
"

# output:
lit share 0.7038181818181818
v mean 2.3411453983278236 [1.53370734 2.55180942 2.83725142]
[0.02809091 0.10581818 0.28554545 0.51281818 0.06709091 0.00063636]
ppm 47.33255738925834 ndl 0.050690239247572315 sy 0.20791169081775934
Bash: Show value_plan head
cd ~/work/pixelart-studies/elements/ground/src && grep -n "def value_plan" -A 12 ground.py | head -14

# output:
219:def value_plan(buf, L, key='golden', sep=0.9, sheen=0.5, lit_floor=0.10):
220-    """Continuous step coordinate v in [0, 5.99]: shadow family 0..2.99, light family 3..5.99."""
221-    direct = L['sunvis'] * L['ndl']
222-    # a flat plane at this sun takes N.L = s_y; normalize so flat ground sits mid-light family
223-    sy = max(L['s'][1], 0.06)
224-    rel = np.clip(direct / max(sy * 1.35, 0.12), 0, 1.25)
225-    lit = (L['sunvis'] > 0.5) & (L['ndl'] > lit_floor * sy)
226-    # light family: 3.0 .. 5.99 ; strongly turned toward the sun -> top step
227-    r = L['ndl'] * L['sunvis'] / sy                 # 1 = a flat plane in full sun
228-    v_lit = 3.0 + 3.0 * np.clip(0.42 + 0.85 * (r - 1), 0, 0.999)
229-    # shadow family: 0 .. 2.99 from sky ambient; faces turned to the sun but shadowed (cast)
230-    # keep a little reflected light (they are the 'lit' plane in shadow) -> v up to 2.7
231-    v_sh = 0.2 + 1.3 * L['sky'] + 0.7 * np.clip(L['ndl'] / sy, 0, 1.3) * (1 - L['sunvis'])
Bash: Material-dependent normal response in value plan
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("def value_plan(buf, L, key='golden', sep=0.9, sheen=0.5, lit_floor=0.10):",
"""NRESP = {'turf': 0.3, 'meadow': 0.2, 'earth': 1.0, 'gravel': 0.8, 'forest': 0.6, 'moss': 0.5, 'water': 0.0}

def value_plan(buf, L, mat=None, sep=0.9, sheen=0.5, lit_floor=0.10, lit_base=0.6):
    \"\"\"lit_base: where a flat lit plane sits inside the light family (0..1).
    Grass is a forest of vertical blades: it catches a low sun whatever the ground slope, so
    its N.L response is weak (NRESP) -- turf masses come from cast shadow, not from shading.
    Bare earth, sand and snow take the full N.L (the dunes of V25).\"\"\"""")
s=s.replace("""    r = L['ndl'] * L['sunvis'] / sy                 # 1 = a flat plane in full sun
    v_lit = 3.0 + 3.0 * np.clip(0.42 + 0.85 * (r - 1), 0, 0.999)""","""    r = L['ndl'] * L['sunvis'] / sy                 # 1 = a flat plane in full sun
    k = np.ones_like(r) if mat is None else np.vectorize(lambda m: NRESP[MATS[m]] if m >= 0 else 1.0)(mat)
    r = 1 + k * (np.minimum(r, 3) - 1)
    v_lit = 3.0 + 3.0 * np.clip(lit_base + 0.5 * (r - 1), 0, 0.999)""")
s=s.replace("""    v, lit, sepm = value_plan(buf, L, hour, sep=sep, sheen=sheen)
    mat, wet = materials(land, buf, ground, moisture, L, seed)""","""    mat, wet = materials(land, buf, ground, moisture, L, seed)
    v, lit, sepm = value_plan(buf, L, mat, sep=sep, sheen=sheen, lit_base=lit_base)""")
s=s.replace("    v = v - vignette * 1.6 * vg\n","    v = v - vignette * 1.6 * vg\n    v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9))   # families never touch\n")
s=s.replace("sheen=0.5, contrast=1.0, jitter=0.5, band_z=(12, 45), seed=0, water=None):",
            "sheen=0.5, contrast=1.0, jitter=0.5, band_z=(12, 45), seed=0, water=None, lit_base=0.6):")
# lit families: lit mask should use grass-softened ndl too: faces away but still sunlit grass
s=s.replace("    lit = (L['sunvis'] > 0.5) & (L['ndl'] > lit_floor * sy)\n","    lit = (L['sunvis'] > 0.5) & (L['ndl'] > lit_floor * sy)\n    lit_ndl_floor = lit_floor * sy\n")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_grid.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s1_grid_3x.png

elements/ground/stages/s1_grid_3x.png 39. elements/ground/stages/s1_grid_3x.png

Bash: Add tuft clustering, fewer hummocks on bank
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("# ------------------------------------------------------------------ 6 MARKS\n","""# ------------------------------------------------------------------ 6 MARKS
def smooth_noise(Hh, W, sy, sx, rng):
    \"\"\"Gaussian-filtered white noise, unit std, correlation lengths sy, sx (px).\"\"\"
    n = ndi.gaussian_filter(rng.normal(size=(Hh, W)), (sy, sx), mode='wrap')
    return (n - n.mean()) / (n.std() + 1e-9)

""")
s=s.replace("""        o, t = runs_field(Hh, W, rl, rng, True)
        thr[sel] = t[sel]; coh[sel] = np.clip((bh - 1.0) / 2.5, 0, 1)[sel]""","""        o, t = runs_field(Hh, W, rl, rng, True)
        # tufts: tall narrow noise gathers darks into pockets and lights onto tuft tops
        tuft = smooth_noise(Hh, W, 3.0, 1.2, rng)
        o = 0.5 * tuft + 0.87 * o
        from scipy.special import ndtr
        thr_t = ndtr(0.6 * tuft + 0.8 * (t - 0.5) * 3.4)
        thr[sel] = thr_t[sel]; coh[sel] = np.clip((bh - 1.0) / 2.5, 0, 1)[sel]""")
s=s.replace("""        for _ in range(60):
                self.hum.append""","""        for _ in range(60 if kind == 'hummocks' else 14):
                self.hum.append""")
open('ground.py','w').write(s)
EOF
grep -n "for _ in range(60" ground.py; cat >> s1_grid.py <<'EOF'
g=ground.paint_ground(220,150,landform='bank',hour='golden',ppm=30,pitch=12,cam_h=1.6,seed=5,sun=(-20,12))
lw.save(np.concatenate([ref[90:150,40:140],np.zeros((60,3,3),np.uint8),g.rgb[90:150,40:140]],1),'../stages/s1_near_8x.png',8)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_grid.py

# output:
50:            for _ in range(60 if kind == 'hummocks' else 14):
Read: elements/ground/stages/s1_near_8x.png

elements/ground/stages/s1_near_8x.png 40. elements/ground/stages/s1_near_8x.png

Bash: Step-skipping gap and stroke marks for grass
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
old=s[s.index("        bh = bh_m * ppm\n        rl = np.clip(bh * 0.9, 1, 14)"):s.index("    # earth: horizontal streaks")]
new='''        bh = bh_m * ppm
        rl = np.clip(bh * 0.45, 1, 6.5)               # measured: 1.6 far, 3.8 mid, 5 near (V16PM)
        o, t = runs_field(Hh, W, rl, rng, True)
        # tufts: tall narrow noise gathers darks into pockets and lights onto tuft tops
        tuft = smooth_noise(Hh, W, 3.0, 1.2, rng)
        from scipy.special import ndtr
        u = ndtr(0.55 * tuft + 0.83 * o)               # run quantile, tuft-correlated
        thr[sel] = u[sel]; coh[sel] = np.clip((bh - 1.0) / 2.5, 0, 1)[sel]
        near = np.clip((bh - 1.5) / 4.0, 0, 1)
        # MARKS SKIP STEPS: the dark gap between blades in light is a near-black green (step 1);
        # in shadow the blades are 'negative painted' olive strokes on the dark (step 2 on 0)
        gap[sel] = ((u < 0.13 * near) & lit)[sel]
        strk[sel] = ((u > 1 - 0.42 * near) & ~lit)[sel]
        # the mass's own mixture of base and one-step-down strands (density carries the turn)
        a = np.where(lit, 0.9, 0.5) * np.clip(bh / 3, 0.25, 1)
        off[sel] = (a * (u - 0.5) * 1.6)[sel]
'''
s=s.replace(old,new)
s=s.replace("    off = np.zeros((Hh, W)); thr = np.full((Hh, W), 0.5); coh = np.zeros((Hh, W))",
            "    off = np.zeros((Hh, W)); thr = np.full((Hh, W), 0.5); coh = np.zeros((Hh, W))\n    gap = np.zeros((Hh, W), bool); strk = np.zeros((Hh, W), bool)")
s=s.replace("    return off * contrast, thr, coh\n","    return off * contrast, thr, coh, gap, strk\n")
s=s.replace("    off, thr, coh = marks(buf, mat, v, lit, rng, contrast)","    off, thr, coh, gap, strk = marks(buf, mat, v, lit, rng, contrast)")
s=s.replace("    step = np.clip(np.floor(vv) + ((vv - np.floor(vv)) > t), 0, 5).astype(int)",
"""    step = np.clip(np.floor(vv) + ((vv - np.floor(vv)) > t), 0, 5).astype(int)
    step[gap] = 1
    step[strk] = np.minimum(np.maximum(step[strk] + 2, 2), 2)""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_grid.py

# output:
[This command modified 1 file you've previously read: ../pixelart-studies/elements/ground/src/ground.py. Call Read before editing.]
Read: elements/ground/stages/s1_near_8x.png

elements/ground/stages/s1_near_8x.png 41. elements/ground/stages/s1_near_8x.png

Bash: Remove offset/threshold cancellation
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("        off[sel] = (a * (u - 0.5) * 1.6)[sel]\n","        off[sel] = (a * (u - 0.5) * 1.6 * (1 - coh))[sel]   # far: texture via ordered dither\n")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_grid.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s1_near_8x.png
Read: elements/ground/stages/s1_grid_3x.png

elements/ground/stages/s1_near_8x.png 42. elements/ground/stages/s1_near_8x.png

elements/ground/stages/s1_grid_3x.png 43. elements/ground/stages/s1_grid_3x.png

Bash: Rounding strands with spread; texture in far shadow
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("        thr[sel] = u[sel]; coh[sel] = np.clip((bh - 1.0) / 2.5, 0, 1)[sel]",
            "        thr[sel] = 0.5; coh[sel] = np.clip((bh - 1.0) / 2.5, 0, 1)[sel]")
s=s.replace("""        a = np.where(lit, 0.9, 0.5) * np.clip(bh / 3, 0.25, 1)
        off[sel] = (a * (u - 0.5) * 1.6 * (1 - coh))[sel]   # far: texture via ordered dither""",
"""        # near: each blade run takes the mass value +- a spread and rounds (always two classes,
        # their shares carry the mass); far: a finer stipple through the ordered dither
        a = np.where(lit, 1.5, 1.1) * np.clip(bh / 3, 0.35, 1)
        off[sel] = (a * (u - 0.5) * 1.7)[sel]""")
s=s.replace("if kind in ('hummocks', 'bank'):\n            for _ in range(60 if kind == 'hummocks' else 14):",
            "if kind in ('hummocks',):\n            for _ in range(60):")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_grid.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s1_grid_3x.png
Read: elements/ground/stages/s1_near_8x.png

elements/ground/stages/s1_near_8x.png 44. elements/ground/stages/s1_near_8x.png

elements/ground/stages/s1_grid_3x.png 45. elements/ground/stages/s1_grid_3x.png

Bash: Materials by hour quick grid
cd ~/work/pixelart-studies/elements/ground/src && cat > quick.py <<'EOF'
import ground, lw, numpy as np, sys, json
from concurrent.futures import ProcessPoolExecutor
def cell(kw):
    kw=dict(kw); W=kw.pop('W',200); H=kw.pop('H',130)
    return ground.paint_ground(W,H,**kw).rgb
def grid(rows, out, k=3):
    with ProcessPoolExecutor(4) as ex:
        ims=[list(ex.map(cell,r)) for r in rows]
    pad=lambda a: np.pad(a,((0,4),(0,4),(0,0)))
    lw.save(np.concatenate([np.concatenate([pad(i) for i in r],1) for r in ims],0),out,k)
if __name__=='__main__':
    base=dict(landform='rolling',ppm=28,pitch=12,seed=4,shade=0.3,shade_scale=20)
    rows=[[{**base,'ground':g,'hour':h} for h in ['noon','golden','dusk']] for g in ['turf','earth','gravel','forest']]
    rows[3]=[{**r,'canopy':0.6,'shade':0} for r in rows[3]]
    grid(rows,'../stages/quick_mats_3x.png',2)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 quick.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/quick_mats_3x.png

elements/ground/stages/quick_mats_3x.png 46. elements/ground/stages/quick_mats_3x.png

Bash: Show ramp internals
cd ~/work/pixelart-studies/elements/ground/src && grep -n "out.append(_set_L" -B 16 ground.py | head -40; grep -n "f = \[0.0" -A 5 ground.py

# output:
448-    if wet > 0:
449-        Ls = Ls * (1 - 0.3 * wet)
450-        alb = alb ** (1 + 0.6 * wet)                     # darker and more saturated
451-    out = []
452-    for i in range(6):
453-        if mat == 'water':
454-            c = haze * (0.8 + 0.05 * i)
455-        elif i < 3:
456-            c = alb * skyc * (1.0 if i else 0.9)
457-            if i == 0: c = c * np.array([0.75, 0.95, 1.35])   # cool core (V16's teal darks)
458-        else:
459-            c = alb * (sunc + skyc * 0.15)
460-            if i == 5: c = c * np.array([1.12, 1.06, 0.62])    # top light goes yellow/ochre
461-            if i == 3: c = c * 0.7 + alb * skyc * 0.6          # halftone keeps some sky
462-            g = (c * np.array([0.2126, 0.7152, 0.0722])).sum()
463-            c = np.clip(g + (c - g) * 1.35, 0, None)            # lit colours carry the chroma
464:        out.append(_set_L(c, Ls[i]))
466:    f = [0.0, 0.18, 0.4][band]
467-    hz = _lin(np.array([0, 0, 0]) + 0) + haze * 0.55
468-    w = f * np.linspace(1.0, 0.35, 6)[:, None]              # far: black point lifts toward haze
469-    out = out * (1 - w) + hz[None] * w
470-    return (np.clip(srgb(out), 0, 1) * 255).astype(np.uint8)
471-
Bash: Coloured shadows, gentler depth bands
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""            c = alb * skyc * (1.0 if i else 0.9)
            if i == 0: c = c * np.array([0.75, 0.95, 1.35])   # cool core (V16's teal darks)""","""            c = alb * skyc * (1.0 if i else 0.9)
            if i == 0: c = c * np.array([0.75, 0.95, 1.35])   # cool core (V16's teal darks)
            g = (c * np.array([0.2126, 0.7152, 0.0722])).sum()
            c = np.clip(g + (c - g) * 1.5, 0, None)             # shadows stay coloured, never grey""")
s=s.replace("    f = [0.0, 0.18, 0.4][band]","    f = [0.0, 0.07, 0.2][band]")
s=s.replace("w = f * np.linspace(1.0, 0.35, 6)[:, None]","w = f * np.linspace(1.0, 0.2, 6)[:, None]")
open('ground.py','w').write(s)
EOF
echo ok

# output:
ok
Bash: Remove small shadow islands; rerender materials grid
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("    can = can * canopy_field(X, Z, shade, shade_scale, s, seed + 17)\n","""    sh = canopy_field(X, Z, shade, shade_scale, s, seed + 17)
    if shade > 0:
        sh = design_masses(sh, min_frac=0.02)
    can = can * sh
""")
s=s.replace("def light(land, buf,","""def design_masses(f, min_frac=0.02):
    \"\"\"Designed masses: no shadow island and no lit hole smaller than min_frac of the frame.
    Ferrari's shadow shapes are a few big ones; small isolated ovals read as holes in the ground.\"\"\"
    m = f < 0.5
    a = m.size * min_frac
    for val in (True, False):
        lab, n = ndi.label(m == val)
        if n == 0: continue
        sizes = ndi.sum(np.ones_like(f), lab, range(1, n + 1))
        small = np.isin(lab, np.nonzero(sizes < a)[0] + 1)
        m[small] = not val
    changed = (m != (f < 0.5))
    return np.where(changed, np.where(m, 0.0, 1.0), f)

def light(land, buf,""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 quick.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/quick_mats_3x.png

elements/ground/stages/quick_mats_3x.png 47. elements/ground/stages/quick_mats_3x.png

Bash: Apply design masses to combined terrain+shade
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""    if shade > 0:
        sh = design_masses(sh, min_frac=0.02)
    can = can * sh
    sunvis = vis * can""","""    # designed masses: terrain and big cast shadows together, small islands absorbed;
    # the dapple of a near canopy is added after (it is meant to be small)
    big = design_masses(vis * sh, min_frac=0.012)
    sunvis = big * can""")
open('ground.py','w').write(s)
EOF
grep -n "sunvis = big" ground.py; grep -n "terrain_vis=vis" ground.py

# output:
224:    sunvis = big * can
231:    return dict(ndl=ndl_macro, ndl_micro=ndl_micro, sunvis=sunvis, terrain_vis=vis, canopy=can,
Bash: Directional skylight for shadow family; rerender
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("    sky = np.clip(0.55 + 0.45 * n[..., 1] - 0.25 * hollow, 0, 1)",
"""    # skylight comes mostly from the bright side of the sky (the sun's side): surfaces facing
    # the glow are the 'light' of the shadow family -- at dusk this is what shapes the land (V13)
    hs = np.array([s[0], 0, s[2]]); hs = hs / (np.linalg.norm(hs) + 1e-6)
    skyv = np.array([0, 1, 0]) + 0.9 * hs; skyv = skyv / np.linalg.norm(skyv)
    nm2 = buf['n_macro']
    sky = np.clip(0.3 + 0.7 * (nm2 * skyv).sum(-1) - 0.3 * hollow, 0, 1)""")
s=s.replace("    v = v + sheen * np.clip(np.log(d / 3) / 4, 0, 1) * (v >= 3)",
"""    low = np.clip((12 - np.degrees(np.arcsin(sy))) / 10, 0, 1)      # 1 when the sun is near the horizon
    v = v + sheen * np.clip(np.log(d / 3) / 4, 0, 1) * np.where(v >= 3, 1.0, 0.8 * low)""")
s=s.replace("    v_sh = 0.2 + 1.3 * L['sky'] +","    v_sh = 0.1 + 1.9 * L['sky'] ** 1.3 +")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 quick.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/quick_mats_3x.png

elements/ground/stages/quick_mats_3x.png 48. elements/ground/stages/quick_mats_3x.png

Bash: Terrace-biased swells; V13 comparison study
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""        n = 7
        ang = r.uniform(0, np.pi, n)""","""        n = 7
        # swells mostly run across the view, so the land recedes in layered crests (V13 terraces)
        ang = np.pi / 2 + r.normal(0, 0.45, n) if kind in ('rolling', 'bank') else r.uniform(0, np.pi, n)""")
s=s.replace("'dusk':    (70, 5,","'dusk':    (75, 4,")
open('ground.py','w').write(s)
EOF
cat > s2_v13.py <<'EOF'
"""Stage 2: rolling terraces + path at dusk/dawn beside V13."""
import ground, lw, numpy as np
d=lw.load('V13'); ref=lw.rgb(d)[240:480, 0:320]
out=[ref]
for sd in (4,7):
    g=ground.paint_ground(320,240,landform='rolling',hour='dusk',sun=(80,3),ppm=14,pitch=6,cam_h=2.0,
                          path=True,seed=sd,relief=1.3)
    out.append(g.rgb)
pad=np.zeros((240,4,3),np.uint8)
a=np.concatenate([out[0],pad,out[1],pad,out[2]],1)
lw.save(a,'../stages/s2_v13_2x.png',2)
def st(a):
    a=a.astype(float); lo,hi=np.percentile(a,1),np.percentile(a,99.5); return np.clip((a-lo)/(hi-lo),0,1)**0.8*255
lw.save(st(a),'../stages/s2_v13_2x_stretch.png',2)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s2_v13.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s2_v13_2x_stretch.png

elements/ground/stages/s2_v13_2x_stretch.png 49. elements/ground/stages/s2_v13_2x_stretch.png

Bash: Sky-dominant shadow hue at dusk; higher camera
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""            c = alb * skyc * (1.0 if i else 0.9)
            if i == 0:""","""            # in shadow the local colour weakens and the sky's hue takes over (strongest at dusk)
            dom = {'noon': 0.25, 'morning': 0.4, 'golden': 0.5, 'dusk': 0.85, 'overcast': 0.2}[hour]
            an = alb / alb.max()
            c = (an ** (1 - dom)) * skyc * (1.0 if i else 0.9)
            if i == 0:""")
s=s.replace("""    if hour == 'overcast':""","""    if hour == 'dusk':
        Ls[:3] = Ls[:3] * 0.8
    if hour == 'overcast':""")
open('ground.py','w').write(s)
EOF
sed -i '' "s/ppm=14,pitch=6,cam_h=2.0/ppm=10,pitch=7,cam_h=6.0/" s2_v13.py
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s2_v13.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s2_v13_2x_stretch.png

elements/ground/stages/s2_v13_2x_stretch.png 50. elements/ground/stages/s2_v13_2x_stretch.png

Bash: Measure V13 ground palette
cd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw,numpy as np
from analyze import Lstar
d=lw.load('V13'); I=d['idx']; P=d['pal']
for name,(x0,y0,x1,y1) in {'near grass R':(400,380,640,480),'terrace mid':(100,280,500,340),'far land':(200,215,600,250),'path':(300,420,420,470)}.items():
    s=I[y0:y1,x0:x1]; u,c=np.unique(s,return_counts=True); o=np.argsort(-c)
    print(name, [(tuple(P[u[k]]),round(float(Lstar(P[u[k]][None])[0])),round(100*c[k]/s.size)) for k in o[:6]])
"

# output:
near grass R [((0, 0, 11), 0, 27), ((0, 11, 19), 3, 16), ((7, 7, 19), 2, 14), ((0, 0, 7), 0, 13), ((11, 7, 15), 2, 7), ((31, 19, 27), 8, 5)]
terrace mid [((0, 7, 19), 2, 19), ((11, 7, 19), 2, 19), ((11, 15, 31), 5, 16), ((0, 0, 15), 0, 15), ((27, 31, 47), 12, 6), ((7, 19, 35), 6, 6)]
far land [((19, 19, 51), 8, 32), ((15, 15, 47), 6, 16), ((27, 23, 55), 10, 11), ((31, 27, 63), 12, 10), ((59, 31, 87), 18, 4), ((39, 31, 75), 15, 3)]
path [((39, 23, 27), 10, 25), ((31, 19, 23), 7, 18), ((31, 19, 27), 8, 15), ((47, 31, 31), 14, 15), ((0, 11, 19), 3, 5), ((0, 0, 7), 0, 5)]
Bash: Darker blue dusk key
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""    if hour == 'dusk':
        Ls[:3] = Ls[:3] * 0.8""","""    if hour == 'dusk':
        Ls[:3] = Ls[:3] * 0.45          # measured V13: dusk ground lives at L* 0..12""")
s=s.replace("'dusk':    (75, 4, (0.95, 0.52, 0.40), (0.34, 0.30, 0.52), (0.70, 0.52, 0.62))",
            "'dusk':    (75, 4, (0.95, 0.52, 0.40), (0.22, 0.20, 0.55), (0.55, 0.40, 0.75))")
open('ground.py','w').write(s)
EOF
grep -n "'dusk':    (75" ground.py; OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s2_v13.py

# output:
154:    'dusk':    (75, 4, (0.95, 0.52, 0.40), (0.22, 0.20, 0.55), (0.55, 0.40, 0.75)),
Read: elements/ground/stages/s2_v13_2x.png

elements/ground/stages/s2_v13_2x.png 51. elements/ground/stages/s2_v13_2x.png

Bash: Hour-keyed far haze; sun below horizon
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""    hz = _lin(np.array([0, 0, 0]) + 0) + haze * 0.55
    w = f * np.linspace(1.0, 0.2, 6)[:, None]              # far: black point lifts toward haze""","""    # far: the black point lifts toward the haze -- by an amount keyed to the hour (at dusk the
    # far land is still dark, L* 6..18 in V13; at noon it pales)
    hz = _set_L(haze, max(Ls[2] * 1.5, 8))
    w = f * 2.0 * np.linspace(1.0, 0.1, 6)[:, None]""")
s=s.replace("    sunvis = big * can\n","    sunvis = big * can * (1.0 if el > 0 else 0.0)\n")
open('ground.py','w').write(s)
EOF
sed -i '' "s/sun=(80,3)/sun=(80,-2)/" s2_v13.py; OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s2_v13.py

# output:
[This command modified 1 file you've previously read: ../pixelart-studies/elements/ground/src/ground.py. Call Read before editing.]
Read: elements/ground/stages/s2_v13_2x.png

elements/ground/stages/s2_v13_2x.png 52. elements/ground/stages/s2_v13_2x.png

Bash: Exaggerate skylight form turning
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""    sky = np.clip(0.3 + 0.7 * (nm2 * skyv).sum(-1) - 0.3 * hollow, 0, 1)""","""    # exaggerated (Ferrari turns the land more than nature): relative to a flat plane
    ns = (nm2 * skyv).sum(-1); n0 = skyv[1]
    sky = np.clip(0.55 + 0.45 * np.tanh(6 * (ns - n0)) - 0.3 * hollow, 0, 1)""")
s=s.replace("vv = np.where(lit, np.clip(vv, 2.6, 5.99), np.clip(vv, 0, 3.1))","vv = np.where(lit, np.clip(vv, 2.6, 5.99), np.clip(vv, 0, 2.99))")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s2_v13.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s2_v13_2x_stretch.png

elements/ground/stages/s2_v13_2x_stretch.png 53. elements/ground/stages/s2_v13_2x_stretch.png

Bash: Clamp families after quantize; mid crop compare
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""    step[gap] = 1""","""    step = np.where(lit, np.maximum(step, 3), np.minimum(step, 2))   # families never touch
    step[gap] = 1""")
s=s.replace("""        a = np.where(lit, 1.5, 1.1) * np.clip(bh / 3, 0.35, 1)""","""        a = np.where(lit, 1.5, 1.1) * np.clip(bh / 3, 0.2, 1) * np.where(lit, 1, np.clip(bh / 2, 0.35, 1))""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s2_v13.py && python3 -c "
import lw,numpy as np
from PIL import Image
a=np.array(Image.open('../stages/s2_v13_2x.png'))[::2,::2]
lw.save(np.concatenate([a[130:230,0:160],a[130:230,648:808]],1),'../stages/s2_v13_mid_6x.png',6)"

# output:
(Bash completed with no output)
Read: elements/ground/stages/s2_v13_mid_6x.png

elements/ground/stages/s2_v13_mid_6x.png 54. elements/ground/stages/s2_v13_mid_6x.png

Bash: Near shadows keep local colour
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""            dom = {'noon': 0.25, 'morning': 0.4, 'golden': 0.5, 'dusk': 0.85, 'overcast': 0.2}[hour]""",
"""            dom = {'noon': 0.25, 'morning': 0.4, 'golden': 0.5, 'dusk': 0.85, 'overcast': 0.2}[hour]
            dom = dom * (0.55, 0.85, 1.0)[band]     # near shadows keep their local green (V13, V16PM)""")
open('ground.py','w').write(s)
EOF
echo

# output:
(Bash completed with no output)
Bash: Show earth mark code
cd ~/work/pixelart-studies/elements/ground/src && grep -n "    # earth: horizontal streaks" -A 12 ground.py

# output:
402:    # earth: horizontal streaks (ruts, the grain of the ground plane) + pebbles
403-    sel = mat == MI['earth']
404-    if sel.any():
405-        rl = np.clip(0.5 * ppm, 1, 18)
406-        o, t = runs_field(Hh, W, rl, rng, False)
407-        thr[sel] = t[sel]; coh[sel] = np.clip((0.5 * ppm - 1.5) / 3, 0, 0.8)[sel]
408-        a = np.clip((0.3 * ppm - 0.5) / 4, 0.1, 0.7)
409-        st = stones_field(buf, 0.035, rng, density=0.18)
410-        off[sel] = (a * o * 0.8 + 0.8 * np.clip(0.03 * ppm, 0.15, 1) * st)[sel]
411-    sel = mat == MI['gravel']
412-    if sel.any():
413-        st = stones_field(buf, 0.06, rng, density=0.9)
414-        off[sel] = (np.clip(0.05 * ppm / 2, 0.2, 1) * st)[sel]
Bash: LIC streaks for earth; path mass; render path study
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("def marks(buf, mat, v, lit, rng, contrast=1.0):","""def lic(noise, phi, length, steps=None):
    \"\"\"Line-integral convolution: smear noise along the iso-lines of phi (screen space).
    Streaks then follow the form -- a path's length, a slope's contours, a dune's crest.\"\"\"
    Hh, W = noise.shape
    gy, gx = np.gradient(phi)
    tx, ty = -gy, gx
    nrm = np.hypot(tx, ty) + 1e-9
    tx, ty = tx / nrm, ty / nrm
    tx = np.where(tx < 0, -tx, tx); ty = np.where(tx == 0, np.abs(ty), np.where(tx < 0, -ty, ty))
    L = int(np.ceil(np.max(length))) if steps is None else steps
    acc = noise.copy(); cnt = np.ones_like(noise)
    for sgn in (1, -1):
        py, px = np.mgrid[0:Hh, 0:W].astype(float)
        for k in range(1, L + 1):
            iy = np.clip(np.round(py).astype(int), 0, Hh - 1); ix = np.clip(np.round(px).astype(int), 0, W - 1)
            px = px + sgn * tx[iy, ix]; py = py + sgn * ty[iy, ix]
            iy = np.clip(np.round(py).astype(int), 0, Hh - 1); ix = np.clip(np.round(px).astype(int), 0, W - 1)
            w = (k <= length)
            acc += noise[iy, ix] * w; cnt += w
    o = acc / cnt
    return (o - o.mean()) / (o.std() + 1e-9)

def marks(buf, mat, v, lit, rng, contrast=1.0, phi=None):""")
s=s.replace("""        rl = np.clip(0.5 * ppm, 1, 18)
        o, t = runs_field(Hh, W, rl, rng, False)
        thr[sel] = t[sel]; coh[sel] = np.clip((0.5 * ppm - 1.5) / 3, 0, 0.8)[sel]
        a = np.clip((0.3 * ppm - 0.5) / 4, 0.1, 0.7)
        st = stones_field(buf, 0.035, rng, density=0.18)
        off[sel] = (a * o * 0.8 + 0.8 * np.clip(0.03 * ppm, 0.15, 1) * st)[sel]""","""        # streaks along the form (LIC along phi's iso-lines), few and quiet; a few pebbles near
        rl = np.clip(0.25 * ppm, 1, 9)
        base = rng.normal(size=(Hh, W))
        base = np.where(rng.random((Hh, W)) < 0.25, base * 2.2, base * 0.5)   # sparse strong grains
        o = lic(base, phi if phi is not None else buf['z'], rl)
        a = np.clip((0.3 * ppm - 0.3) / 3, 0.25, 0.8) * np.where(lit, 0.9, 0.6)
        st = stones_field(buf, 0.03, rng, density=0.06)
        off[sel] = (a * o + 0.8 * np.clip(0.03 * ppm, 0.1, 1) * st)[sel]""")
s=s.replace("    off, thr, coh, gap, strk = marks(buf, mat, v, lit, rng, contrast)","""    phi = buf['z'].copy()
    if path:
        pd = land.path_dist(buf['x'], buf['z'])
        phi = np.where(pd < 2.0, pd * 40, phi)
        # the path's mass: a packed, lighter crown; grass-shaded margins drop a step
        onp = (mat == MI['earth']) & (pd < 1.2)
        v = np.where(onp, v + 0.5 * np.exp(-(pd / 0.35) ** 2) - 0.9 * np.clip((pd - 0.45) / 0.3, 0, 1), v)
        v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9))
    off, thr, coh, gap, strk = marks(buf, mat, v, lit, rng, contrast, phi)""")
open('ground.py','w').write(s)
EOF
cat > s3_path.py <<'EOF'
import ground, lw, numpy as np
from quick import grid
base=dict(landform='rolling',path=True,ppm=40,pitch=18,cam_h=1.7,relief=0.6,W=220,H=150,seed=11)
grid([[{**base,'hour':'noon','shade':0.3},{**base,'hour':'golden','sun':(10,14)},{**base,'hour':'dusk','sun':(80,-2)}]],'../stages/s3_path_3x.png',3)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s3_path.py

# output:
Exit code 1
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "~/miniconda3/lib/python3.12/multiprocessing/spawn.py", line 122, in spawn_main
    exitcode = _main(fd, parent_sentinel)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "~/miniconda3/lib/python3.12/multiprocessing/spawn.py", line 131, in _main
    prepare(preparation_data)
  File "~/miniconda3/lib/python3.12/multiprocessing/spawn.py", line 246, in prepare
    _fixup_main_from_path(data['init_main_from_path'])
  File "~/miniconda3/lib/python3.12/multiprocessing/spawn.py", line 297, in _fixup_main_from_path
    main_content = runpy.run_path(main_path,
                   ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<frozen runpy>", line 286, in run_path
  File "<frozen runpy>", line 98, in _run_module_code
  File "<frozen runpy>", line 88, in _run_code
  File "~/work/pixelart-studies/elements/ground/src/s3_path.py", line 4, in <module>
    grid([[{**base,'hour':'noon','shade':0.3},{**base,'hour':'golden','sun':(10,14)},{**base,'hour':'dusk','sun':(80,-2)}]],'../stages/s3_path_3x.png',3)
  File "~/work/pixelart-studies/elements/ground/src/quick.py", line 8, in grid
    ims=[list(ex.map(cell,r)) for r in rows]
              ^^^^^^^^^^^^^^
  File "~/miniconda3/lib/python3.12/concurrent/futures/process.py", line 859, in map
    results = super().map(partial(_process_chunk, fn),
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "~/miniconda3/lib/python3.12/concurrent/futures/_base.py", line 608, in map
    fs = [self.submit(fn, *args) for args in zip(*iterables)]
          ^^^^^^^^^^^^^^^^^^^^^^
  File "~/miniconda3/lib/python3.12/concurrent/futures/process.py", line 830, in submit
    self._adjust_process_count()
  File "~/miniconda3/lib/python3.12/concurrent/futures/process.py", line 789, in _adjust_process_count
    self._spawn_process()
  File "~/miniconda3/lib/python3.12/concurrent/futures/process.py", line 807, in _spawn_process
    p.start()
  File "~/miniconda3/lib/python3.12/multiprocessing/process.py", line 121, in start
    self._popen = self._Popen(self)
                  ^^^^^^^^^^^^^^^^^
  File "~/miniconda3/lib/python3.12/multiprocessing/context.py", line 289, in _Popen
    return Popen(process_obj)
           ^^^^^^^^^^^^^^^^^^
  File "~/miniconda3/lib/python3.12/multiprocessing/popen_spawn_posix.py", line 32, in __init__
    super().__init__(process_obj)
  File "~/miniconda3/lib/python3.12/multiprocessing/popen_fork.py", line 19, in __init__
    self._launch(process_obj)
  File "~/miniconda3/lib/python3.12/multiprocessing/popen_spawn_posix.py", line 42, in _launch
    prep_data = spawn.get_preparation_data(process_obj._name)
                ^^^^^^^^^^^^^^^^^^^^
Read: elements/ground/stages/s3_path_3x.png

I need the __main__ guard in scripts that use the pool.

Bash: Rerun path study with main guard
cd ~/work/pixelart-studies/elements/ground/src && cat > s3_path.py <<'EOF'
import ground, lw, numpy as np
from quick import grid
if __name__=='__main__':
    base=dict(landform='rolling',path=True,ppm=40,pitch=18,cam_h=1.7,relief=0.6,W=220,H=150,seed=11)
    grid([[{**base,'hour':'noon','shade':0.3},{**base,'hour':'golden','sun':(10,14)},{**base,'hour':'dusk','sun':(80,-2)}]],'../stages/s3_path_3x.png',3)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s3_path.py 2>&1 | tail -3

# output:
(Bash completed with no output)
Read: elements/ground/stages/s3_path_3x.png

elements/ground/stages/s3_path_3x.png 55. elements/ground/stages/s3_path_3x.png

Bash: Locate lines to modify
cd ~/work/pixelart-studies/elements/ground/src && grep -n "gap\[sel\]" ground.py; grep -n "LSTEP = {" -A 8 ground.py | head -9; grep -n "c = alb \* (sunc + skyc \* 0.15)" ground.py

# output:
417:        gap[sel] = ((u < 0.13 * near) & lit)[sel]
458:LSTEP = {  # target CIE L* per step: few steps, far apart; darks near black (V16 / V16PM ramps)
459-    'turf':   (4, 11, 21, 34, 49, 66),
460-    'meadow': (6, 14, 25, 40, 56, 72),
461-    'earth':  (7, 15, 25, 37, 50, 63),
462-    'gravel': (8, 17, 28, 42, 57, 72),
463-    'forest': (4, 10, 19, 31, 45, 60),
464-    'moss':   (5, 12, 22, 36, 52, 70),
465-    'water':  (30, 40, 50, 60, 70, 80)}
466-
517:            c = alb * (sunc + skyc * 0.15)
Bash: Scattered gaps, greener lit steps, darker grass shadows
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("        gap[sel] = ((u < 0.13 * near) & lit)[sel]","""        ug = runs_field(Hh, W, np.clip(rl * 0.8, 1, 5), rng, True)[1]   # gaps: independent, scattered
        gap[sel] = ((ug < 0.11 * near) & lit)[sel]""")
s=s.replace("""            c = alb * (sunc + skyc * 0.15)""","""            a2 = np.array(ALBEDO_LIT.get(mat, alb)) if i < 5 else alb
            c = a2 * (sunc + skyc * 0.15)""")
s=s.replace("LSTEP = {  #","""# the light family's lower steps keep a greener local colour; only the top light goes yellow/ochre
ALBEDO_LIT = {'turf': (0.20, 0.30, 0.07), 'meadow': (0.34, 0.31, 0.10), 'moss': (0.14, 0.30, 0.05)}

LSTEP = {  #""")
s=s.replace("""    if hour == 'dusk':
        Ls[:3] = Ls[:3] * 0.45""","""    if mat in ('turf', 'meadow'):
        Ls[:3] = Ls[:3] * 0.8           # under skylight grass is darker than bare soil (self-occlusion)
    if hour == 'dusk':
        Ls[:3] = Ls[:3] * (0.35 if mat in ('turf', 'meadow') else 0.5)""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s3_path.py 2>&1 | tail -3

# output:
(Bash completed with no output)
Read: elements/ground/stages/s3_path_3x.png

elements/ground/stages/s3_path_3x.png 56. elements/ground/stages/s3_path_3x.png

Bash: Near grass 6x comparison with V16/V16PM
cd ~/work/pixelart-studies/elements/ground/src && sed -i '' 's/            a2 = np.array(ALBEDO_LIT.get(mat, alb)) if i < 5 else alb/            a2 = np.array(ALBEDO_LIT.get(mat, alb)) if i == 3 else (0.5 * (np.array(ALBEDO_LIT.get(mat, alb)) + alb) if i == 4 else alb)/' ground.py && cat > s3_near.py <<'EOF'
import ground, lw, numpy as np
d=lw.load('V16'); ref=lw.rgb(d)[240:300, 0:110]
g=ground.paint_ground(220,150,landform='rolling',path=True,ppm=40,pitch=18,cam_h=1.7,relief=0.6,seed=11,hour='noon',shade=0.3)
d2=lw.load('V16PM'); ref2=lw.rgb(d2)[380:440,0:110]
g2=ground.paint_ground(220,150,landform='rolling',path=True,ppm=40,pitch=18,cam_h=1.7,relief=0.6,seed=11,hour='golden',sun=(10,14))
p=np.zeros((60,3,3),np.uint8)
lw.save(np.concatenate([ref,p,g.rgb[20:80,100:210],p,ref2,p,g2.rgb[20:80,0:110]],1),'../stages/s3_near_8x.png',6)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s3_near.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s3_near_8x.png

elements/ground/stages/s3_near_8x.png 57. elements/ground/stages/s3_near_8x.png

Bash: Longer strands, narrower tufts
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("        rl = np.clip(bh * 0.45, 1, 6.5)","        rl = np.clip(bh * 0.6, 1, 7.5)")
s=s.replace("        tuft = smooth_noise(Hh, W, 3.0, 1.2, rng)\n        from scipy.special import ndtr\n        u = ndtr(0.55 * tuft + 0.83 * o)",
            "        tuft = smooth_noise(Hh, W, 5.0, 0.8, rng)\n        from scipy.special import ndtr\n        u = ndtr(0.45 * tuft + 0.89 * o)")
s=s.replace("ug = runs_field(Hh, W, np.clip(rl * 0.8, 1, 5), rng, True)[1]","ug = runs_field(Hh, W, np.clip(rl * 1.0, 2, 6), rng, True)[1]")
open('ground.py','w').write(s)
EOF
grep -c "smooth_noise(Hh, W, 5.0" ground.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s3_near.py

# output:
1
Read: elements/ground/stages/s3_near_8x.png

elements/ground/stages/s3_near_8x.png 58. elements/ground/stages/s3_near_8x.png

Bash: Set lit_base 0.5 and sheen 0.35 defaults
cd ~/work/pixelart-studies/elements/ground/src && sed -i '' 's/seed=0, water=None, lit_base=0.6):/seed=0, water=None, lit_base=0.5):/; s/shade_scale=12.0, vignette=0.35, path=False, relief=1.0, sep=0.9,/shade_scale=12.0, vignette=0.35, path=False, relief=1.0, sep=0.9,/' ground.py && grep -n "sheen=0.5, contrast" ground.py && sed -i '' 's/                 sheen=0.5, contrast=1.0/                 sheen=0.35, contrast=1.0/' ground.py && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s3_near.py

# output:
587:                 sheen=0.5, contrast=1.0, jitter=0.5, band_z=(12, 45), seed=0, water=None, lit_base=0.5):
Read: elements/ground/stages/s3_near_8x.png

elements/ground/stages/s3_near_8x.png 59. elements/ground/stages/s3_near_8x.png

Bash: Measure lit turf step shares
cd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
g=ground.paint_ground(220,150,landform='rolling',path=True,ppm=40,pitch=18,cam_h=1.7,relief=0.6,seed=11,hour='golden',sun=(10,14))
m=g.lit&(g.mat==0); print(np.bincount(g.step[m],minlength=6)/m.sum()); print(np.percentile(g.v[m],[10,50,90]))
print(ground.ramp('turf','golden'))
"

# output:
[0.00000000e+00 6.93281979e-02 2.96274350e-04 1.79912599e-01
 3.77823865e-01 3.72639064e-01]
[3.95274527 4.3335215  4.44838903]
[[  8  12   6]
 [ 24  26   6]
 [ 41  43  14]
 [ 78  75   0]
 [129 104   0]
 [193 135   0]]
Bash: Tame chroma and spread
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("c = np.clip(g + (c - g) * 1.35, 0, None)            # lit colours carry the chroma","c = np.clip(g + (c - g) * 1.1, 0, None)             # lit colours carry the chroma")
s=s.replace("if i == 5: c = c * np.array([1.12, 1.06, 0.62])","if i == 5: c = c * np.array([1.10, 1.04, 0.85])")
s=s.replace("a = np.where(lit, 1.5, 1.1) * np.clip(bh / 3, 0.2, 1)","a = np.where(lit, 1.0, 1.1) * np.clip(bh / 3, 0.2, 1)")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s3_near.py; python3 -c "
import ground; print(ground.ramp('turf','golden')); print(ground.ramp('turf','noon'))"

# output:
[[  8  12   6]
 [ 24  26   6]
 [ 41  43  14]
 [ 77  75  21]
 [126 105  14]
 [187 138   0]]
[[  6  13   2]
 [ 22  27   0]
 [ 37  44   0]
 [ 71  85  32]
 [113 121  40]
 [173 163  32]]
Read: elements/ground/stages/s3_near_8x.png

elements/ground/stages/s3_near_8x.png 60. elements/ground/stages/s3_near_8x.png

Bash: Adjust turf albedo and golden sun
cd ~/work/pixelart-studies/elements/ground/src && sed -i '' "s/'turf': (0.33, 0.31, 0.05)/'turf': (0.33, 0.30, 0.10)/; s/'golden':  (20, 14, (1.00, 0.58, 0.30)/'golden':  (20, 14, (1.00, 0.60, 0.38)/" ground.py && python3 -c "
import ground; print(ground.ramp('turf','golden')[3:])"

# output:
[[ 76  75  34]
 [125 105  35]
 [186 137  45]]
Bash: Material grid: forest, earth, gravel, meadow
cd ~/work/pixelart-studies/elements/ground/src && cat > s4_mats.py <<'EOF'
import ground, lw, numpy as np
from quick import grid
if __name__=='__main__':
    b=dict(landform='rolling',ppm=40,pitch=20,cam_h=1.7,relief=0.5,W=200,H=130,seed=21)
    rows=[[{**b,'ground':'forest','hour':h,'shade':0.72,'shade_scale':4,'canopy':0.25,'canopy_scale':0.7,'moisture':0.3} for h in ['noon','golden','overcast']],
          [{**b,'ground':'earth','hour':h,'shade':0.3,'shade_scale':6} for h in ['noon','golden','overcast']],
          [{**b,'ground':'gravel','hour':h,'shade':0.3,'shade_scale':6} for h in ['noon','golden','overcast']],
          [{**b,'ground':'meadow','hour':h,'shade':0.3,'shade_scale':10} for h in ['noon','golden','overcast']]]
    grid(rows,'../stages/s4_mats_3x.png',2)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s4_mats.py 2>&1|tail -3

# output:
(Bash completed with no output)
Read: elements/ground/stages/s4_mats_3x.png

elements/ground/stages/s4_mats_3x.png 61. elements/ground/stages/s4_mats_3x.png

Bash: Locate value plan lines
cd ~/work/pixelart-studies/elements/ground/src && grep -n "def value_plan" -A 30 ground.py | grep -n "lit = \|pen\|v_sh =" 

# output:
11:256-    lit = (L['sunvis'] > 0.5) & (L['ndl'] > lit_floor * sy)
17:262-    v_lit = 3.0 + 3.0 * np.clip(lit_base + 0.5 * (r - 1), 0, 0.999)
20:265-    v_sh = 0.1 + 1.9 * L['sky'] ** 1.3 + 0.7 * np.clip(L['ndl'] / sy, 0, 1.3) * (1 - L['sunvis'])
21:266-    v_sh = np.clip(v_sh, 0, 2.6)
23:268-    # the soft canopy edge: blend across the family boundary only inside the penumbra
24:269-    pen = (L['sunvis'] > 0.15) & (L['sunvis'] < 0.85) & (L['ndl'] > lit_floor * sy)
26:271-    v = np.where(pen, (1 - t) * v_sh + t * v_lit, v)
Bash: Broken terminators and overcast single family
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""    lit = (L['sunvis'] > 0.5) & (L['ndl'] > lit_floor * sy)
""","""    # the terminator of a cast shadow is broken by the material: blade tips cross it on turf,
    # grains on earth -- never a clean cut-paper curve
    Hh, W = L['sunvis'].shape
    rng = np.random.default_rng(12345)
    en = np.zeros((Hh, W))
    if mat is not None:
        nt = ndi.gaussian_filter(rng.normal(size=(Hh, W)), (4, 0.7)); nt /= nt.std() + 1e-9
        ne = ndi.gaussian_filter(rng.normal(size=(Hh, W)), (1.3, 1.3)); ne /= ne.std() + 1e-9
        grass = np.isin(mat, [MI['turf'], MI['meadow'], MI['moss']])
        en = np.where(grass, 0.22 * nt, 0.15 * ne) + 0.06 * rng.normal(size=(Hh, W))
    sv = L['sunvis'] + en * (L['sunvis'] * (1 - L['sunvis']) * 4 + 0.15)
    lit = (sv > 0.5) & (L['ndl'] > lit_floor * sy)
""")
s=s.replace("""    pen = (L['sunvis'] > 0.15) & (L['sunvis'] < 0.85) & (L['ndl'] > lit_floor * sy)""","""    pen = np.zeros_like(lit)""")
s=s.replace("def value_plan(buf, L, mat=None, sep=0.9, sheen=0.5, lit_floor=0.10, lit_base=0.6):",
            "def value_plan(buf, L, mat=None, sep=0.9, sheen=0.5, lit_floor=0.10, lit_base=0.6, overcast=False):")
s=s.replace("""    v = np.where(pen, (1 - t) * v_sh + t * v_lit, v)""","""    v = np.where(pen, (1 - t) * v_sh + t * v_lit, v)
    if overcast:
        # no cast shadows, no terminator: one soft family across the whole ramp, shaped by the sky
        v = 0.8 + 3.6 * L['sky'] ** 1.2
        lit = v >= 3.0""")
s=s.replace("v, lit, sepm = value_plan(buf, L, mat, sep=sep, sheen=sheen, lit_base=lit_base)",
            "v, lit, sepm = value_plan(buf, L, mat, sep=sep, sheen=sheen, lit_base=lit_base, overcast=(hour == 'overcast'))")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s4_mats.py 2>&1|tail -3

# output:
(Bash completed with no output)
Read: elements/ground/stages/s4_mats_3x.png

elements/ground/stages/s4_mats_3x.png 62. elements/ground/stages/s4_mats_3x.png

Bash: Sharper shade edges, subtler edge noise
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("return 1 - 1 / (1 + np.exp(-(acc - thr) / 0.08))","return 1 - 1 / (1 + np.exp(-(acc - thr) / 0.025))")
s=s.replace("en = np.where(grass, 0.22 * nt, 0.15 * ne) + 0.06 * rng.normal(size=(Hh, W))","en = np.where(grass, 0.12 * nt, 0.08 * ne) + 0.04 * rng.normal(size=(Hh, W))")
s=s.replace("sv = L['sunvis'] + en * (L['sunvis'] * (1 - L['sunvis']) * 4 + 0.15)","sv = L['sunvis'] + en * (L['sunvis'] * (1 - L['sunvis']) * 4 + 0.05)")
s=s.replace("        v = 0.8 + 3.6 * L['sky'] ** 1.2\n","        v = 0.6 + 3.6 * L['sky'] ** 1.2 + 0.9 * np.clip(np.log(buf['z'] / 4) / 4, 0, 1)\n")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s4_mats.py 2>&1|tail -3

# output:
(Bash completed with no output)
Read: elements/ground/stages/s4_mats_3x.png

elements/ground/stages/s4_mats_3x.png 63. elements/ground/stages/s4_mats_3x.png

Bash: Distance ladder sheet
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("        v = 0.6 + 3.6 * L['sky'] ** 1.2 +","        v = 1.3 + 3.2 * L['sky'] ** 1.2 +")
open('ground.py','w').write(s)
EOF
cat > s5_dist.py <<'EOF'
import ground, lw, numpy as np
from quick import grid
if __name__=='__main__':
    rows=[]
    for hr,kw in [('golden',dict(sun=(15,12))),('noon',{})]:
        rows.append([dict(landform='rolling',path=True,ground='turf',hour=hr,ppm=p,pitch=pi,cam_h=1.7*(40/p)**0.6,relief=0.8,
                          shade=0.3,shade_scale=12,W=200,H=130,seed=8,**kw) for p,pi in [(120,35),(40,20),(12,12),(4,8),(1.2,5)]])
    grid(rows,'../stages/s5_distance_2x.png',2)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s5_dist.py 2>&1|tail -3

# output:
(Bash completed with no output)
Read: elements/ground/stages/s5_distance_2x.png

elements/ground/stages/s5_distance_2x.png 64. elements/ground/stages/s5_distance_2x.png

Bash: More texture in far shadows, bigger min masses
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("big = design_masses(vis * sh, min_frac=0.012)","big = design_masses(vis * sh, min_frac=0.03)")
s=s.replace("np.where(v >= 3, 1.0, 0.8 * low)","np.where(v >= 3, 1.0, 0.5 + 0.5 * low)")
s=s.replace("        a = np.where(lit, 1.0, 1.1) * np.clip(bh / 3, 0.2, 1) * np.where(lit, 1, np.clip(bh / 2, 0.35, 1))",
            "        a = np.where(lit, 1.0, 1.1) * np.clip(bh / 3, 0.45, 1)")
open('ground.py','w').write(s)
EOF
grep -n "fade = " ground.py

# output:
633:    fade = np.clip(1.2 - np.log(buf['z'] / band_z[0] + 1e-6) / 2.2, 0.15, 1)
Bash: Keep far stipple; rerender distance
cd ~/work/pixelart-studies/elements/ground/src && sed -i '' 's|    fade = np.clip(1.2 - np.log(buf\[.z.\] / band_z\[0\] + 1e-6) / 2.2, 0.15, 1)|    fade = np.clip(1.2 - np.log(buf["z"] / band_z[0] + 1e-6) / 2.2, 0.45, 1)|' ground.py && grep -n "fade = " ground.py && OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s5_dist.py 2>&1|tail -3

# output:
633:    fade = np.clip(1.2 - np.log(buf["z"] / band_z[0] + 1e-6) / 2.2, 0.45, 1)
Read: elements/ground/stages/s5_distance_2x.png

elements/ground/stages/s5_distance_2x.png 65. elements/ground/stages/s5_distance_2x.png

Bash: Lift shadow family centre and top step
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("    v_sh = 0.1 + 1.9 * L['sky'] ** 1.3 +","    v_sh = 0.5 + 1.8 * L['sky'] ** 1.2 +")
s=s.replace("    'turf':   (4, 11, 21, 34, 49, 66),","    'turf':   (4, 12, 28, 36, 50, 66),     # V16 far hummocks: L 2 / 6-9 / 25 in shadow")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s5_dist.py 2>&1|tail -3

# output:
(Bash completed with no output)
Read: elements/ground/stages/s5_distance_2x.png

elements/ground/stages/s5_distance_2x.png 66. elements/ground/stages/s5_distance_2x.png

Bash: Read driftwood paint API
cd ~/work/pixelart-studies/elements/driftwood/src && sed -n 25,70p driftwood.py; sed -n 1,40p ../LESSONS.md | head -60

# output:
from util import board

ROOT = os.path.expanduser('~/work/pixelart-studies/elements/driftwood')
LIGHTS = {'left-front': (300, 40), 'right-front': (60, 40), 'back': (180, 22), 'high': (330, 68)}
HOUR_EL = {'golden': 10, 'morning': 22}
DIST = {'near': 40.0, 'mid': 16.0, 'far': 6.0}
PITCH = {'30': 30.0, 'low': 10.0}


def paint(length=3.0, diameter=0.35, orientation=15.0, rootwad=False, weathering=1.0, wetness=0.0,
          light='left-front', hour='noon', camera='30', distance='near', seed=0, pile=0,
          ground='gravel', W=160, H=96, grey=False):
    scale = DIST.get(distance, distance) if isinstance(distance, str) else float(distance)
    pitch = PITCH[camera]
    r = diameter / 2
    az, el = LIGHTS[light]
    if hour in HOUR_EL:
        el = min(el, HOUR_EL[hour] if light != 'high' else HOUR_EL[hour] * 2.2)
    rng = np.random.default_rng(seed)
    rw = dict(n=6, end=0) if rootwad else None
    lg = geo.make_log(L=length, r=r, yaw=orientation, seed=seed, rootwad=rw,
                      ends=('snapped' if rootwad else str(rng.choice(['sawn', 'worn', 'worn', 'snapped'])),
                            str(rng.choice(['snapped', 'worn', 'splinter']))),
                      n_stubs=int(round(3 * weathering)), wet=1.0 if wetness >= 0.5 else 0.0,
                      n_knots=int(round(3 * weathering)))
    logs = [lg]
    if pile:
        extra = pilemod.make_pile(seed=seed + 1, n=pile, extent=(length * 1.6, length * 0.8),
                                  flow_yaw=orientation, r_range=(r * 0.4, r * 0.9))
        placed = [lg]
        for q in extra:
            placed.append(pilemod.settle(q, placed))
        logs = placed
    cz = float(np.mean([q.pts[:, 2].mean() for q in logs]))
    cx = float(np.mean(lg.pts[:, 0])); cy = float(np.mean(lg.pts[:, 1]))
    cam = geo.Camera(W, H, scale, pitch=pitch, look=(cx, cy, cz))
    li = Light(cam, az=az, el=el, overcast=(hour == 'overcast'))
    cv, buf, sh = render.render(logs, cam, li, seed=seed, stone_m=max(0.06, 1.6 / scale),
                                opts=dict(gravel=(ground == 'gravel'), weather=weathering))
    if grey:
        return render.to_rgb(cv, render.grey_ramps())
    return render.to_rgb(cv, palette.ramps(hour, 1.0 if wetness >= 0.5 else 0.0))


# ------------------------------------------------------------------ sheets
def _cell(args):
# Driftwood, after Mark Ferrari

Whole trees and logs stranded on river gravel bars: silvered, barkless trunks with root wads and
snapped stubs, piled against each other. Everything here is made by the programs in `src/`; the
stage images are in `stages/`, the parameter sheets in `sheets/`. Photos are in `refs/` (sources
in `refs/sources.tsv`) plus `~/work/pixelart-studies/transfer/refs/braided_driftwood_bar.jpg`.


src/run_all.sh                                # every stage image and sheet (~9 min, 4 processes)
nice -n 10 python3 src/driftwood.py sheets    # just the sheets
The technique code: - `src/geo.py` is the world: logs as bent, t
Bash: Inspect driftwood render outputs
cd ~/work/pixelart-studies/elements/driftwood/src && grep -n "^def render" -A 30 render.py | head -60; grep -n "return cv" render.py; grep -n "class\|def to_rgb" -A6 render.py | head -40

# output:
40:def render(logs, cam, light, seed=0, stone_m=0.06, opts=None):
41-    o = dict(contact=True, bottom_row=True, gravel=True, cast=True)
42-    o.update(opts or {})
43-    H, W = cam.H, cam.W
44-    # what is below a pixel does not exist: limbs thinner than ~0.6 px and rootlets on logs under
45-    # ~3 px radius are dropped (critic r10: stubs read as antennae at 6-9 px/m)
46-    for lg in logs:
47-        lg.limbs = [l for l in lg.limbs if l[2] * cam.scale >= 0.6]
48-        if lg.rad.mean() * cam.scale < 3:
49-            lg.rootlets = []
50-    buf = geo.trace(cam, logs)
51-    sh = geo.shadow(buf, light.L, logs) if o['cast'] and not light.overcast else np.zeros((H, W), bool)
52-    cv = Canvas(H, W)
53-    V = -cam.f
54-    obj = buf['obj']
55-    wood = obj >= 0
56-    # ---------- wood form: five classes around the axis
57-    d, phL = tech.normal_plane_angles(buf['N'], V, light.L, buf['T'])
58-    v = tech.cylinder_classes(d, phL, sh=sh)
59-    if light.overcast:
60-        # overcast: the sky is the light; peak straight up
61-        d2, ph2 = tech.normal_plane_angles(buf['N'], V, np.array([0, 0, 1.0]), buf['T'])
62-        v = tech.cylinder_classes(d2, ph2)
63-    diam = 2 * buf['rad'] * cam.scale
64-    # with marks on, big logs turn their form by MARK DENSITY, not by checker: the classes are
65-    # quantized nearly pure (seams at most 1 px) and the grain supplies the gradation
66-    if o.get('marks', True):
67-        v = tech.sharpen(v, diam, max_gain=12.0)
68-        v = np.where(diam >= 7, tech.sharpen(v, np.full_like(diam, 1.0), max_gain=12.0), v)
69-    else:
70-        v = tech.sharpen(v, diam)
153:    return cv, buf, sh
4:  1 form     geo.trace + tech.cylinder_classes (wood), pebble domes (gravel)
5-  2 value    families: cast shadow forces the shadow family; contact line at 0
6-  3 marks    (stage 2+) grain, cracks, knots
7-  4 drawing  contact line, bottom row of the log, silhouette runs
8:  5 quantize class checker (wood), ordered 2x2 (gravel)
9-  6 palette  (stage 4) ramps per material
10-"""
11-import numpy as np
12-import geo, tech
13-from paint import (WOOD, GRAVEL, RAW, BARK, Light, gravel_field, hash2, BAYER2)
14-ROOTM = 5
--
34:class Canvas:
35-    def __init__(self, H, W):
36-        self.mat = np.full((H, W), GRAVEL)
37-        self.step = np.zeros((H, W), int)
38-
39-
40-def render(logs, cam, light, seed=0, stone_m=0.06, opts=None):
--
56:    # ---------- wood form: five classes around the axis
57-    d, phL = tech.normal_plane_angles(buf['N'], V, light.L, buf['T'])
58:    v = tech.cylinder_classes(d, phL, sh=sh)
59-    if light.overcast:
60-        # overcast: the sky is the light; peak straight up
61-        d2, ph2 = tech.normal_plane_angles(buf['N'], V, np.array([0, 0, 1.0]), buf['T'])
62:        v = tech.cylinder_classes(d2, ph2)
63-    diam = 2 * buf['rad'] * cam.scale
64:    # with marks on, big logs turn their form by MARK DENSITY, not by checker: the classes are
65-    # quantized nearly pure (seams at m
Bash: Driftwood-on-ground integration demo
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("sheen=0.35, contrast=1.0, jitter=0.5, band_z=(12, 45), seed=0, water=None, lit_base=0.5):",
            "sheen=0.35, contrast=1.0, jitter=0.5, band_z=(12, 45), seed=0, water=None, lit_base=0.5,\n                 shadow_mask=None):")
s=s.replace("    L = light(land, buf, az, el, canopy, canopy_scale, seed, shade, shade_scale)\n",
"""    L = light(land, buf, az, el, canopy, canopy_scale, seed, shade, shade_scale)
    if shadow_mask is not None:
        # cast shadows of objects standing on the ground (a log, a bush, a stone): the ground
        # itself in its shadow family, texture kept inside (READY.md: cast shadows)
        L['sunvis'] = L['sunvis'] * (1 - shadow_mask.astype(float))
""")
open('ground.py','w').write(s)
EOF
cat > s6_driftwood.py <<'EOF'
"""Integration: the driftwood student's logs standing on this ground."""
import sys, os, numpy as np
sys.path.insert(0, os.path.expanduser('~/work/pixelart-studies/elements/driftwood/src'))
here = os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0, here)
import ground, lw
import driftwood as dw, geo, render as dwr, palette as dwp, pile as pilemod
from paint import Light, GRAVEL

def logs_on_ground(W=240, H=150, hour='golden', ground_kw=None, seed=24, scale=40.0, pitch=30.0, pile=4):
    rng = np.random.default_rng(seed)
    lg = geo.make_log(L=3.0, r=0.17, yaw=15.0, seed=seed, rootwad=dict(n=6, end=0), ends=('snapped', 'worn'),
                      n_stubs=3, wet=0.0, n_knots=3)
    logs = [lg]; placed = [lg]
    for q in pilemod.make_pile(seed=seed + 1, n=pile, extent=(4.8, 2.4), flow_yaw=15.0, r_range=(0.07, 0.15)):
        placed.append(pilemod.settle(q, placed))
    logs = placed
    cz = float(np.mean([q.pts[:, 2].mean() for q in logs])); cx = float(np.mean(lg.pts[:, 0])); cy = float(np.mean(lg.pts[:, 1]))
    cam = geo.Camera(W, H, scale, pitch=pitch, look=(cx, cy, cz))
    az, el = {'golden': (300, 10), 'noon': (300, 40), 'overcast': (300, 40)}[hour]
    li = Light(cam, az=az, el=el, overcast=(hour == 'overcast'))
    cv, buf, sh = dwr.render(logs, cam, li, seed=seed, stone_m=0.06, opts=dict(gravel=True, weather=1.0))
    wood_rgb = dwr.to_rgb(cv, dwp.ramps(hour, 0.0))
    wood = cv.mat != GRAVEL
    # contact: darkest ground pixels the log student drew right under the wood
    near = np.zeros_like(wood)
    near[:-1] |= wood[1:]; near[1:] |= wood[:-1]
    contact = (~wood) & (cv.step == 0) & near
    kw = dict(ground='gravel', landform='flat', ppm=scale, pitch=pitch, cam_h=4.0, hour=hour, seed=seed,
              sun=(20 if az > 180 else 160, el))
    kw.update(ground_kw or {})
    g = ground.paint_ground(W, H, shadow_mask=sh & ~wood, **kw)
    out = g.rgb.copy()
    out[wood | contact] = wood_rgb[wood | contact]
    return out, g.rgb, wood_rgb

if __name__ == '__main__':
    rows = []
    for hr in ('golden', 'noon'):
        a, gr, old = logs_on_ground(hour=hr, ground_kw=dict(shade=0.35, shade_scale=3, moisture=0.3))
        b, _, _ = logs_on_ground(hour=hr, ground_kw=dict(ground='turf', shade=0.3, shade_scale=3))
        p = np.zeros((a.shape[0], 4, 3), np.uint8)
        rows.append(np.concatenate([old, p, a, p, b], 1))
    q = np.zeros((4, rows[0].shape[1], 3), np.uint8)
    lw.save(np.concatenate([rows[0], q, rows[1]], 0), os.path.join(here, '../sheets/integration_driftwood_3x.png'), 3)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s6_driftwood.py 2>&1 | tail -5

# output:
(Bash completed with no output)
Read: elements/ground/sheets/integration_driftwood_3x.png

elements/ground/sheets/integration_driftwood_3x.png 67. elements/ground/sheets/integration_driftwood_3x.png

Bash: Ragged shadow edges; quieter gravel
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""    # threshold so that 'cover' of the ground is in shade; soft band gives dither in the edge""","""    # world-anchored ragged edge: a few octaves of sinusoids at crown-to-leaf scale, so shadow
    # shapes are lobed and notched (foliage), never a clean ellipse
    rr_ = np.random.default_rng(seed + 5)
    amp = acc.std() + 1e-6
    for k, wl in enumerate([scale * 1.2, scale * 0.55, scale * 0.25, scale * 0.12]):
        for _ in range(3):
            a0 = rr_.uniform(0, np.pi); ph = rr_.uniform(0, 6.3)
            acc = acc + amp * 0.35 * 0.7 ** k * np.sin((np.cos(a0) * x + np.sin(a0) * z) * 2 * np.pi / wl + ph)
    # threshold so that 'cover' of the ground is in shade; soft band gives dither in the edge""")
s=s.replace("return 1 - 1 / (1 + np.exp(-(acc - thr) / 0.025))","return 1 - 1 / (1 + np.exp(-(acc - thr) / (0.03 * amp)))")
# gravel: quieter
s=s.replace("""        off[sel] = (np.clip(0.05 * ppm / 2, 0.2, 1) * st)[sel]""","""        off[sel] = (np.clip(0.05 * ppm / 2, 0.2, 1) * st * np.where(lit, 0.6, 0.45))[sel]""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s6_driftwood.py 2>&1 | tail -5

# output:
(Bash completed with no output)
Read: elements/ground/sheets/integration_driftwood_3x.png

elements/ground/sheets/integration_driftwood_3x.png 68. elements/ground/sheets/integration_driftwood_3x.png

Bash: Labelled sheets script; render landform sheet
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("acc = acc + amp * 0.35 * 0.7 ** k *","acc = acc + amp * 0.25 * 0.7 ** k *")
s=s.replace("c = np.clip(g + (c - g) * 1.5, 0, None)             # shadows stay coloured, never grey",
            "c = np.clip(g + (c - g) * 1.25, 0, None)            # shadows stay coloured, never grey")
open('ground.py','w').write(s)
EOF
cat > sheets.py <<'EOF'
"""Labelled parameter sheets.  nice -n 10 python3 sheets.py [name ...]"""
import sys, os, numpy as np
from PIL import Image, ImageDraw
from concurrent.futures import ProcessPoolExecutor
import ground
HERE = os.path.dirname(os.path.abspath(__file__)); OUT = os.path.join(HERE, '../sheets')

def cell(kw):
    kw = dict(kw); W = kw.pop('W', 200); H = kw.pop('H', 130); kw.pop('label', None)
    return ground.paint_ground(W, H, **kw).rgb

def sheet(name, rows, rlabels, clabels, title, k=3):
    flat = [c for r in rows for c in r]
    with ProcessPoolExecutor(4) as ex:
        ims = list(ex.map(cell, flat))
    H, W = ims[0].shape[:2]
    nr, nc = len(rows), len(rows[0])
    top, left, gap = 40, 110, 8
    S = Image.new('RGB', (left + nc * (W * k + gap), top + nr * (H * k + gap) + 20), (22, 22, 26))
    d = ImageDraw.Draw(S)
    d.text((8, 8), title, fill=(230, 230, 230))
    for j, cl in enumerate(clabels):
        d.text((left + j * (W * k + gap) + 4, top - 14), cl, fill=(200, 200, 200))
    for i, rl in enumerate(rlabels):
        d.text((6, top + i * (H * k + gap) + 6), rl, fill=(200, 200, 200))
        for j in range(nc):
            im = Image.fromarray(ims[i * nc + j]).resize((W * k, H * k), Image.NEAREST)
            S.paste(im, (left + j * (W * k + gap), top + i * (H * k + gap)))
    S.save(os.path.join(OUT, f'{name}.png'))
    print('wrote', name)

BASE = dict(landform='rolling', ppm=24, pitch=12, cam_h=1.7, seed=8, W=200, H=130)

def s_ground_hour():
    grounds = ['turf', 'meadow', 'earth', 'gravel', 'forest']
    hours = ['morning', 'noon', 'golden', 'dusk', 'overcast']
    extra = {'forest': dict(shade=0.7, shade_scale=4, canopy=0.2, canopy_scale=0.7, moisture=0.3)}
    rows = [[{**BASE, 'ground': g, 'hour': h, 'shade': 0.3, 'shade_scale': 10, **extra.get(g, {}),
              **({'sun': (80, 2)} if h == 'dusk' else {})} for h in hours] for g in grounds]
    sheet('A_ground_x_hour', rows, grounds, hours, 'Sheet A: ground type (rows) x hour (cols); rolling land, seed 8, ppm 24, off-frame shade 0.3', 2)

def s_landform_light():
    lfs = ['flat', 'rolling', 'hummocks', 'bank', 'ledges']
    suns = [(-60, 35), (0, 20), (60, 12), (85, 5)]
    rows = [[{**BASE, 'ground': 'turf', 'hour': 'golden', 'landform': lf, 'sun': s, 'relief': 1.2} for s in suns] for lf in lfs]
    sheet('B_landform_x_sun', rows, lfs, ['sun behind camera 35', 'sun left 20', 'sun left-behind 12', 'contre-jour 5'],
          'Sheet B: landform (rows) x sun (cols); turf, golden palette, no off-frame shade -- masses from land and light alone', 2)

def s_distance():
    ps = [(120, 35), (40, 20), (12, 12), (4, 8), (1.2, 5)]
    gs = ['turf', 'earth', 'gravel', 'forest']
    extra = {'forest': dict(shade=0.7, shade_scale=4, canopy=0.2, canopy_scale=0.7)}
    rows = [[dict(landform='rolling', path=(g == 'turf'), ground=g, hour='golden', sun=(15, 14), ppm=p, pitch=pi,
                  cam_h=1.7 * (40 / p) ** 0.6, relief=0.8, shade=0.3, shade_scale=12, W=200, H=130, seed=8,
                  **extra.get(g, {})) for p, pi in ps] for g in gs]
    sheet('C_distance', rows, gs, [f'{p} px/m, pitch {pi}' for p, pi in ps],
          'Sheet C: distance ladder (px per metre at screen centre); golden, seed 8', 2)

def s_moisture():
    ms = [0.0, 0.35, 0.7, 1.0]
    gs = ['earth', 'turf', 'forest']
    rows = [[dict(landform='rolling', ground=g, hour='noon', ppm=30, pitch=16, cam_h=1.7, relief=0.8, moisture=m,
                  path=(g == 'turf'), shade=0.25, shade_scale=8, W=200, H=130, seed=5) for m in ms] for g in gs]
    sheet('D_moisture', rows, gs, [f'moisture {m}' for m in ms], 'Sheet D: moisture (cols); wet ground drops a step, puddles in hollows take the sky', 2)

def s_seeds():
    rows = [[{**BASE, 'ground': 'turf', 'hour': 'golden', 'seed': sd, 'shade': 0.35, 'shade_scale': 12, 'path': True, 'sun': (15, 12)}
             for sd in range(1, 6)]]
    rows.append([{**r, 'hour': 'noon', 'sun': None} for r in rows[0]])
    sheet('E_seeds', rows, ['golden', 'noon'], [f'seed {i}' for i in range(1, 6)], 'Sheet E: seeds; rolling turf with a path and off-frame shade', 2)

SHEETS = dict(A=s_ground_hour, B=s_landform_light, C=s_distance, D=s_moisture, E=s_seeds)
if __name__ == '__main__':
    for n in (sys.argv[1:] or SHEETS):
        SHEETS[n]()
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py B 2>&1 | tail -3

# output:
wrote B_landform_x_sun
Read: elements/ground/sheets/B_landform_x_sun.png

elements/ground/sheets/B_landform_x_sun.png 69. elements/ground/sheets/B_landform_x_sun.png

Bash: Stronger in-family form turning
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("NRESP = {'turf': 0.3, 'meadow': 0.2,","NRESP = {'turf': 0.6, 'meadow': 0.5,")
s=s.replace("v_lit = 3.0 + 3.0 * np.clip(lit_base + 0.5 * (r - 1), 0, 0.999)","v_lit = 3.0 + 3.0 * np.clip(lit_base + 1.0 * (r - 1), 0, 0.999)")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py B 2>&1 | tail -1

# output:
wrote B_landform_x_sun
Read: elements/ground/sheets/B_landform_x_sun.png

elements/ground/sheets/B_landform_x_sun.png 70. elements/ground/sheets/B_landform_x_sun.png

Bash: Cluster gaps; render sheets A and C
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("gap[sel] = ((ug < 0.11 * near) & lit)[sel]","gap[sel] = ((ug < 0.22 * near) & (tuft < -0.35) & lit)[sel]   # gaps sit in the tufts' dark pockets")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py A C 2>&1 | tail -2

# output:
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: dict() got multiple values for keyword argument 'shade'
Read: elements/ground/sheets/A_ground_x_hour.png

elements/ground/sheets/A_ground_x_hour.png 71. elements/ground/sheets/A_ground_x_hour.png

Bash: Dusk range, crest tips, overcast gradient
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("Ls[:3] = Ls[:3] * (0.35 if mat in ('turf', 'meadow') else 0.5)","Ls[:3] = Ls[:3] * (0.5 if mat in ('turf', 'meadow') else 0.6)")
s=s.replace("            L = int(np.clip(round(bh * rng.uniform(0.3, 1.0)), 1, 12))","            L = int(np.clip(round(bh * rng.uniform(0.15, 0.6) ** 1.5 * 1.6), 1, 8))")
s=s.replace("        v = 1.3 + 3.2 * L['sky'] ** 1.2 + 0.9 * np.clip(np.log(buf['z'] / 4) / 4, 0, 1)\n",
            "        v = 0.9 + 3.0 * L['sky'] ** 1.2 + 1.6 * np.clip(np.log(buf['z'] / 4) / 4, 0, 1)\n")
open('ground.py','w').write(s)
EOF
grep -n "def s_distance" -A 6 sheets.py | head -8

# output:
49:def s_distance():
50-    ps = [(120, 35), (40, 20), (12, 12), (4, 8), (1.2, 5)]
51-    gs = ['turf', 'earth', 'gravel', 'forest']
52-    extra = {'forest': dict(shade=0.7, shade_scale=4, canopy=0.2, canopy_scale=0.7)}
53-    rows = [[dict(landform='rolling', path=(g == 'turf'), ground=g, hour='golden', sun=(15, 14), ppm=p, pitch=pi,
54-                  cam_h=1.7 * (40 / p) ** 0.6, relief=0.8, shade=0.3, shade_scale=12, W=200, H=130, seed=8,
55-                  **extra.get(g, {})) for p, pi in ps] for g in gs]
Bash: Render distance sheet and debug earth rings
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('sheets.py').read()
s=s.replace("""                  cam_h=1.7 * (40 / p) ** 0.6, relief=0.8, shade=0.3, shade_scale=12, W=200, H=130, seed=8,
                  **extra.get(g, {})) for p, pi in ps] for g in gs]""","""                  cam_h=1.7 * (40 / p) ** 0.6, relief=0.8, W=200, H=130, seed=8,
                  **{'shade': 0.3, 'shade_scale': 12, **extra.get(g, {})}) for p, pi in ps] for g in gs]""")
open('sheets.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py C 2>&1 | tail -1; python3 -c "
import ground,lw
g=ground.paint_ground(200,130,landform='rolling',ground='earth',hour='morning',ppm=24,pitch=12,seed=8,shade=0.3,shade_scale=10)
lw.save(g.rgb[70:130,0:100],'../stages/dbg_earth_8x.png',8)"

# output:
wrote C_distance
Read: elements/ground/stages/dbg_earth_8x.png

elements/ground/stages/dbg_earth_8x.png 72. elements/ground/stages/dbg_earth_8x.png

Bash: Headroom for marks; rerender sheets
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""    off, thr, coh, gap, strk = marks(buf, mat, v, lit, rng, contrast, phi)""","""    # headroom: keep the mass off the ends of its family so marks can go both ways (a mass
    # pinned at the top step goes flat -- the flat-tone failure)
    v = np.where(lit, 3.15 + (v - 3.0) * 0.78, 0.25 + v * 0.8)
    off, thr, coh, gap, strk = marks(buf, mat, v, lit, rng, contrast, phi)""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py C A 2>&1 | tail -1

# output:
wrote A_ground_x_hour
Read: elements/ground/sheets/C_distance.png

elements/ground/sheets/C_distance.png 73. elements/ground/sheets/C_distance.png

Bash: Show stones_field
cd ~/work/pixelart-studies/elements/ground/src && grep -n "def stones_field" -A 32 ground.py | head -36

# output:
365:def stones_field(buf, size_m, rng, density=0.7):
366-    """Gravel / litter: small blobs with a light top row and a dark lower rim (offsets)."""
367-    Hh, W = buf['z'].shape
368-    out = np.zeros((Hh, W))
369-    ppm = buf['ppm']
370-    n = int(Hh * W * density / 6)
371-    ys = rng.integers(0, Hh, n); xs = rng.integers(0, W, n)
372-    order = np.argsort(ys)
373-    for i in order:
374-        y, x = ys[i], xs[i]
375-        s = size_m * ppm[y, x] * rng.uniform(0.6, 1.5)
376-        if s < 1.6:
377-            out[y, x] += rng.choice([-1, 1]) * 0.8
378-            continue
379-        a = max(1, int(round(s / 2))); b = max(1, int(round(s / 3.2)))
380-        y0, y1 = max(0, y - b), min(Hh, y + b + 1)
381-        x0, x1 = max(0, x - a), min(W, x + a + 1)
382-        yy, xx = np.mgrid[y0:y1, x0:x1]
383-        q = ((xx - x) / (a + 0.5)) ** 2 + ((yy - y) / (b + 0.5)) ** 2
384-        m = q < 1
385-        tone = rng.normal(0.3, 0.5)
386-        top = (yy - y) < -b * 0.2
387-        bot = (yy - y) > b * 0.4
388-        blk = np.where(top, tone + 0.8, np.where(bot, tone - 0.5, tone))
389-        sub = out[y0:y1, x0:x1]
390-        sub[m] = blk[m]
391-        # pocket under the stone
392-        yb = min(Hh - 1, y + b + 1)
393-        out[yb, x0:x1][np.abs(np.arange(x0, x1) - x) < a * 0.8] = -1.3
394-    return out
395-
396-def lic(noise, phi, length, steps=None):
397-    """Line-integral convolution: smear noise along the iso-lines of phi (screen space).
Bash: Only significant crests get separation/fringe
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a="jump[:-1] = (z[:-1] > z[1:] * 1.25) & ~buf['sky'][:-1]"
assert a in s
s=s.replace(a,"jump[:-1] = (z[:-1] > z[1:] * 1.25) & (z[:-1] - z[1:] > 2.0) & ~buf['sky'][:-1]")
b="crest[1:] = (z[:-1] > z[1:] * 1.25) | buf['sky'][:-1]"
assert b in s
s=s.replace(b,"crest[1:] = ((z[:-1] > z[1:] * 1.25) & (z[:-1] - z[1:] > 2.0)) | buf['sky'][:-1]")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py C A D 2>&1 | tail -1

# output:
wrote D_moisture
Read: elements/ground/sheets/D_moisture.png

elements/ground/sheets/D_moisture.png 74. elements/ground/sheets/D_moisture.png

Bash: Locate puddle code
cd ~/work/pixelart-studies/elements/ground/src && grep -n "puddle" ground.py; grep -n "if mat == 'water':" -A 2 ground.py

# output:
334:    # puddles: earth in hollows when wet
335:    puddle = (mat == MI['earth']) & (wet > 0.72) & (L['hollow'] > 0.25)
336:    mat[puddle] = MI['water']
534:        if mat == 'water':
535-            c = haze * (0.8 + 0.05 * i)
536-        elif i < 3:
Bash: Lobed puddles with wet rims; sky ramp water
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""    puddle = (mat == MI['earth']) & (wet > 0.72) & (L['hollow'] > 0.25)
    mat[puddle] = MI['water']""","""    # puddles: small, lobed pools where bare earth dips (fine-scale hollows + world noise),
    # growing with moisture; never a band across the valley
    X, Z = buf['x'], buf['z']
    hf = land.h(X, Z)
    ringf = np.mean([land.h(X + dx, Z + dz) for dx, dz in [(0.7, 0), (-0.7, 0), (0, 0.7), (0, -0.7)]], 0)
    rr = np.random.default_rng(seed + 77)
    wn = sum(np.sin((np.cos(a) * X + np.sin(a) * Z) * 2 * np.pi / wl + p)
             for a, wl, p in zip(rr.uniform(0, np.pi, 6), rr.uniform(0.6, 2.5, 6), rr.uniform(0, 6.3, 6))) / 2.5
    pscore = 30 * (ringf - hf) + 0.5 * wn + 1.2 * np.clip(L['hollow'], 0, 1) + 1.6 * moisture
    puddle = (mat == MI['earth']) & (pscore > 2.4)
    mat[puddle] = MI['water']""")
s=s.replace("""    if land.water is not None:""","""    # the wet rim: mud darkens around every pool
    rim = ndi.binary_dilation(puddle, iterations=2) & ~puddle & (mat == MI['earth'])
    wet = np.where(rim, 1.0, wet)
    if land.water is not None:""")
s=s.replace("""        if mat == 'water':
            c = haze * (0.8 + 0.05 * i)""","""        if mat == 'water':
            # a puddle is the sky, lighter than the ground around it, cooled; steps are the
            # sky's own ramp from zenith (low) to horizon (high)
            c = (skyc * (1 - i / 6) + haze * (i / 6)) * 1.0""")
s=s.replace("    'water':  (30, 40, 50, 60, 70, 80)}","    'water':  (38, 46, 54, 62, 70, 78)}")
s=s.replace("'moss': (0.18, 0.30, 0.05)","'moss': (0.20, 0.26, 0.07)")
open('ground.py','w').write(s)
EOF
grep -n "def materials" ground.py; grep -n "mat, wet = materials" ground.py

# output:
318:def materials(land, buf, ground, moisture, L, seed=0):
641:    mat, wet = materials(land, buf, ground, moisture, L, seed)
Bash: Puddle sky-reflection values; rerender moisture sheet
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a="""    step = np.where(lit, np.maximum(step, 3), np.minimum(step, 2))   # families never touch"""
assert a in s
s=s.replace(a,a+"""
    wat = mat == MI['water']
    if wat.any():
        # puddles reflect the sky: nearer water sees higher (darker) sky; 1-px ripple rows
        vw = 1.8 + 3.0 * np.clip(np.log(buf['z'] / 2) / 3.5, 0, 1)
        rows = (np.arange(H)[:, None] % 3 == 0) & (rng.random((H, W)) < 0.35)
        vw = vw - 0.6 * rows
        tw = dither.tile(dither.B2, H, W)
        sw = np.clip(np.floor(vw) + ((vw - np.floor(vw)) > tw), 0, 5).astype(int)
        step[wat] = sw[wat]""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py D 2>&1 | tail -1

# output:
wrote D_moisture
Read: elements/ground/sheets/D_moisture.png

elements/ground/sheets/D_moisture.png 75. elements/ground/sheets/D_moisture.png

Bash: Far-bank dark band in puddles; zoom check
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("puddle = (mat == MI['earth']) & (pscore > 2.4)","puddle = (mat == MI['earth']) & (pscore > 2.4) & (moisture > 0.25)")
s=s.replace("""        step[wat] = sw[wat]""","""        # the far bank reflects as a dark band under the pool's top edge (the channel lesson:
        # far edge dark, near edge light), with stepped bites
        k = np.clip(np.round(0.06 * buf['ppm']), 1, 4).astype(int)
        band_ = np.zeros((H, W), bool)
        for d in range(1, 5):
            above = np.zeros((H, W), bool); above[d:] = ~wat[:-d]
            band_ |= above & (d <= k + (rng.random((H, W)) < 0.3))
        sw = np.where(band_, np.maximum(sw - 3, 0), sw)
        step[wat] = sw[wat]""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,lw
g=ground.paint_ground(200,130,landform='rolling',ground='earth',hour='noon',ppm=30,pitch=16,seed=5,moisture=0.7,shade=0.25,shade_scale=8,relief=0.8)
lw.save(g.rgb[40:130,0:140],'../stages/dbg_puddle_6x.png',6)"

# output:
(Bash completed with no output)
Read: elements/ground/stages/dbg_puddle_6x.png

elements/ground/stages/dbg_puddle_6x.png 76. elements/ground/stages/dbg_puddle_6x.png

Bash: Re-render V16PM and V13 comparisons
cd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 s1_grid.py && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s2_v13.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s1_grid_3x.png
Read: elements/ground/stages/s2_v13_2x.png

elements/ground/stages/s2_v13_2x.png 77. elements/ground/stages/s2_v13_2x.png

elements/ground/stages/s1_grid_3x.png 78. elements/ground/stages/s1_grid_3x.png

Bash: Darker far haze at dusk
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("    hz = _set_L(haze, max(Ls[2] * 1.5, 8))","    hz = _set_L(haze, max(Ls[2] * 1.5, 8) if hour != 'dusk' else Ls[2] * 1.3)")
open('ground.py','w').write(s)
EOF
echo

# output:
(Bash completed with no output)
Bash: Hero blades for near grass
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("def paint_ground(","""def hero_blades(step, mat, band, buf, lit, rng, density=0.05, min_px=7):
    \"\"\"The nearest grass carries a few long single blades drawn as strokes (V13's foreground,
    V16's right bank): in light a dark stem with a lit pixel beside it (the paired stroke); in
    shadow an olive stroke on the dark (negative painting). Rasterized one row at a time so the
    lean accumulates into clean 1:1 / 2:1 runs.\"\"\"
    H, W = step.shape
    ppm = buf['ppm']
    ys, xs = np.nonzero(np.isin(mat, [MI['turf'], MI['meadow']]) & (0.2 * ppm >= min_px))
    if len(ys) == 0:
        return step, mat, band
    n = int(len(ys) * density / max(1.0, 0.2 * ppm[ys].mean()))
    pick = rng.choice(len(ys), size=min(n, len(ys)), replace=False)
    for i in pick:
        y, x = ys[i], xs[i]
        bh = 0.2 * ppm[y, x] * (2.2 if mat[y, x] == MI['meadow'] else 1.0)
        L = int(bh * rng.uniform(1.0, 2.3))
        lean = rng.normal(0, 0.35); bend = rng.choice([-1, 1]) * rng.uniform(0.2, 1.2)
        m0, b0, lt = mat[y, x], band[y, x], lit[y, x]
        fx = float(x)
        for k in range(L):
            t = k / max(L, 1)
            fx += lean + bend * t * t * 1.5
            yy, xx = y - k, int(round(fx))
            if yy < 0 or xx < 0 or xx >= W: break
            if lt:
                step[yy, xx] = 1 if t < 0.85 else 2
                if xx + 1 < W and rng.random() < 0.7: step[yy, xx + 1] = 5
            else:
                step[yy, xx] = 2 if t < 0.9 else 1
            mat[yy, xx] = m0; band[yy, xx] = b0
    return step, mat, band

def paint_ground(""")
s=s.replace("    step, mat, band = crest_fringe(step, mat, band, buf, lit, rng)\n",
            "    step, mat, band = crest_fringe(step, mat, band, buf, lit, rng)\n    if blades > 0:\n        step, mat, band = hero_blades(step, mat, band, buf, lit, rng, density=blades)\n")
s=s.replace("                 shadow_mask=None):","                 shadow_mask=None, blades=0.05):")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,lw,numpy as np
a=ground.paint_ground(200,130,landform='bank',hour='golden',ppm=60,pitch=14,seed=5,sun=(-20,12),shade=0.3,shade_scale=6).rgb
b=ground.paint_ground(200,130,landform='bank',hour='noon',ppm=60,pitch=14,seed=5,shade=0.4,shade_scale=6).rgb
lw.save(np.concatenate([a,b],1),'../stages/s7_blades_3x.png',3)"

# output:
(Bash completed with no output)
Read: elements/ground/stages/s7_blades_3x.png

elements/ground/stages/s7_blades_3x.png 79. elements/ground/stages/s7_blades_3x.png

Bash: Scenes sheet
cd ~/work/pixelart-studies/elements/ground/src && sed -i '' 's/shadow_mask=None, blades=0.05):/shadow_mask=None, blades=0.035):/' ground.py && cat >> sheets.py <<'EOF'

def s_scenes():
    sc = [
        ('meadow bank, golden, sun left-behind', dict(ground='turf', landform='bank', hour='golden', sun=(25, 10), ppm=30, pitch=10, cam_h=1.7, shade=0.35, shade_scale=14, seed=5)),
        ('rolling turf + path, noon, cloud/forest shade', dict(ground='turf', landform='rolling', path=True, hour='noon', ppm=22, pitch=11, cam_h=2.0, shade=0.4, shade_scale=16, seed=3)),
        ('forest floor, morning pools of light', dict(ground='forest', landform='rolling', hour='morning', ppm=40, pitch=18, cam_h=1.7, shade=0.72, shade_scale=4, canopy=0.2, canopy_scale=0.7, moisture=0.35, relief=0.5, seed=21)),
        ('wet earth track, overcast', dict(ground='turf', landform='rolling', path=True, hour='overcast', ppm=36, pitch=16, cam_h=1.7, moisture=0.8, relief=0.7, seed=5)),
        ('gravel bar, golden, contre-jour', dict(ground='gravel', landform='flat', hour='golden', sun=(80, 8), ppm=30, pitch=14, cam_h=2.0, shade=0.25, shade_scale=10, seed=9)),
        ('terraces at dusk (after V13)', dict(ground='turf', landform='rolling', path=True, hour='dusk', sun=(80, -2), ppm=10, pitch=7, cam_h=6.0, relief=1.3, seed=7)),
    ]
    rows = [[{**kw, 'W': 320, 'H': 200} for _, kw in sc[i:i + 3]] for i in (0, 3)]
    sheet('F_scenes', rows, ['', ''], [s[0] for s in sc[:3]], 'Sheet F: designed scenes (320x200 native, 2x). Row 2: ' + ' | '.join(s[0] for s in sc[3:]), 2)

SHEETS['F'] = s_scenes
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py F 2>&1 | tail -1

# output:
KeyError: 'F'
Read: elements/ground/sheets/F_scenes.png
Bash: Fix sheets main ordering; render scenes
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('sheets.py').read()
main="""if __name__ == '__main__':
    for n in (sys.argv[1:] or SHEETS):
        SHEETS[n]()
"""
s=s.replace(main,"")+"\n"+main
open('sheets.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py F 2>&1 | tail -1

# output:
wrote F_scenes
Read: elements/ground/sheets/F_scenes.png

elements/ground/sheets/F_scenes.png 80. elements/ground/sheets/F_scenes.png

Bash: Debug ring artefacts
cd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,lw,numpy as np
g=ground.paint_ground(320,200,ground='gravel', landform='flat', hour='golden', sun=(80, 8), ppm=30, pitch=14, cam_h=2.0, shade=0.25, shade_scale=10, seed=9)
f=lambda a:(np.stack([np.clip(a,0,1)*255]*3,-1)).astype(np.uint8)
lw.save(np.concatenate([f(g.lit*1.0),f(g.v/6),f(g.L['terrain_vis']),f(g.sepm)],1),'../stages/dbg_rings.png',1)
"

# output:
(Bash completed with no output)
Read: elements/ground/stages/dbg_rings.png

elements/ground/stages/dbg_rings.png 81. elements/ground/stages/dbg_rings.png

Bash: Absorb islands with their penumbra
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
old=s[s.index("def design_masses"):s.index("def light(")]
new='''def design_masses(f, min_frac=0.02):
    """Designed masses: no shadow island and no lit hole smaller than min_frac of the frame.
    Ferrari's shadow shapes are a few big ones; small isolated ovals read as holes in the ground.
    An absorbed island takes its soft penumbra with it (else a ring of edge is left behind)."""
    f = f.copy()
    a = f.size * min_frac
    for val in (True, False):
        m = f < 0.5
        lab, n = ndi.label(m == val)
        if n == 0: continue
        sizes = ndi.sum(np.ones_like(f), lab, range(1, n + 1))
        small = np.isin(lab, np.nonzero(sizes < a)[0] + 1)
        if not small.any(): continue
        grow = ndi.binary_dilation(small, iterations=4)
        if val:     # shadow island -> full light, including its soft rim
            f[grow & (f < 0.999)] = np.maximum(f[grow & (f < 0.999)], 1.0)
        else:       # small lit hole -> full shadow
            f[grow & (f > 0.001)] = 0.0
    return f

'''
s=s.replace(old,new)
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py F 2>&1 | tail -1

# output:
wrote F_scenes
Read: elements/ground/sheets/F_scenes.png

elements/ground/sheets/F_scenes.png 82. elements/ground/sheets/F_scenes.png

Bash: Composition plan bias for shade masses
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("def canopy_field(x, z, cover, scale, sunv, seed):","def canopy_field(x, z, cover, scale, sunv, seed, bias=None):")
s=s.replace("""    # threshold so that 'cover' of the ground is in shade; soft band gives dither in the edge""","""    if bias is not None:
        acc = acc + bias * amp * 2.5          # the composition plan: where shadow is wanted
    # threshold so that 'cover' of the ground is in shade; soft band gives dither in the edge""")
s=s.replace("def light(land, buf, az, el, canopy=0.0, canopy_scale=3.0, seed=0, shade=0.0, shade_scale=12.0):",
            "def light(land, buf, az, el, canopy=0.0, canopy_scale=3.0, seed=0, shade=0.0, shade_scale=12.0, bias=None):")
s=s.replace("    sh = canopy_field(X, Z, shade, shade_scale, s, seed + 17)","    sh = canopy_field(X, Z, shade, shade_scale, s, seed + 17, bias)")
s=s.replace("    L = light(land, buf, az, el, canopy, canopy_scale, seed, shade, shade_scale)\n","""    L = light(land, buf, az, el, canopy, canopy_scale, seed, shade, shade_scale, plan_bias(plan, H, W, buf))
""")
s=s.replace("def paint_ground(","""def plan_bias(plan, H, W, buf):
    \"\"\"Screen-space composition of the big shadow masses (Ferrari designs them; V16: the right
    bank in shadow, the left lit, a pool of light on the islet; V09: a lit path in a dark floor).
      None | 'side-left' | 'side-right' | 'foreground' | 'pool' | 'V' | 'far'  \"\"\"
    if plan is None:
        return None
    yy, xx = np.mgrid[0:H, 0:W]
    u = xx / W - 0.5; t = yy / H
    ground_top = np.argmax(~buf['sky'], axis=0).mean() / H
    tg = np.clip((t - ground_top) / max(1 - ground_top, 0.1), 0, 1)     # 0 at horizon, 1 at bottom
    b = {'side-left': -u * 2, 'side-right': u * 2, 'foreground': (tg - 0.6) * 2.5,
         'far': (0.35 - tg) * 2.5,
         'pool': (np.hypot(u * 1.6, (tg - 0.5) * 2.2) - 0.45) * 2.5,
         'V': np.abs(u) * 2 + (tg - 0.8) * 1.5 - 0.3}[plan]
    return b

def paint_ground(""")
s=s.replace("                 shadow_mask=None, blades=0.035):","                 shadow_mask=None, blades=0.035, plan=None):")
open('ground.py','w').write(s)
EOF
python3 - <<'EOF'
p='~/work/pixelart-studies/elements/ground/src/sheets.py'
s=open(p).read()
s=s.replace("sun=(25, 10), ppm=30, pitch=10, cam_h=1.7, shade=0.35, shade_scale=14, seed=5)","sun=(25, 10), ppm=30, pitch=10, cam_h=1.7, shade=0.4, shade_scale=14, seed=5, plan='side-right')")
s=s.replace("shade=0.72, shade_scale=4, canopy=0.2, canopy_scale=0.7, moisture=0.35, relief=0.5, seed=21)","shade=0.72, shade_scale=4, canopy=0.2, canopy_scale=0.7, moisture=0.35, relief=0.5, seed=21, plan='pool')")
s=s.replace("sun=(80, 8), ppm=30, pitch=14, cam_h=2.0, shade=0.25, shade_scale=10, seed=9)","sun=(80, 8), ppm=30, pitch=14, cam_h=2.0, shade=0.4, shade_scale=10, seed=9, plan='foreground')")
open(p,'w').write(s)
EOF
cd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py F 2>&1 | tail -1

# output:
wrote F_scenes
Read: elements/ground/sheets/F_scenes.png

elements/ground/sheets/F_scenes.png 83. elements/ground/sheets/F_scenes.png

Bash: Crop Ferrari forest floor passages
cd ~/work/pixelart-studies/elements/ground/src && python3 crop.py V30 300 250 560 340 5 && python3 crop.py V09 0 380 200 470 5

# output:
../ref/crop_V30_300_250_5.png
../ref/crop_V09_0_380_5.png
Read: elements/ground/ref/crop_V30_300_250_5.png
Read: elements/ground/ref/crop_V09_0_380_5.png

elements/ground/ref/crop_V09_0_380_5.png 84. elements/ground/ref/crop_V09_0_380_5.png

elements/ground/ref/crop_V30_300_250_5.png 85. elements/ground/ref/crop_V30_300_250_5.png

Bash: Show forest marks
cd ~/work/pixelart-studies/elements/ground/src && grep -n "sel = mat == MI\['forest'\]" -A 7 ground.py

# output:
483:    sel = mat == MI['forest']
484-    if sel.any():
485-        st = stones_field(buf, 0.05, rng, density=0.8)       # leaf litter clumps
486-        rl = np.clip(0.08 * ppm, 1, 6)
487-        o, t = runs_field(Hh, W, rl, rng, True); o = o * 0.4
488-        off[sel] = (np.clip(0.05 * ppm / 1.5, 0.2, 1) * st * np.where(lit, 1.0, 0.55) + o)[sel]
489-    return off * contrast, thr, coh, gap, strk
490-
Bash: Leaf-lozenge forest floor marks
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""        st = stones_field(buf, 0.05, rng, density=0.8)       # leaf litter clumps
        rl = np.clip(0.08 * ppm, 1, 6)
        o, t = runs_field(Hh, W, rl, rng, True); o = o * 0.4
        off[sel] = (np.clip(0.05 * ppm / 1.5, 0.2, 1) * st * np.where(lit, 1.0, 0.55) + o)[sel]""","""        # ground-cover leaves (V30): flat horizontal lozenges, lit top row, dark underside,
        # overlapping; bold in light, nearly gone in shade
        st = leaves_field(buf, 0.09, rng, density=1.2)
        a = np.clip(0.09 * ppm / 3, 0.3, 1.2)
        off[sel] = (a * st * np.where(lit, 1.0, 0.45))[sel]""")
s=s.replace("def lic(noise, phi, length, steps=None):","""def leaves_field(buf, size_m, rng, density=1.0):
    Hh, W = buf['z'].shape
    out = np.zeros((Hh, W)); ppm = buf['ppm']
    n = int(Hh * W * density / 8)
    ys = rng.integers(0, Hh, n); xs = rng.integers(0, W, n)
    for i in np.argsort(ys):                      # far to near: nearer leaves overlap
        y, x = ys[i], xs[i]
        s = size_m * ppm[y, x] * rng.uniform(0.6, 1.4)
        if s < 1.5:
            out[y, x] = rng.choice([-1.0, 0.8]); continue
        a = max(1, int(round(s / 2))); b = max(1, int(round(s / 7)))
        tilt = rng.normal(0, 0.15)
        y0, y1 = max(0, y - b - 1), min(Hh, y + b + 2); x0, x1 = max(0, x - a - 1), min(W, x + a + 2)
        yy, xx = np.mgrid[y0:y1, x0:x1]
        yc = y + tilt * (xx - x)
        q = ((xx - x) / (a + 0.5)) ** 2 + ((yy - yc) / (b + 0.5)) ** 2
        m = q < 1
        tone = rng.normal(0.2, 0.4)
        blk = np.where(yy - yc < -0.3 * b, tone + 0.9, np.where(yy - yc > 0.3 * b, tone - 0.6, tone + 0.2))
        out[y0:y1, x0:x1][m] = blk[m]
        yb = int(round(y + b + 1))
        if yb < Hh:
            xs_ = np.arange(max(0, x - a + 1), min(W, x + a))
            out[yb, xs_] = np.minimum(out[yb, xs_], -1.2)   # the dark gap under each leaf
    return out

def lic(noise, phi, length, steps=None):""")
s=s.replace("'forest': (0.26, 0.17, 0.09)","'forest': (0.24, 0.22, 0.08)")
s=s.replace("ALBEDO_LIT = {'turf': (0.20, 0.30, 0.07),","ALBEDO_LIT = {'forest': (0.16, 0.30, 0.05), 'turf': (0.20, 0.30, 0.07),")
open('ground.py','w').write(s)
EOF
python3 - <<'EOF'
p='~/work/pixelart-studies/elements/ground/src/sheets.py'
s=open(p).read()
s=s.replace("moisture=0.35, relief=0.5, seed=21, plan='pool')","moisture=0.35, relief=0.5, seed=21, plan='side-left')")
open(p,'w').write(s)
EOF
cd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,lw,numpy as np
a=ground.paint_ground(240,150,ground='forest', landform='rolling', hour='morning', ppm=40, pitch=18, cam_h=1.7, shade=0.65, shade_scale=4, canopy=0.2, canopy_scale=0.7, moisture=0.35, relief=0.5, seed=21, plan='side-left').rgb
lw.save(a,'../stages/s8_forest_3x.png',3)"

# output:
(Bash completed with no output)
Read: elements/ground/stages/s8_forest_3x.png

elements/ground/stages/s8_forest_3x.png 86. elements/ground/stages/s8_forest_3x.png

Bash: Greener forest floor; rerender sheets
cd ~/work/pixelart-studies/elements/ground/src && sed -i '' "s/'forest': (0.24, 0.22, 0.08)/'forest': (0.19, 0.25, 0.06)/; s/off\[sel\] = (a \* st \* np.where(lit, 1.0, 0.45))\[sel\]/off[sel] = (a * st * np.where(lit, 1.0, 0.7))[sel]/" ground.py && OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py F A 2>&1 | tail -1

# output:
wrote A_ground_x_hour
Read: elements/ground/sheets/F_scenes.png

elements/ground/sheets/F_scenes.png 87. elements/ground/sheets/F_scenes.png

Bash: Build Ferrari side-by-side comparison
cd ~/work/pixelart-studies/elements/ground/src && cat > compare.py <<'EOF'
"""Honest side-by-sides at 4x: Ferrari crop | mine, same native size."""
import ground, lw, numpy as np
from PIL import Image, ImageDraw
def crop(name, box): return lw.rgb(lw.load(name))[box[1]:box[3], box[0]:box[2]]
pairs = []
# 1 lit near bank (V16PM) vs my golden bank
g = ground.paint_ground(320, 200, ground='turf', landform='bank', hour='golden', sun=(25, 10), ppm=30, pitch=10, cam_h=1.7, shade=0.4, shade_scale=14, seed=5, plan='side-right')
pairs.append(('V16PM near lit bank', crop('V16PM', (0, 330, 150, 430)), 'mine: golden bank', g.rgb[100:200, 20:170]))
# 2 mid-distance masses: V16 far hummocks vs my rolling noon
g2 = ground.paint_ground(320, 200, ground='turf', landform='rolling', path=True, hour='noon', ppm=22, pitch=11, cam_h=2.0, shade=0.4, shade_scale=16, seed=3)
pairs.append(('V16 hummocks (mid)', crop('V16', (430, 218, 580, 318)), 'mine: rolling noon, mid', g2.rgb[40:140, 100:250]))
# 3 dusk terraces
g3 = ground.paint_ground(320, 200, ground='turf', landform='rolling', path=True, hour='dusk', sun=(80, -2), ppm=10, pitch=7, cam_h=6.0, relief=1.3, seed=7)
pairs.append(('V13 terraces + path (dusk)', crop('V13', (150, 260, 300, 360)), 'mine: dusk terraces', g3.rgb[60:160, 120:270]))
# 4 forest pool
g4 = ground.paint_ground(320, 200, ground='forest', landform='rolling', hour='morning', ppm=40, pitch=18, cam_h=1.7, shade=0.72, shade_scale=4, canopy=0.2, canopy_scale=0.7, moisture=0.35, relief=0.5, seed=21, plan='side-left')
pairs.append(('V09 lit moss path', crop('V09', (0, 380, 150, 480)), 'mine: forest pool', g4.rgb[100:200, 170:320]))
k = 4
rows = []
for a, A, b, B in pairs:
    pad = np.full((A.shape[0], 3, 3), 20, np.uint8)
    rows.append(np.concatenate([A, pad, B], 1))
    rows.append(np.full((3, rows[-1].shape[1], 3), 20, np.uint8))
img = Image.fromarray(lw.up(np.concatenate(rows, 0), k))
d = ImageDraw.Draw(img)
y = 0
for a, A, b, B in pairs:
    d.text((6, y + 4), a, fill=(255, 255, 0)); d.text((A.shape[1] * k + 18, y + 4), b, fill=(255, 255, 0))
    y += (A.shape[0] + 3) * k
img.save('../stages/compare_ferrari_4x.png')
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 compare.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/compare_ferrari_4x.png

elements/ground/stages/compare_ferrari_4x.png 88. elements/ground/stages/compare_ferrari_4x.png

Bash: Blade tones, earlier dither handoff, dusk quieting
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""            if lt:
                step[yy, xx] = 1 if t < 0.85 else 2
                if xx + 1 < W and rng.random() < 0.7: step[yy, xx + 1] = 5""","""            if lt:
                step[yy, xx] = 3 if t < 0.8 else 4          # a mid-tone stem, not a black crack
                if xx + 1 < W and rng.random() < 0.5: step[yy, xx + 1] = 5""")
s=s.replace("thr[sel] = 0.5; coh[sel] = np.clip((bh - 1.0) / 2.5, 0, 1)[sel]","thr[sel] = 0.5; coh[sel] = np.clip((bh - 3.0) / 2.5, 0, 1)[sel]   # mid distance: checker, not strands (V16)")
s=s.replace("'dusk':    (75, 4, (0.95, 0.52, 0.40), (0.22, 0.20, 0.55), (0.55, 0.40, 0.75))","'dusk':    (75, 4, (0.95, 0.52, 0.40), (0.16, 0.20, 0.55), (0.45, 0.38, 0.75))")
s=s.replace("    off, thr, coh, gap, strk = marks(buf, mat, v, lit, rng, contrast, phi)\n","""    off, thr, coh, gap, strk = marks(buf, mat, v, lit, rng, contrast, phi)
    if hour == 'dusk':                 # low light: texture sinks, masses carry the picture (V13)
        off = off * 0.45; strk &= rng.random(strk.shape) < 0.35
""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 compare.py

# output:
[This command modified 1 file you've previously read: ../pixelart-studies/elements/ground/src/ground.py. Call Read before editing.]
Read: elements/ground/stages/compare_ferrari_4x.png

elements/ground/stages/compare_ferrari_4x.png 89. elements/ground/stages/compare_ferrari_4x.png

Bash: Green-teal shadow albedo for green materials
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""            an = alb / alb.max()""","""            ash = np.array(ALBEDO_SH.get(mat, alb))
            an = ash / ash.max()""")
s=s.replace("ALBEDO_LIT = {","""# shadows of green things go green-teal, not olive-grey (V16 noon: 0,23,7 / 31,67,39 / 0,7,15)
ALBEDO_SH = {'turf': (0.10, 0.30, 0.13), 'meadow': (0.16, 0.28, 0.10), 'forest': (0.08, 0.26, 0.12),
             'moss': (0.08, 0.28, 0.10)}
ALBEDO_LIT = {""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 compare.py && python3 -c "
import ground
for h in ['noon','golden','dusk']: print(h, ground.ramp('turf',h)[:3].tolist())"

# output:
noon [[1, 14, 14], [6, 30, 23], [20, 60, 48]]
golden [[2, 13, 16], [11, 29, 27], [28, 58, 54]]
dusk [[1, 6, 16], [6, 17, 27], [14, 31, 45]]
Read: elements/ground/stages/compare_ferrari_4x.png

elements/ground/stages/compare_ferrari_4x.png 90. elements/ground/stages/compare_ferrari_4x.png

Bash: Regenerate all sheets and comparisons
cd ~/work/pixelart-studies/elements/ground/src && sed -i '' "s/ALBEDO_SH = {'turf': (0.10, 0.30, 0.13)/ALBEDO_SH = {'turf': (0.10, 0.30, 0.09)/" ground.py && (OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py 2>&1 | tail -6; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 compare.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s6_driftwood.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s0_islet.py) 2>&1 | tail -3

# output:
wrote D_moisture
wrote E_seeds
wrote F_scenes
Read: elements/ground/sheets/A_ground_x_hour.png
Read: elements/ground/sheets/E_seeds.png

elements/ground/sheets/E_seeds.png 91. elements/ground/sheets/E_seeds.png

elements/ground/sheets/A_ground_x_hour.png 92. elements/ground/sheets/A_ground_x_hour.png

Bash: Absorb floating shadow islands; break form terminator
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""        sizes = ndi.sum(np.ones_like(f), lab, range(1, n + 1))
        small = np.isin(lab, np.nonzero(sizes < a)[0] + 1)""","""        sizes = ndi.sum(np.ones_like(f), lab, range(1, n + 1))
        edge = np.unique(np.concatenate([lab[0], lab[-1], lab[:, 0], lab[:, -1]]))
        floating = ~np.isin(np.arange(1, n + 1), edge)
        # a shadow floating free of the frame reads as a hole unless it is big
        lim = np.where(floating & val, a * 3.5, a)
        small = np.isin(lab, np.nonzero(sizes < lim)[0] + 1)""")
s=s.replace("    lit = (sv > 0.5) & (L['ndl'] > lit_floor * sy)\n","    lit = (sv > 0.5) & (L['ndl'] + en * sy * 1.5 > lit_floor * sy)   # the form terminator is broken too\n")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py E 2>&1 | tail -1

# output:
wrote E_seeds
Read: elements/ground/sheets/E_seeds.png

elements/ground/sheets/E_seeds.png 93. elements/ground/sheets/E_seeds.png

Bash: Debug straight vertical shadow edge
cd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
g=ground.paint_ground(200,130,landform='rolling',ppm=24,pitch=12,cam_h=1.7,seed=1,ground='turf',hour='noon',shade=0.35,shade_scale=12,path=True)
c=slice(55,85)
print('sunvis',g.L['sunvis'][60,c].round(2)); print('terr',g.L['terrain_vis'][60,c].round(2)); print('ndl',g.L['ndl'][60,c].round(2)); print('z',g.buf['z'][60,c].round(1)); print('x', g.buf['x'][60,c].round(2))
"

# output:
sunvis [0.   0.   0.   0.   0.   0.   0.   0.   0.01 0.02 0.03 0.07 0.13 0.23
 0.38 0.56 0.73 0.85 0.92 0.96 0.98 0.99 1.   1.   1.   1.   1.   1.
 1.   1.  ]
terr [1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.

 1. 1. 1. 1. 1. 1.]
ndl [0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91
 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91 0.91
 0.91 0.91]
z [2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2. 2.

 2. 2. 2. 2. 2. 2.]
x [-0.53 -0.52 -0.51 -0.5  -0.48 -0.47 -0.46 -0.45 -0.44 -0.42 -0.41 -0.4
 -0.39 -0.38 -0.36 -0.35 -0.34 -0.33 -0.32 -0.3  -0.29 -0.28 -0.27 -0.26
 -0.24 -0.23 -0.22 -0.21 -0.2  -0.19]
Bash: Camera height relative to local ground
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""    zs = z_near * (z_far / z_near) ** (np.arange(steps) / (steps - 1))""","""    zs = z_near * (z_far / z_near) ** (np.arange(steps) / (steps - 1))
    # the camera stands cam_h above the ground under it (and above the next few metres ahead)
    zz = np.linspace(0, 4, 9)
    base = float(max(land.h(np.zeros(1), np.full(1, q), micro=False)[0] for q in zz))""")
s=s.replace("""        h = land.h(x, np.full_like(x, z))
        y = h - cam_h""","""        h = land.h(x, np.full_like(x, z))
        y = h - cam_h - base""")
s=s.replace("""    buf = dict(z=Zs, x=X, sky=sky, F=F, pitch=p, cam_h=cam_h)""","""    buf = dict(z=Zs, x=X, sky=sky, F=F, pitch=p, cam_h=cam_h, cam_y=base + cam_h)""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py E 2>&1 | tail -1

# output:
wrote E_seeds
Read: elements/ground/sheets/E_seeds.png

elements/ground/sheets/E_seeds.png 94. elements/ground/sheets/E_seeds.png

Bash: Regenerate all outputs
cd ~/work/pixelart-studies/elements/ground/src && (OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 compare.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s6_driftwood.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s2_v13.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s1_grid.py) 2>&1 | tail -3; ls ../sheets ../stages

# output:
wrote D_moisture
wrote E_seeds
wrote F_scenes
../sheets:
A_ground_x_hour.png
B_landform_x_sun.png
C_distance.png
D_moisture.png
E_seeds.png
F_scenes.png
integration_driftwood_3x.png

../stages:
compare_ferrari_4x.png
dbg_earth_8x.png
dbg_puddle_6x.png
dbg_rings.png
palettes_16x.png
quick_mats_3x.png
s0a_islet_6x.png
s0a_islet_fields_4x.png
s0a_jitter_6x.png
s1_grid_3x.png
s1_near_8x.png
s1_v16pm_3x.png
s1_v16pm_8x.png
s2_v13_2x.png
s2_v13_2x_stretch.png
s2_v13_mid_6x.png
s3_near_8x.png
s3_path_3x.png
s4_mats_3x.png
s5_distance_2x.png
s7_blades_3x.png
s8_forest_3x.png
try1_3x.png
try2_3x.png
Read: elements/ground/sheets/F_scenes.png
Read: elements/ground/sheets/C_distance.png

elements/ground/sheets/F_scenes.png 95. elements/ground/sheets/F_scenes.png

elements/ground/sheets/C_distance.png 96. elements/ground/sheets/C_distance.png

Read: elements/ground/sheets/integration_driftwood_3x.png

elements/ground/sheets/integration_driftwood_3x.png 97. elements/ground/sheets/integration_driftwood_3x.png

Bash: Remove debug images and scratch scripts
cd ~/work/pixelart-studies/elements/ground && rm -f stages/dbg_* stages/try1_3x.png stages/try2_3x.png src/try1.py src/try2.py src/s0_islet_j.py && rm -rf src/__pycache__ && ls stages src

# output:
src:
analyze.py
compare.py
crop.py
dither.py
ground.py
lw.py
quick.py
s0_islet.py
s1_grid.py
s1_v16.py
s2_v13.py
s3_near.py
s3_path.py
s4_mats.py
s5_dist.py
s6_driftwood.py
sheets.py

stages:
compare_ferrari_4x.png
palettes_16x.png
quick_mats_3x.png
s0a_islet_6x.png
s0a_islet_fields_4x.png
s0a_jitter_6x.png
s1_grid_3x.png
s1_near_8x.png
s1_v16pm_3x.png
s1_v16pm_8x.png
s2_v13_2x.png
s2_v13_2x_stretch.png
s2_v13_mid_6x.png
s3_near_8x.png
s3_path_3x.png
s4_mats_3x.png
s5_distance_2x.png
s7_blades_3x.png
s8_forest_3x.png
Write: elements/ground/LESSONS.md
# The ground, after Mark Ferrari

What I was asked to fix: across the studio the ground is texture at a single scale everywhere, with no big value masses. What I built is a ground painter where **the masses come first, from landform and light, and texture lives inside them**. It works as a mass designer. As a pixel surface it is **not yet** Ferrari-grade at 8×, and I say where below.

Everything here comes from `src/`. Stage images are in `stages/`, labelled sheets in `sheets/`, and Ferrari reference crops in `ref/`.


cd src
nice -n 10 python3 sheets.py          # sheets A-F (4 processes, about 2 min)
nice -n 10 python3 compare.py         # stages/compare_ferrari_4x.png
nice -n 10 python3 s6_driftwood.py    # sheets/integration_driftwood_3x.png
--- ## Stage 0: what Ferrari's ground is made of (read from his index maps) I loaded his scenes as index maps (`src/lw.py`) and made squint maps (`src/analyze.py` → `ref/mass_*.png`), random-colour index maps (`ref/idxmap_*.png`) and 4–6× crops (`ref/crop_*.png`). What I found: 1. **Every ground passage is 2–4 big shapes before it is any texture.** In V16 there are four: the lit islet, a shadowed right bank, a lit left bank and dark far hummocks. In V13 the land is 5–6 terraces, each one a single dark value with a slightly lighter crest and a lighter path cutting across. In V09 the moss path is one lit ribbon in a black-green floor. Squinted, each of these reads as a flat poster shape. 2. **The shapes come from light, not from local colour.** The right bank of V16 is shadowed by forest outside the frame. Ferrari uses cast shadow from off-frame objects as a *compositional* tool (the analysis student found this too). 3. **Shadow masses are nearly black and hold very few values.** Measured in V16 (`L*`): the far hummocks are 2 / 6–9 / 25, the right bank 0 / 2 / 15–18, and the near left bank 3 / 10 / 15. In V13 the whole dusk ground lives between L\* 0 and 12. The light family is where the colours are. 4. **Inside a mass the turn is carried by an ordered 2×2 dither between adjacent steps.** On the V16 islet the checkered transition zone is textbook DPaint gradient fill: 50% becomes a clean checker, with a little jitter. I reproduced it (`stages/s0a_islet_6x.png`). Left is his islet. Middle is his value field put through my quantizer, which is indistinguishable at 6× once jitter is about 0.35 (`s0a_jitter_6x.png`). Right is my own radial field with his silhouette, which is close but has a rounder falloff than his flat-topped lozenge. 5. **Grain grows with nearness** (the master-copy student measured this, and I confirm it): 1-px checker stipple far off, then 1-px vertical strands 2–7 px tall, then drawn single blades in the nearest metre (the V13 foreground and the V16 right bank). 6. **Marks skip steps.** In lit grass the dark gap between blades is a near-black green, two or three steps below the base. In shade the blades are olive strokes on black (negative painting). This is where the contrast lives, inside the mass, and it isn't produced by the mass's own dither. ## Stage 1: masses from landform and light (the core of the technique) `ground.py`, layers 1–4. The land is an analytic height field built from a few big features (rolling swells biased to run across the view so they stack into terraces, hummocks, a bank, ledges, a path). It is rendered by a column march, so every pixel knows its world x, z, height, normal and pixels per metre. Light makes the masses: - **Terrain horizon shadow**, marched toward the sun. - **Off-frame shade** (`shade`, `shade_scale`). Soft unions of stretched crown shadows along the sun's azimuth, with a world-anchored ragged edge of octave sinusoids, so the shapes are lobed and notched like foliage shadows. - **`design_masses`**: no shadow island or lit hole smaller than about 3% of the frame, and floating shadows must be bigger still. This single step did more against "speckle" than anything else. A mass is a *designed* shape, and small ovals read as holes in the ground. - **`plan`** (`side-left | side-right | foreground | far | pool | V`) biases where shade falls, so the director can compose the masses as Ferrari does (V16: shaded right bank, lit left bank). The value plan uses **two families that never touch**: shadow steps 0–2, light steps 3–5. The families are clamped both before and after quantization. The terminator is broken by the material: blade tips on turf, grains on earth. Inside the light family, grass responds weakly to slope (it's a forest of vertical blades that catches a low sun at any slope), while bare earth and gravel take full N·L. Inside the shadow family, form comes from **directional skylight**: surfaces facing the bright side of the sky are the "light" of the shadow family. At dusk that is all that shapes the land (V13). I exaggerate it with a tanh, since Ferrari turns land more than nature does. Then come the crest separation (land just behind a crest drops a step), receding sheen, and a repoussoir vignette inside each family. See `sheets/B_landform_x_sun.png` (masses from land and light alone, with no off-frame shade) and `sheets/E_seeds.png`. What I learned: - A physical light model alone gives *either* nothing (a front sun makes everything one lit value) *or* too much (a low side sun makes everything shadow). Ferrari's masses are designed shapes. The fix was design_masses plus plan, not more physics. - The 3.0/2.9 family clamp has to happen after the marks as well, or the ordered dither will mix a lit step into a shadow and the notan goes grey. - The camera must stand `cam_h` above the ground *under it*. Otherwise the near ground can rise above the eye, and I got one seed whose frame was half a wall of dark grass (fixed). ## Stage 2: texture inside the masses, by material Layers 5–7. Offsets in step space whose **size follows pixels per metre** and whose **contrast follows the family and the distance**: - **turf/meadow**: vertical runs (length ≈ 0.6 × blade px, capped at 7.5), tuft-correlated so darks gather into pockets. Near: each run rounds to a whole step, so there are always two classes. Mid: ordered checker (V16). Near-black gaps sit in the tufts' dark pockets. In shade: olive negative strokes. A crest fringe puts blade tips over the land behind and dark holes along crests. In the nearest metre, a few `hero_blades` are drawn as leaning clean runs. - **earth/path**: LIC streaks along the form (the path's length, the ground's contours), sparse and strong, plus a few pebbles. The path is its own mass: a packed, lighter crown and grass-shaded margins a step down. - **gravel**: small stones with a light top row, dark lower row and dark pocket, deliberately quiet (1–2 steps in light, less in shade). - **forest floor**: flat, overlapping leaf lozenges (V30's ground cover), bold in light and near-gone in shade. - **moisture**: a mass-level darkening, plus puddles. Puddles are lobed pools in fine hollows that take the sky's ramp, with a dark far-edge band (the channel student's rule) and a wet rim. Palettes (layer 8) are generated per material × depth band × hour from L\* targets, not from shading maths. The shadow albedo of green things is shifted to teal-green (measured in V16). The top light step goes ochre. The far bands lift the black point by an hour-keyed amount (at dusk the far land stays dark). `g.relight(hour)` repaints the same index canvas: `sheets/A_ground_x_hour.png`, `stages/palettes_16x.png`. ## Stage 3: distance `sheets/C_distance.png`, from 120 px/m to 1.2 px/m. Strands hand off to checker at about 3 px of blade height. Mark contrast fades with log distance but keeps a floor, so far ground stays stippled rather than flat. The masses are what hold the image together at every scale. I did **not** run a 1.25× step zoom for pops (the grass-lod student's test). The handoffs are continuous functions of pixels per metre, so there's no switch to pop, but that is inference, not a check. ## Honest comparison with Ferrari (`stages/compare_ferrari_4x.png`, 4×) | passage | verdict | why | |---|---|---| | V16PM near lit bank vs my golden bank | **close at 1×, not at 4×** | Value economy and strand scale are right. My palette is more olive and his is salmon-orange (a key choice, easy to change). My hero blades read as a drawn overlay. His near bank has none; they belong only in the front metre or on dark ground (V13, V16 right bank). | | V16 hummocks vs my rolling noon | **not there** | His mid ground is *lozenge-shaped hummocks with a lit band under each crest and a dithered falloff*, a sculpted form turned by ordered dither. Mine is a flat shadow value with strand texture. My landforms are too smooth to make his hummock shapes; the hummocks landform exists but doesn't turn this way yet. | | V13 dusk terraces vs mine | **masses right, surface not** | The terrace layering and path work. His terraces have a lighter crest band each, and the grass texture shows only on crests. Mine are more uniform. | | V09 lit moss path vs my forest pool | **not there** | His light is a *ribbon*, 8–12 px wide, snaking. Mine is a broad pool with leaf texture that is too regular and too bright. Ferrari's forest light is narrower and darker, and it follows paths. | The sheets beat the old studio ground on the stated failure: every cell is 2–4 designed value masses first. They do **not** yet sit beside Ferrari at 8×. The three biggest gaps: 1. **Sculpted mid-ground forms.** Ferrari turns every hummock and terrace with a lit crest band and a dithered falloff (the islet recipe, applied per landform unit). I have the quantizer and the islet copy, but my landform-to-value mapping at mid distance collapses to one flat shadow value. Next step: a per-crest "lit band" term keyed to distance below the crest line, to generalize V16's islet. 2. **Forest light shapes.** These should be ribbons and pools that follow paths (V09), not blobs. 3. **Hero blades on lit ground** read as scratches. They should be restricted to the front metre and to shadow. ## Using this ground (for cobbles, driftwood, willow, channel)
import sys; sys.path.insert(0, &#x27;~/work/pixelart-studies/elements/ground/src&#x27;)
import ground
g = ground.paint_ground(W, H, ground=&#x27;gravel&#x27;, landform=&#x27;flat&#x27;, hour=&#x27;golden&#x27;, sun=(20, 10),
                        ppm=40, pitch=30, cam_h=4, shade=0.35, shade_scale=3, plan=&#x27;side-right&#x27;,
                        moisture=0.3, seed=24, shadow_mask=my_cast_shadow)
rgb = g.rgb; g.depth, g.wx, g.wz, g.h   # world buffers to place objects
g.mat, g.band, g.step                   # index canvas; g.relight(&#x27;noon&#x27;) repaints
Parameters: - `ground`: turf | meadow | earth | gravel | forest - `landform`: flat | rolling | hummocks | bank | ledges, plus `path` - `hour`: morning | noon | golden | dusk | overcast, or `sun=(az, el)`. az 0 = sun on the left, 90 = contre-jour. - `moisture`: 0–1 - `ppm`: px per metre at screen centre; this is the distance knob - `pitch`, `cam_h`: camera - `shade`, `shade_scale`, `plan`: designed off-frame shadow masses - `canopy`: small dapple - `shadow_mask`: your object's cast shadow, painted as the ground in its shadow family - `blades`, `vignette`, `sheen`, `sep`, `contrast`, `jitter`, `lit_base`, `seed` **Demonstrated**: `sheets/integration_driftwood_3x.png`. The driftwood student's logs, root wad and pile (their `render` and palette, unmodified) sit on this ground. The left column is their original gravel. The middle column is my gravel with off-frame shade and moisture, and the right is my turf. The logs' own cast shadows pass through `shadow_mask`, and their contact pixels are kept. The difference is the point of the study: their ground was even speckle as loud as the logs, and mine is two big quiet masses with the logs as the brightest shapes. Caveat: inside a big shade mass a log's own shadow disappears (correctly, but compositionally you may want the log in the light: use `plan`). The rules to take even without the code: 1. Decide 2–4 masses first, lit versus shadow, from off-frame casters and landform. Delete anything smaller than about 3% of the frame. 2. Keep shadow masses near black with 2–3 values and put the colour in the light. 3. Marks go inside a mass, sized by pixels per metre, and never cross the terminator except as a fringe of tips. 4. Use ordered 2×2 plus jitter for turns in mid and far ground, and runs only near. 5. Give contrast to the marks (gaps, stems), not to the mass. ## Requests - A critic pass on `compare_ferrari_4x.png` and `F_scenes.png`, especially on whether the masses read as designed or as random blobs. - The next study I would do: a stage-0 copy of the V16 far hummocks (lozenge, lit band under crest, dithered falloff), then fold it into the value plan as a per-crest term. That is gap 1 above.
SubagentHandback:

I've sent the ground study to the agent that launched it.

Bash: Character map of V16 far hummocks
cd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw,numpy as np
from analyze import Lstar
d=lw.load('V16'); I=d['idx']; P=d['pal']
s=I[215:275,430:640]
L=Lstar(P[s].reshape(-1,3)).reshape(s.shape)
ch={}
u=np.unique(s); order=sorted(u,key=lambda k:Lstar(P[k][None])[0])
sym=' .:-=+*#%@&'
m={k:sym[min(i,len(sym)-1)] for i,k in enumerate(order)}
for r in s: print(''.join(m[v] for v in r[:160]))
print([(k,tuple(P[k]),round(float(Lstar(P[k][None])[0]))) for k in order])
"

# output:
&--:*-*--:-:-:-&::-:-::&*-:**--:&&-:&&&-&&-::::&:&&&&&&&  &  &*:-&&-:*-:&&**::&-:&&**-&*&&*--:*-*-*&&*&-&-&&&:&*-:-&-:-:-**--:::-:-:*-*-&**--:::**-******-***&*-
&&****---&***--:&::::::-&:--&&**--&&**----:::&&&&&&&&&    &...&-&*&&:&*-:&&*-:-&:&-&&*&&&&****-*-**&&&*-&-&**:&---&--*--------:-:-:::***-*:::::&*-:*****-*-*&&-:
&**-*--:-&&**--::&:&-::--&&&&**--::-&&:**--:-:::.&&&&&& .....&-&&&&:*&*-:&-&&&&#&*&&&&&&&**:*:*-*&&&&*&*&-&*--&--&----*-*--:-:-:-:::*--**-*--:&&::-**--**-*&&--:
***---::&&**----:---::::--&&***---:&:&:&**----::&.....&& &.. &:&&*::&*&&#&&&#&#&*&&&-&&&***:*&***&&&&&***-&&&&-:&--&---*------:-::::::--::**-&*-**-**--*-**--&-*
&-*:-:-:&**::-*-*--:-:-::--&&-:*&&&--&*:&**-&:-::..&....&..&&&:-*&&&#&#&&#&#&#&&&*&&&**-*-*-&*&*&&*:&*&-*-----&&&:&---*-*&*--:-:-:::-:::::*-&**-*****---*--**-&*
*--::-::*-::-*------:-::::-&*-:*&&&*--&&:&--:&:::::..&&&&&& & :*##&&&##&#&#&&&&&**&&**-*--:*&&*&*&::&&**::-&---&:&----&**-**--::**--::::&&**&&**-*-*--**&--&&&-&
&**--::**--:*------:-:-:-::---&&&&&-&**:::-:-:-::::&&&&&&&&&&&&#&&#&##&#&&&&&&&**&&**---*--:&*&-*::&:&&:::&&-&-&&--:&---&--:-**::**-*-&*&**-&**-*---*-*&*-*--*&-
&*-:*&***---:-----:::::-------&&&&&&***---::::::::::::&&&&&&&&&#&&******&&****-&&&**-------*::-&--&&:&*::&::&-&-----&&--&::::*:-::**&&*&****--****----**------&&
-:&**&&**--::----:-:-::-&*&*&:&**&&&&**-*&-:-:*:-::::.&...&&&.#&#&&**-&*&*&**-*&&**---*--:*--&&--&&:&*::&:&:-&&----&&---&:-:::*--::**&&**-*--*--**--**&*--*---&-
&&**--&&**--::----:&:::&&&&&&&**&&&*&&**--::::&*-:::...&.&&&&#&#&&***&&&******&&**&**&---**-&&-----&**-&&&:-&&---&:&&---&:::::**---&**&**-*&**-&&-****-&-&---&&-
&**-*--&&**:-&&:&:&:&+&&&&&&&*&-&*&&---::&::&:-:::&:&#&&&#&&#&#&####&&&*&**-*-&&&***&**----:&-----&-&&&**--&&---&--&&---&:-:-::**-&-&&-&&*****&---*-&&----&&&&&&
***---:&**&&&&&&&&&&&&&&&&+&&&&&**&-&:*&----:&:&&#&#&&&#&#&#&#&#&#######&&**&**-***&&***----*----&--&-&&**-&----&-&&---&-::::&&&*&&&&&#&&#&&**&-&&---&&&-&&&&#-&
:##&&#&&#&#&#&#&#&#&&&#&&+&&&&&&&&&&&&&*&&:&#&#&#&#&&&&&#&############&#&&&**-*-*-&*&**-*--:***----&&&&-&--&-&-:-&&**-&&&:&&-&#&&&&#&&&&&&&&#&&&#&#&&&&&&-&&&&#&
#&###&####&#&#&#&#&#&&&&&&&+&&&+&&&&&&&&&&&#&#&#&&&#&#&#&#&##+&########&&&****--&&&&***-*--***----&*&&--&&-&--&--&&&&#:&&&##&#&&&&&&&&&&&&&#&#&&&&&&&&#&#&&&&#&&
###+#+#+#+#+#+#&#&+&+&++&&+&+&&&&&&&&&&&&&&&&&&&#&#&&&#&#&############&*&**&***-&*&**-***&&**----:&&&-&&&&&&&&#&&&&#&&&#&#&&&&&&#&&&&&&&&#&&&&&&&&&#&&&#&&&#&&&#
####+#+#+#+#+#++++&+&+&&&&&&&&&&&+&+&&&&&&&&&&&&&&#&&&&&&&#&#&#&######&&**&&****&&**-***-&&*&&-&:*&&&#&#&#&&&#&&&&&&&#&&&&&&&##&&&&#&&#&#&&#&#&#&&&&&&&&&#&&&#&&
#+#+#+#+#+#+#++++&+&+&+&+&+&+&+&+&&&&&++&&&&&&&&&&&&&&#&&#&#&#&&##&#######&*&*&*&-&--*:-&&&*#&&&&#&&&&&&&&&&#&#&&&&&&&#&&#&&&#&&&&&&&&##&&&&&&&&&&&&&##&&&&&#&&&
##+#+#+++#++++#+++++++&+++&&&+&&&&&&&&&&&&&&&&&&&&&&&&&&&&&#&&&&&&#&#&#####&&**--&----&&##&&&&&&&&#&&&#&&&&&&&&&#&&&#&&&#&#&&&&&#&&&&&&&#&&&&&&&#&&#&&&&#&#&&&&&
#+#+###+#+++#+#+#++&+&+&+&+&+&+&+&+&+&&&+&&&+&+&+&&&&&&&&&&&&&&&&&&&&&&&#&#&#&*&*&&:-:#&&&&&#&&&#&&&&&
Bash: Measure hummock value profile below crest
cd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw,numpy as np
d=lw.load('V16'); I=d['idx']
s=I[215:280,300:640]
ramp=[69,235,72,59,70,120,126,71,127,60,61,62,52,53]
# order by value: 69/235 L25, 72/59 L9, 70 L6, 71 L2, darker
val={69:3,235:3,72:2,59:2,70:1,120:1,126:1,71:0,127:0,60:0,61:0,62:0,52:0,53:0}
isr=np.isin(s,list(val))
v=np.vectorize(lambda k: val.get(k,-1))(s)
# crest: ramp pixel whose above pixel is not ramp (water/sky/other)
H,W=s.shape
prof={}
for x in range(W):
  col=v[:,x]; y=1
  while y<H:
    if col[y]>=0 and col[y-1]<0:
      y0=y
      while y<H and col[y]>=0: y+=1
      L=y-y0
      if L>=6:
        for k in range(L): prof.setdefault((min(L,40)//10,k),[]).append(col[y0+k])
    y+=1
for Lb in range(5):
  row=[]
  for k in range(0,40,2):
    a=prof.get((Lb,k))
    if a: row.append('%.1f'%np.mean(a))
  print('segLen~%d0:'%Lb,' '.join(row))
"

# output:
segLen~00: 1.2 1.6 1.5 1.7 0.8
segLen~10: 2.0 1.6 2.2 1.0 0.0 0.5 0.0
segLen~20: 1.4 1.7 1.7 1.7 1.9 1.7 1.6 1.6 1.5 1.4 1.1 1.0 1.0 0.8 0.5
segLen~30: 1.7 1.9 1.9 1.8 2.0 2.2 1.7 1.2 0.9 0.9 1.1 1.0 0.8 0.8 0.8 1.2 1.2 1.3 0.7
segLen~40: 0.8 1.1 1.2 1.1 1.5 1.9 1.8 1.6 1.9 1.9 1.9 1.8 1.9 1.9 1.8 2.6 2.3 2.2 2.1 1.8
Bash: Stage 0b: hummock crest-band copy
cd ~/work/pixelart-studies/elements/ground/src && cat > s9_hummock.py <<'EOF'
"""Stage 0b: V16 far hummocks, copied by procedure.
His silhouettes (the index mask), my fill: per column, a value profile below each crest
(dark accent on the crest line, lit band ~25% down, falloff to a dark base), smoothed
across columns, quantized with the islet's ordered 2x2 + jitter."""
import lw, numpy as np, dither
from scipy import ndimage as ndi
d = lw.load('V16'); I = d['idx']; P = d['pal']
x0, y0, x1, y1 = 300, 215, 640, 280
S = I[y0:y1, x0:x1]
VAL = {69: 3, 235: 3, 72: 2, 59: 2, 70: 1, 120: 1, 126: 1, 71: 0, 127: 0, 60: 0, 61: 0, 62: 0, 52: 0, 53: 0}
RAMP = [71, 70, 72, 69]
m = np.isin(S, list(VAL))
H, W = S.shape

def crest_profile(t, accent=0.35, peak=0.25):
    """0..1 value shape below a crest, t = fraction of the way from crest to base"""
    up = accent + (1 - accent) * np.clip(t / peak, 0, 1) ** 0.7
    down = np.clip(1 - (t - peak) / (1 - peak), 0, 1) ** 0.9
    return np.where(t < peak, up, down)

def crest_t(mask):
    """for each masked pixel: rows below the top of its vertical run, and run length"""
    k = np.zeros(mask.shape); L = np.zeros(mask.shape)
    for x in range(mask.shape[1]):
        col = mask[:, x]; y = 0
        while y < len(col):
            if col[y]:
                y0_ = y
                while y < len(col) and col[y]: y += 1
                k[y0_:y, x] = np.arange(y - y0_); L[y0_:y, x] = y - y0_
            else:
                y += 1
    return k, L

k, L = crest_t(m)
t = k / np.maximum(L, 1)
f = crest_profile(t)
# smooth across columns so the band reads as a form, not per-column noise
num = ndi.gaussian_filter(f * m, (0.6, 2.5)); den = ndi.gaussian_filter(m * 1.0, (0.6, 2.5)) + 1e-6
f = num / den
v = 0.3 + 2.4 * f
st = dither.ordered(v, jitter=0.4, rng=np.random.default_rng(3), n=4)
out = P[S].copy(); out[m] = P[np.array(RAMP)[st]][m]
ref = P[S]
pad = np.zeros((3, W, 3), np.uint8)
lw.save(np.concatenate([ref, pad, out], 0), '../stages/s9_hummocks_5x.png', 5)
EOF
python3 s9_hummock.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s9_hummocks_5x.png

elements/ground/stages/s9_hummocks_5x.png 98. elements/ground/stages/s9_hummocks_5x.png

Bash: Cut runs at his crest lines
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('s9_hummock.py').read()
s=s.replace("k, L = crest_t(m)","""# his crest lines: where his squinted value steps UP going down the column (dark base of the
# hummock behind, lit band of the one in front). These are the silhouettes I must draw myself
# in my own ground; here I take them from him, to test only the fill.
vs = np.vectorize(lambda q: VAL.get(q, 0))(S).astype(float)
sq = ndi.gaussian_filter(vs, (1.2, 3.0))
dy = np.zeros_like(sq); dy[1:] = sq[1:] - sq[:-1]
crest = (dy > 0.12) & (ndi.maximum_filter(dy, size=(5, 1)) == dy) & m
m_cut = m & ~crest
k, L = crest_t(m_cut)""")
s=s.replace("lw.save(np.concatenate([ref, pad, out], 0)","dbg = ref.copy(); dbg[crest] = (255, 0, 0)\nlw.save(np.concatenate([ref, pad, out, pad, dbg], 0)")
open('s9_hummock.py','w').write(s)
EOF
python3 s9_hummock.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s9_hummocks_5x.png

elements/ground/stages/s9_hummocks_5x.png 99. elements/ground/stages/s9_hummocks_5x.png

Bash: Keep only long crest lines
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('s9_hummock.py').read()
s=s.replace("m_cut = m & ~crest","""lab, n = ndi.label(ndi.binary_dilation(crest, np.ones((3, 3))))
sz = ndi.sum(crest, lab, range(1, n + 1))
crest = crest & np.isin(lab, np.nonzero(sz >= 25)[0] + 1)
m_cut = m & ~crest""")
open('s9_hummock.py','w').write(s)
EOF
python3 s9_hummock.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s9_hummocks_5x.png

elements/ground/stages/s9_hummocks_5x.png 100. elements/ground/stages/s9_hummocks_5x.png

Bash: Check his checker pairs; use 3-step ramp
cd ~/work/pixelart-studies/elements/ground/src && python3 -c "
import lw,numpy as np
d=lw.load('V16'); s=d['idx'][225:275,450:640]
u,c=np.unique(s,return_counts=True); print(sorted(zip(-c,u))[:8])
# checker pairs: horizontally adjacent class pairs counts
a=s[:,:-1];b=s[:,1:]; from collections import Counter
print(Counter(zip(a[a!=b],b[a!=b])).most_common(8))"
sed -i '' 's/RAMP = \[71, 70, 72, 69\]/RAMP = [71, 70, 69]/; s/v = 0.3 + 2.4 \* f/v = 0.1 + 1.85 * f/; s/n=4)/n=3)/' s9_hummock.py && python3 s9_hummock.py

# output:
[(-3367, 69), (-3121, 70), (-1339, 71), (-1082, 72), (-204, 61), (-104, 60), (-98, 59), (-52, 62)]
[((69, 70), 1402), ((70, 69), 1376), ((71, 69), 380), ((69, 71), 363), ((70, 71), 311), ((71, 70), 294), ((71, 72), 181), ((72, 71), 165)]
Read: elements/ground/stages/s9_hummocks_5x.png

elements/ground/stages/s9_hummocks_5x.png 101. elements/ground/stages/s9_hummocks_5x.png

Bash: Locate insertion points
cd ~/work/pixelart-studies/elements/ground/src && grep -n "    v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9))   # families never touch" ground.py; grep -n "def value_plan" ground.py; grep -n "    return np.clip(v, 0, 5.99), lit, sepm" ground.py

# output:
743:    v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9))   # families never touch
269:def value_plan(buf, L, mat=None, sep=0.9, sheen=0.5, lit_floor=0.10, lit_base=0.6, overcast=False):
326:    return np.clip(v, 0, 5.99), lit, sepm
Bash: Add crest band and mass turn terms
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("def paint_ground(","""def crest_profile(t, accent=0.35, peak=0.25):
    \"\"\"Measured on V16's far hummocks: value below a crest line, t = 0 at the crest, 1 at the
    base. A thin darker accent on the crest, the lit band ~25% down, falloff to a dark base.\"\"\"
    up = accent + (1 - accent) * np.clip(t / peak, 0, 1) ** 0.7
    down = np.clip(1 - (t - peak) / (1 - peak), 0, 1) ** 0.9
    return np.where(t < peak, up, down)

def crest_runs(buf, cap):
    \"\"\"Per pixel: rows below the crest line above it (an occlusion edge or the skyline) and the
    length of that run down to the next crest; runs longer than cap px use cap (near ground has
    no single form reaching the frame bottom).\"\"\"
    z = buf['z']; H, W = z.shape
    brk = np.zeros((H, W), bool)
    brk[1:] = ((z[:-1] > z[1:] * 1.25) & (z[:-1] - z[1:] > 2.0)) | buf['sky'][:-1]
    brk[0] = True
    brk &= ~buf['sky']
    k = np.zeros((H, W)); Ln = np.ones((H, W))
    for x in range(W):
        ys = list(np.nonzero(brk[:, x])[0]) + [H]
        for a, b in zip(ys[:-1], ys[1:]):
            k[a:b, x] = np.arange(b - a); Ln[a:b, x] = b - a
    return k, np.minimum(Ln, cap), Ln

def turn_masses(v, lit, buf, crest_band=1.0, mass_turn=1.0, cap=None):
    \"\"\"Turn the flat masses into forms, inside each family:
      crest band -- every land unit gets the V16 hummock profile below its crest line
      mass turn  -- a lit pool is hottest in its interior and falls off (dithered) toward its
                    edge (the V16 islet); a shadow is darkest along its edge and lifts inside
                    (reflected light), so no mass is a flat colour field with a noisy edge\"\"\"
    H, W = v.shape
    out = v.copy()
    if crest_band > 0:
        cap = cap or max(24, H * 0.35)
        k, Lc, Ln = crest_runs(buf, cap)
        t = np.clip(k / Lc, 0, 1)
        f = crest_profile(t)
        f = np.where(k > Lc, 0.0, f)          # beyond the capped run the form is done
        f = ndi.gaussian_filter(f, (0.6, 2.0))
        amp = crest_band * np.where(lit, 0.9, 1.3)
        out = out + amp * (f - 0.45) * (k <= Lc * 1.2)
    if mass_turn > 0:
        dl = ndi.distance_transform_edt(lit); ds = ndi.distance_transform_edt(~lit)
        D = max(6.0, 0.06 * min(H, W))
        out = out + mass_turn * np.where(lit, 0.8 * (np.clip(dl / D, 0, 1) - 0.5),
                                         0.6 * (np.clip(ds / (1.5 * D), 0, 1) - 0.6))
    return np.where(lit, np.clip(out, 3.0, 5.99), np.clip(out, 0, 2.9))

def paint_ground(""")
s=s.replace("    v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9))   # families never touch\n    off, thr",
"""    v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9))   # families never touch
    v = turn_masses(v, lit, buf, crest_band, mass_turn)
    off, thr""",1)
s=s.replace("shadow_mask=None, blades=0.035, plan=None):","shadow_mask=None, blades=0.035, plan=None, crest_band=1.0, mass_turn=1.0):")
open('ground.py','w').write(s)
EOF
grep -n "turn_masses(v, lit" ground.py; sed -n 735,760p ground.py

# output:
738:def turn_masses(v, lit, buf, crest_band=1.0, mass_turn=1.0, cap=None):
            k[a:b, x] = np.arange(b - a); Ln[a:b, x] = b - a
    return k, np.minimum(Ln, cap), Ln

def turn_masses(v, lit, buf, crest_band=1.0, mass_turn=1.0, cap=None):
    """Turn the flat masses into forms, inside each family:
      crest band -- every land unit gets the V16 hummock profile below its crest line
      mass turn  -- a lit pool is hottest in its interior and falls off (dithered) toward its
                    edge (the V16 islet); a shadow is darkest along its edge and lifts inside
                    (reflected light), so no mass is a flat colour field with a noisy edge"""
    H, W = v.shape
    out = v.copy()
    if crest_band > 0:
        cap = cap or max(24, H * 0.35)
        k, Lc, Ln = crest_runs(buf, cap)
        t = np.clip(k / Lc, 0, 1)
        f = crest_profile(t)
        f = np.where(k > Lc, 0.0, f)          # beyond the capped run the form is done
        f = ndi.gaussian_filter(f, (0.6, 2.0))
        amp = crest_band * np.where(lit, 0.9, 1.3)
        out = out + amp * (f - 0.45) * (k <= Lc * 1.2)
    if mass_turn > 0:
        dl = ndi.distance_transform_edt(lit); ds = ndi.distance_transform_edt(~lit)
        D = max(6.0, 0.06 * min(H, W))
        out = out + mass_turn * np.where(lit, 0.8 * (np.clip(dl / D, 0, 1) - 0.5),
                                         0.6 * (np.clip(ds / (1.5 * D), 0, 1) - 0.6))
    return np.where(lit, np.clip(out, 3.0, 5.99), np.clip(out, 0, 2.9))
Bash: Smooth crest run ends; render scenes
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
s=s.replace("""        t = np.clip(k / Lc, 0, 1)
        f = crest_profile(t)
        f = np.where(k > Lc, 0.0, f)          # beyond the capped run the form is done
        f = ndi.gaussian_filter(f, (0.6, 2.0))
        amp = crest_band * np.where(lit, 0.9, 1.3)
        out = out + amp * (f - 0.45) * (k <= Lc * 1.2)""","""        t = np.clip(k / Lc, 0, 1)
        f = crest_profile(t)
        # a run cut short by the next crest ends in the dark base (the hummock meets the one in
        # front); a long run (near ground) just relaxes back to neutral after its band
        open_ = Ln > Lc
        relax = 0.45 + (1 - 0.45) * np.exp(-np.clip(t - 0.25, 0, None) / 0.3)
        f = np.where(open_ & (t > 0.25), relax, f)
        f = ndi.gaussian_filter(f, (0.6, 2.0))
        amp = crest_band * np.where(lit, 0.9, 1.3)
        out = out + amp * (f - 0.45)""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py F 2>&1|tail -1

# output:
wrote F_scenes
Read: elements/ground/sheets/F_scenes.png

elements/ground/sheets/F_scenes.png 102. elements/ground/sheets/F_scenes.png

Bash: Check the turn term's effect on v
cd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,lw,numpy as np
kw=dict(ground='gravel', landform='flat', hour='golden', sun=(80, 8), ppm=30, pitch=14, cam_h=2.0, shade=0.4, shade_scale=10, seed=9, plan='foreground')
a=ground.paint_ground(320,200,crest_band=0,mass_turn=0,**kw); b=ground.paint_ground(320,200,**kw)
f=lambda v:np.stack([(v/6*255).astype(np.uint8)]*3,-1)
lw.save(np.concatenate([f(a.v),f(b.v)],1),'../stages/dbg_turn.png',1)
print(np.abs(a.v-b.v).mean())
"

# output:
0.0
Read: elements/ground/stages/dbg_turn.png

elements/ground/stages/dbg_turn.png 103. elements/ground/stages/dbg_turn.png

Bash: Find where to insert turn call
cd ~/work/pixelart-studies/elements/ground/src && grep -n "families never touch\|headroom\|v = np.where(lit, 3.15" ground.py

# output:
794:    v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9))   # families never touch
803:    # headroom: keep the mass off the ends of its family so marks can go both ways (a mass
805:    v = np.where(lit, 3.15 + (v - 3.0) * 0.78, 0.25 + v * 0.8)
819:    step = np.where(lit, np.maximum(step, 3), np.minimum(step, 2))   # families never touch
Bash: Show context around headroom
cd ~/work/pixelart-studies/elements/ground/src && sed -n 790,808p ground.py

# output:
# repoussoir: bottom corners sink, inside each family
    yy, xx = np.mgrid[0:H, 0:W]
    vg = np.clip((yy / H - 0.55) / 0.45, 0, 1) ** 1.5 * (0.45 + 0.55 * np.abs(xx - W / 2) / (W / 2))
    v = v - vignette * 1.6 * vg
    v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9))   # families never touch
    phi = buf['z'].copy()
    if path:
        pd = land.path_dist(buf['x'], buf['z'])
        phi = np.where(pd < 2.0, pd * 40, phi)
        # the path's mass: a packed, lighter crown; grass-shaded margins drop a step
        onp = (mat == MI['earth']) & (pd < 1.2)
        v = np.where(onp, v + 0.5 * np.exp(-(pd / 0.35) ** 2) - 0.9 * np.clip((pd - 0.45) / 0.3, 0, 1), v)
        v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9))
    # headroom: keep the mass off the ends of its family so marks can go both ways (a mass
    # pinned at the top step goes flat -- the flat-tone failure)
    v = np.where(lit, 3.15 + (v - 3.0) * 0.78, 0.25 + v * 0.8)
    off, thr, coh, gap, strk = marks(buf, mat, v, lit, rng, contrast, phi)
    if hour == 'dusk':                 # low light: texture sinks, masses carry the picture (V13)
        off = off * 0.45; strk &= rng.random(strk.shape) < 0.35
Bash: Actually insert turn call; compare
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a="""    v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9))   # families never touch
    phi = buf['z'].copy()"""
assert a in s
s=s.replace(a,"""    v = np.where(lit, np.maximum(v, 3.0), np.minimum(v, 2.9))   # families never touch
    v = turn_masses(v, lit, buf, crest_band, mass_turn)            # masses become forms
    phi = buf['z'].copy()""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,lw,numpy as np
kw=dict(ground='gravel', landform='flat', hour='golden', sun=(80, 8), ppm=30, pitch=14, cam_h=2.0, shade=0.4, shade_scale=10, seed=9, plan='foreground')
a=ground.paint_ground(320,200,crest_band=0,mass_turn=0,**kw); b=ground.paint_ground(320,200,**kw)
f=lambda v:np.stack([(v/6*255).astype(np.uint8)]*3,-1)
lw.save(np.concatenate([f(a.v),f(b.v)],1),'../stages/dbg_turn.png',1)
lw.save(np.concatenate([a.rgb,b.rgb],1),'../stages/dbg_turn_rgb.png',2)
"

# output:
(Bash completed with no output)
Read: elements/ground/stages/dbg_turn_rgb.png

elements/ground/stages/dbg_turn_rgb.png 104. elements/ground/stages/dbg_turn_rgb.png

Bash: Shadow family value distribution
cd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
kw=dict(ground='gravel', landform='flat', hour='golden', sun=(80, 8), ppm=30, pitch=14, cam_h=2.0, shade=0.4, shade_scale=10, seed=9, plan='foreground')
a=ground.paint_ground(320,200,crest_band=0,mass_turn=0,**kw)
m=~a.lit&~a.buf['sky']; print(np.percentile(a.v[m],[5,25,50,75,95]))
m=a.lit; print(np.percentile(a.v[m],[5,25,50,75,95]))"

# output:
[1.60567487 1.7248309  1.81313887 1.87761914 1.95426293]
[3.45794215 4.0081161  4.54700021 5.05158216 5.05158216]
Bash: Stronger mass turns; rerender scenes
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a="""        out = out + mass_turn * np.where(lit, 0.8 * (np.clip(dl / D, 0, 1) - 0.5),
                                         0.6 * (np.clip(ds / (1.5 * D), 0, 1) - 0.6))"""
assert a in s
s=s.replace(a,"""        # lit: the islet recipe, a hot interior and a dithered falloff to the edge.
        # shadow: a dark core just inside the terminator (Ferrari's accent where shade meets
        # light), then reflected light lifting the broad interior
        out = out + mass_turn * np.where(lit, 1.0 * (np.clip(dl / D, 0, 1) - 0.55),
                                         -0.8 * np.exp(-ds / 3.0) + 0.7 * (np.clip(ds / (2.5 * D), 0, 1) - 0.4))""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py F 2>&1|tail -1

# output:
wrote F_scenes
Read: elements/ground/sheets/F_scenes.png

elements/ground/sheets/F_scenes.png 105. elements/ground/sheets/F_scenes.png

Bash: Hummocks landform with/without crest band vs V16
cd ~/work/pixelart-studies/elements/ground/src && cat > s10_hum.py <<'EOF'
"""Stage 4: V16's mid-ground hummocks, in my own ground, with and without the crest band."""
import ground, lw, numpy as np
ref = lw.rgb(lw.load('V16'))[215:295, 330:640]
kw = dict(ground='turf', landform='hummocks', hour='noon', sun=(60, 30), ppm=14, pitch=6, cam_h=2.2, relief=1.6, seed=4)
a = ground.paint_ground(310, 160, crest_band=0, mass_turn=0, **kw).rgb[50:130]
b = ground.paint_ground(310, 160, **kw).rgb[50:130]
p = np.zeros((3, 310, 3), np.uint8)
lw.save(np.concatenate([ref, p, a, p, b], 0), '../stages/s10_hummocks_4x.png', 4)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s10_hummocks_4x.png

elements/ground/stages/s10_hummocks_4x.png 106. elements/ground/stages/s10_hummocks_4x.png

Bash: Hummocks in shadow family
cd ~/work/pixelart-studies/elements/ground/src && sed -i '' "s/sun=(60, 30), ppm=14, pitch=6, cam_h=2.2, relief=1.6, seed=4)/sun=(80, -2), ppm=14, pitch=4, cam_h=1.5, relief=2.5, seed=4)/" s10_hum.py && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s10_hummocks_4x.png

elements/ground/stages/s10_hummocks_4x.png 107. elements/ground/stages/s10_hummocks_4x.png

Bash: Distinct lozenge hummocks + convexity skylight
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a="""        if kind in ('hummocks',):
            for _ in range(60):
                self.hum.append((r.uniform(-60, 60), r.uniform(3, 140), r.uniform(3, 9), r.uniform(1.5, 3.5),
                                 r.uniform(0.4, 1.3), r.uniform(-0.5, 0.5)))"""
assert a in s
s=s.replace(a,"""        if kind in ('hummocks',):
            # V16's far bank: separate lozenges, wide and flat-bottomed, long axis across the
            # view, with gaps of flat ground between them (not an overlapping bumpy mush)
            tries = 0
            while len(self.hum) < 40 and tries < 2000:
                tries += 1
                cz = 8 * (140 / 8) ** r.random(); cx = r.uniform(-0.8, 0.8) * (10 + cz * 0.7)
                ax = r.uniform(3, 10) * (1 + cz / 80); az = ax * r.uniform(0.3, 0.55)
                if any(np.hypot((cx - q[0]) / (ax + q[2]), (cz - q[1]) / (az + q[3])) < 0.8 for q in self.hum):
                    continue
                self.hum.append((cx, cz, ax, az, r.uniform(0.7, 1.6) * (1 + cz / 100), r.uniform(-0.25, 0.25)))""")
s=s.replace("amp = {'flat': 0.25, 'rolling': 1.6, 'hummocks': 0.5,","amp = {'flat': 0.25, 'rolling': 1.6, 'hummocks': 0.12,")
s=s.replace("                H = H + ht * self.relief * np.clip(1 - q, 0, None) ** 1.5","                H = H + ht * self.relief * np.clip(1 - q, 0, None) ** 0.6   # domed top, steep flanks")
# convexity term in turn_masses
a="""    if mass_turn > 0:
        dl = ndi.distance_transform_edt(lit)"""
s=s.replace(a,"""    if crest_band > 0 and 'convex' in buf:
        # skylight on convex land: mound tops take the sky, the feet sink (V16 hummock bodies)
        out = out + crest_band * np.where(lit, 0.5, 0.9) * np.tanh(2.5 * buf['convex'])
""" + a)
s=s.replace("""    buf['n_macro'] = land.normal(X, Zs, e=0.8, micro=False)""","""    buf['n_macro'] = land.normal(X, Zs, e=0.8, micro=False)
    hm = land.h(X, Zs, micro=False)
    ring = np.mean([land.h(X + 3 * np.cos(a), Zs + 3 * np.sin(a), micro=False) for a in np.linspace(0, 6.28, 8, endpoint=False)], 0)
    buf['convex'] = np.where(sky, 0.0, (hm - ring) / 0.6)""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s10_hummocks_4x.png

elements/ground/stages/s10_hummocks_4x.png 108. elements/ground/stages/s10_hummocks_4x.png

Bash: Keep hummocks turf; pull camera back
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a="        mat[slope > 0.22] = MI['earth']          # bare risers and cut banks"
assert a in s
s=s.replace(a,"        if land.kind in ('ledges', 'rolling'):\n            mat[slope > 0.22] = MI['earth']      # bare risers and cut banks (turf hummocks stay turf)")
open('ground.py','w').write(s)
EOF
sed -i '' "s/ppm=14, pitch=4, cam_h=1.5, relief=2.5, seed=4)/ppm=8, pitch=5, cam_h=3.0, relief=1.5, seed=4)/" s10_hum.py
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s10_hummocks_4x.png

elements/ground/stages/s10_hummocks_4x.png 109. elements/ground/stages/s10_hummocks_4x.png

Bash: Regenerate everything
cd ~/work/pixelart-studies/elements/ground/src && (OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 compare.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s6_driftwood.py) 2>&1 | tail -2

# output:
wrote E_seeds
wrote F_scenes
Read: elements/ground/sheets/B_landform_x_sun.png

elements/ground/sheets/B_landform_x_sun.png 110. elements/ground/sheets/B_landform_x_sun.png

Bash: Add stage 4 to LESSONS; clean debug
cd ~/work/pixelart-studies/elements/ground && rm -f stages/dbg_* && python3 - <<'EOF'
p='LESSONS.md'
s=open(p).read()
add='''
## Stage 4: turning the masses (the V16 hummocks and the "construction paper" problem)

The coordinator's note on F_scenes was right: at 1× some masses, like the forest pool and the gravel-bar shadow, read as flat colour fields with a noisy edge. That is the construction-paper look. Ferrari's masses are *forms*: each one turns inside its family.

**Stage 0b copy** (`src/s9_hummock.py` → `stages/s9_hummocks_5x.png`). I measured the value profile below each crest line on V16's far hummocks. There is a thin darker accent on the crest, the lit band sits about 25% of the way down, and the value falls off to a dark base where the hummock meets the one in front. The checker is almost entirely between two classes, 69/70, which is a jump from L\* 6 to L\* 25. The middle step 72 is barely used, so the band is a *two-value* checker with a big gap. I used his crest lines (found as upward steps in his squinted value) and filled with my profile plus the 2×2 ordered dither on a 3-step ramp. The vertical rhythm matches. **What's missing:** his big hummock also turns *horizontally*, lit on its upper right and dark on its left flank. A 1-D profile can't do that; in my own ground it has to come from the geometry.

**Into the engine** (`turn_masses` in ground.py, parameters `crest_band` and `mass_turn`):

- **Crest band**: every land unit below an occlusion crest or the skyline gets the measured profile. A run cut short by the next crest ends in the dark base, and a long open run relaxes back to neutral after its band.
- **Convexity skylight**: mound tops take the sky and the feet sink. This gives the horizontal turn the copy lacked, from real geometry.
- **Mass turn**: a lit pool is hottest in its interior and falls off to its edge through dither (the islet). A shadow has a dark core just inside the terminator and lifts in its broad interior (reflected light).
- **The hummocks landform was rebuilt** as separate, flat-bottomed lozenges with the long axis across the view and flat ground between them. It was an overlapping bumpy mush that made no occlusion crests. Turf hummocks no longer turn into bare earth on their steep flanks.

`stages/s10_hummocks_4x.png` shows V16 on top, then mine without the new terms, then mine with them. The bottom row now reads as hummocks: lit bands under crests, dark feet, layered. See also the hummocks row of `sheets/B_landform_x_sun.png`, where the contre-jour cell rim-lights the crests.

**Where it stands:**

- The band's *value structure* is right.
- The texture is not. My band is a near-flat pale teal where his is a lively L6/L25 checker. The fix is a two-class ramp for the far shadow band, skipping the middle step as he does.
- The forest pool and gravel shadow now have a hot interior and a dark core edge. They are better, but the forest pool is still the wrong *shape* (it should be a ribbon, as in V09).
- I haven't re-checked these at 8× against V16.
'''
s=s.replace("## Honest comparison with Ferrari", add + "\n## Honest comparison with Ferrari")
s=s.replace("| V16 hummocks vs my rolling noon | **not there** |","| V16 hummocks vs my rolling noon | **not there (see stage 4 for the hummocks landform, now closer)** |")
s=s.replace("1. **Sculpted mid-ground forms.**","1. **Sculpted mid-ground forms** (partly addressed in stage 4).")
open(p,'w').write(s)
EOF
grep -n "Stage 4" LESSONS.md

# output:
<stdin>:3: SyntaxWarning: invalid escape sequence '\*'
62:## Stage 4: turning the masses (the V16 hummocks and the "construction paper" problem)
SubagentHandback:

I've done the follow-up the coordinator asked for and sent the report. Mid-ground hummocks now read as forms: each has a lit band under its crest and dark feet. The band's surface still isn't Ferrari's, and I haven't checked any of it at 8×.

  • Copying his hummocks: In V16 the light sits in a band about a quarter of the way below each crest, with a thin darker line on the crest itself. Below the band it fades to a dark base where the hummock meets the one in front. His band flickers between just two colours with a big jump between them (L* 6 and 25). Filling his own hummock outlines with that profile matches his vertical rhythm. It can't reproduce the sideways turn on his big hummock, which is lit on the upper right and dark on the left flank.
  • In my ground painter: Every stretch of land below a crest or the skyline now gets the measured band. Mound tops also catch more sky, and that gives the sideways turn from the actual shape of the land.
  • The flat-mass problem the coordinator raised: Lit pools are now hottest inside and dither down toward their edges. Shadows get a darker line just inside their edge and lighten toward the middle. The gravel-bar light and shadow show this now.
  • Hummock landform rebuilt: Hummocks are now separate flat-bottomed lozenges with ground between them. Before, they overlapped into one bumpy mass with no crests to light.

stages/s10_hummocks_4x.png shows V16, then my ground without the new band, then with it. The last row is the one that reads as hummocks.

What's still wrong:

  • Band surface: Mine is a nearly flat pale teal where his is a lively two-colour checker. The fix is to use only his two colours in the far shadow band and skip the middle one.
  • Forest pool: It is still the wrong shape. It should be a narrow ribbon of light along a path, as in V09, not a broad pool.

All sheets, the Ferrari comparison and the driftwood-on-ground sheet are regenerated. LESSONS.md has a new stage 4 section. Everything is in ~/work/pixelart-studies/elements/ground/.

Read: voxsim-wt/paint-near/renders/near/st8/dusk.png

voxsim-wt/paint-near/renders/near/st8/dusk.png 111. voxsim-wt/paint-near/renders/near/st8/dusk.png

Bash: Measure my hummock band step shares
cd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
kw=dict(ground='turf', landform='hummocks', hour='noon', sun=(80, -2), ppm=8, pitch=5, cam_h=3.0, relief=1.5, seed=4)
g=ground.paint_ground(310,160,**kw)
s=g.step[50:130]; m=g.mat[50:130]>=0
print('mine shares',np.bincount(s[m],minlength=6)/m.sum())
print('v pct',np.percentile(g.v[50:130][m],[5,25,50,75,95]))
print('ramp',ground.ramp('turf','noon',1)[:3].tolist())
"

# output:
mine shares [0.05296182 0.11661507 0.83042312 0.         0.         0.        ]
v pct [0.45645466 2.01837467 2.33316605 2.56176414 2.57      ]
ramp [[25, 32, 33], [25, 39, 34], [31, 62, 46]]
Bash: Two-class far band: gentler dark lift, lower shadow key
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a="    w = f * 2.0 * np.linspace(1.0, 0.1, 6)[:, None]"
assert a in s
s=s.replace(a,"""    # the lift is gentle on the darks: V16's far hummocks still hold L* 2 in their bases, and a
    # lifted black point merges steps 0 and 1 into one flat colour (the pale-teal band failure)
    w = f * np.array([0.6, 0.7, 0.9, 1.4, 1.1, 0.6])[:, None]""")
a="    v = np.where(lit, 3.15 + (v - 3.0) * 0.78, 0.25 + v * 0.8)"
assert a in s
s=s.replace(a,"""    # the shadow family sits so that its body is the 1/2 checker and only crest bands reach a
    # solid step 2 (V16 hummocks: L6/L25 checker ~70% of the mass, solid lights rare)
    v = np.where(lit, 3.15 + (v - 3.0) * 0.78, 0.2 + v * shadow_key)""")
s=s.replace("plan=None, crest_band=1.0, mass_turn=1.0):","plan=None, crest_band=1.0, mass_turn=1.0,\n                 shadow_key=0.66):")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
kw=dict(ground='turf', landform='hummocks', hour='noon', sun=(80, -2), ppm=8, pitch=5, cam_h=3.0, relief=1.5, seed=4)
g=ground.paint_ground(310,160,**kw)
s=g.step[50:130]; m=g.mat[50:130]>=0
print('mine shares',np.bincount(s[m],minlength=6)/m.sum())
print('ramp',ground.ramp('turf','noon',1)[:3].tolist())
" && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py

# output:
mine shares [0.06158927 0.26439628 0.67401445 0.         0.         0.        ]
ramp [[11, 21, 20], [17, 35, 26], [29, 61, 45]]
Read: elements/ground/stages/s10_hummocks_4x.png

elements/ground/stages/s10_hummocks_4x.png 112. elements/ground/stages/s10_hummocks_4x.png

Bash: Quantile-keyed shadow family
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a="""    v = np.where(lit, 3.15 + (v - 3.0) * 0.78, 0.2 + v * shadow_key)"""
s=s.replace(a,"""    v = np.where(lit, 3.15 + (v - 3.0) * 0.78, v)
    sm = (~lit) & (mat >= 0) & (mat != MI['water'])
    if sm.sum() > 50:
        # key the shadow family by its own quantiles (a value plan, not a lighting formula):
        # the body of the shadow is the 1/2 checker, the crest bands reach solid 2, and only
        # the feet and cores reach 0 -- V16 hummocks: L2 15% / L6 35% / L25 38%
        q = np.percentile(v[sm], [3, 50, 97])
        tgt = np.array([0.35, 1.0 + shadow_key, 2.45])
        v = np.where(sm, np.interp(v, q, tgt, left=0.2, right=2.6), v)""")
s=s.replace("shadow_key=0.66):","shadow_key=0.55):")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
kw=dict(ground='turf', landform='hummocks', hour='noon', sun=(80, -2), ppm=8, pitch=5, cam_h=3.0, relief=1.5, seed=4)
g=ground.paint_ground(310,160,**kw)
s=g.step[50:130]; m=g.mat[50:130]>=0
print('mine shares',np.bincount(s[m],minlength=6)/m.sum())" && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py

# output:
mine shares [0.06431373 0.14662539 0.78906089 0.         0.         0.        ]
Read: elements/ground/stages/s10_hummocks_4x.png

elements/ground/stages/s10_hummocks_4x.png 113. elements/ground/stages/s10_hummocks_4x.png

Bash: Debug shadow v and vv distributions
cd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
kw=dict(ground='turf', landform='hummocks', hour='noon', sun=(80, -2), ppm=8, pitch=5, cam_h=3.0, relief=1.5, seed=4)
g=ground.paint_ground(310,160,**kw)
m=(g.mat>=0)
print('v',np.percentile(g.v[m],[3,25,50,75,97]).round(2)); print('vv',np.percentile(g.vv[m],[3,25,50,75,97]).round(2)); print('lit frac',g.lit[m].mean())"

# output:
v [0.2  1.51 2.01 2.35 2.45]
vv [0.24 1.38 1.93 2.41 2.99]
lit frac 0.0
Bash: Check keying code placement
cd ~/work/pixelart-studies/elements/ground/src && grep -n "shadow_key\|tgt = \|np.interp(v, q" ground.py; grep -n "v = v - 0.9 \* wet\|dusk.*off = off" ground.py

# output:
790:                 shadow_key=0.55):
835:        tgt = np.array([0.35, 1.0 + shadow_key, 2.45])
836:        v = np.where(sm, np.interp(v, q, tgt, left=0.2, right=2.6), v)
809:    v = v - 0.9 * wet * (v > 0.5)
Bash: Exclude sky from shadow keying
cd ~/work/pixelart-studies/elements/ground/src && sed -i '' "s/    sm = (~lit) \& (mat >= 0) \& (mat != MI\['water'\])/    sm = (~lit) \& (mat >= 0) \& (mat != MI['water']) \& ~buf['sky']/" ground.py && grep -n "sm = (~lit)" ground.py && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
kw=dict(ground='turf', landform='hummocks', hour='noon', sun=(80, -2), ppm=8, pitch=5, cam_h=3.0, relief=1.5, seed=4)
g=ground.paint_ground(310,160,**kw)
s=g.step[50:130]; m=g.mat[50:130]>=0
print('mine shares',np.bincount(s[m],minlength=6)/m.sum())" && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py

# output:
829:    sm = (~lit) & (mat >= 0) & (mat != MI['water']) & ~buf['sky']
mine shares [0.06410733 0.24301342 0.69287926 0.         0.         0.        ]
Read: elements/ground/stages/s10_hummocks_4x.png

elements/ground/stages/s10_hummocks_4x.png 114. elements/ground/stages/s10_hummocks_4x.png

Bash: Debug v vs step shares
cd ~/work/pixelart-studies/elements/ground/src && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
kw=dict(ground='turf', landform='hummocks', hour='noon', sun=(80, -2), ppm=8, pitch=5, cam_h=3.0, relief=1.5, seed=4)
g=ground.paint_ground(310,160,**kw)
m=(g.mat>=0)&~g.buf['sky']
print('v',np.percentile(g.v[m],[3,25,50,75,97]).round(2)); print('vv',np.percentile(g.vv[m],[3,25,50,75,97]).round(2))
print('steps all',np.bincount(g.step[m],minlength=6)/m.sum())"; grep -n "g.__dict__.update(mat=mat" ground.py; grep -n "    v = np.where(lit, np.clip(v, 3.0, 5.99)\|turn_masses(v, lit, buf, crest_band, mass_turn)" ground.py

# output:
v [0.33 1.23 1.55 2.25 2.45]
vv [0.24 1.1  1.68 2.24 2.99]
steps all [0.07615607 0.29901185 0.62483207 0.         0.         0.        ]
878:    g.__dict__.update(mat=mat, band=band, step=step, v=v, vv=vv, lit=lit, wet=wet, buf=buf, L=L,
815:    v = turn_masses(v, lit, buf, crest_band, mass_turn)            # masses become forms
Bash: Lower shadow key target
cd ~/work/pixelart-studies/elements/ground/src && sed -i '' 's/        tgt = np.array(\[0.35, 1.0 + shadow_key, 2.45\])/        tgt = np.array([0.15, 1.0 + shadow_key, 2.3])/; s/shadow_key=0.55):/shadow_key=0.3):/' ground.py && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 -c "
import ground,numpy as np
kw=dict(ground='turf', landform='hummocks', hour='noon', sun=(80, -2), ppm=8, pitch=5, cam_h=3.0, relief=1.5, seed=4)
g=ground.paint_ground(310,160,**kw)
m=(g.mat>=0)&~g.buf['sky']
print('steps all',np.bincount(g.step[m],minlength=6)/m.sum())" && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py

# output:
steps all [0.12681733 0.3121175  0.56106517 0.         0.         0.        ]
Read: elements/ground/stages/s10_hummocks_4x.png

elements/ground/stages/s10_hummocks_4x.png 115. elements/ground/stages/s10_hummocks_4x.png

Bash: Wider gap between shadow steps 1 and 2
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a="    'turf':   (4, 12, 28, 36, 50, 66),"
assert a in s
s=s.replace(a,"    'turf':   (3, 8, 29, 36, 50, 66),")
a="        Ls[:3] = Ls[:3] * 0.8           # under skylight grass is darker than bare soil (self-occlusion)"
assert a in s
s=s.replace(a,"        Ls[:2] = Ls[:2] * 0.8           # under skylight grass is darker than bare soil (self-occlusion)\n        # (step 2 is kept high: the shadow band is a two-value checker with a big gap, L6/L25)")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s10_hum.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s10_hummocks_4x.png

elements/ground/stages/s10_hummocks_4x.png 116. elements/ground/stages/s10_hummocks_4x.png

Bash: High-angle meadow with/without land-following terms
cd ~/work/pixelart-studies/elements/ground/src && cat > s11_sweeps.py <<'EOF'
"""For the voxsim near-paint lane: a high-angle meadow (their camera), golden hour, long tree
shadows. Left: masses from cast shadow only. Right: + the land-following terms
(convexity skylight, sky-facing turn, crest band, mass turn, quantile keying)."""
import ground, lw, numpy as np
from PIL import Image, ImageDraw
kw = dict(ground='turf', landform='rolling', hour='golden', sun=(-35, 16), ppm=10, pitch=40, cam_h=40,
          relief=2.2, shade=0.3, shade_scale=3, seed=6, blades=0)
rows = []
for sd in (6, 11):
    a = ground.paint_ground(300, 180, crest_band=0, mass_turn=0, **{**kw, 'seed': sd}).rgb
    b = ground.paint_ground(300, 180, **{**kw, 'seed': sd}).rgb
    rows.append(np.concatenate([a, np.zeros((180, 4, 3), np.uint8), b], 1))
img = np.concatenate([rows[0], np.zeros((4, rows[0].shape[1], 3), np.uint8), rows[1]], 0)
lw.save(img, '../stages/s11_sweeps_3x.png', 3)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s11_sweeps.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s11_sweeps_3x.png

elements/ground/stages/s11_sweeps_3x.png 117. elements/ground/stages/s11_sweeps_3x.png

Bash: Retry sweeps with moderate camera
cd ~/work/pixelart-studies/elements/ground/src && sed -i '' "s/ppm=10, pitch=40, cam_h=40,/ppm=18, pitch=38, cam_h=9, moisture=0.0,/; s/relief=2.2, shade=0.3, shade_scale=3,/relief=1.0, shade=0.3, shade_scale=5,/" s11_sweeps.py && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s11_sweeps.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s11_sweeps_3x.png

elements/ground/stages/s11_sweeps_3x.png 118. elements/ground/stages/s11_sweeps_3x.png

Bash: Lit family keying and exaggerated turn
cd ~/work/pixelart-studies/elements/ground/src && python3 - <<'EOF'
s=open('ground.py').read()
a="""    sm = (~lit) & (mat >= 0) & (mat != MI['water']) & ~buf['sky']"""
s=s.replace(a,"""    lm = lit & (mat >= 0) & (mat != MI['water']) & ~buf['sky']
    if lm.sum() > 50 and lit_key > 0:
        # key the light family the same way: whatever turning the land gives (N.L, skylight,
        # crest bands, convexity) is stretched to fill 3..5.9, so a lit meadow shows its
        # swells as sweeps of halftone and top light instead of one flat ochre
        q = np.percentile(v[lm], [3, 50, 97])
        if q[2] - q[0] > 1e-3:
            tgt = np.array([3.05, 4.35, 5.75])
            vk = np.interp(v, q, tgt, left=3.0, right=5.9)
            v = np.where(lm, (1 - lit_key) * v + lit_key * vk, v)
""" + a)
s=s.replace("                 shadow_key=0.3):","                 shadow_key=0.3, lit_key=0.8):")
# stronger in-family N.L for lit, exaggerated relative to flat
a="    v_lit = 3.0 + 3.0 * np.clip(lit_base + 1.0 * (r - 1), 0, 0.999)"
assert a in s
s=s.replace(a,"""    # exaggerated like the skylight: Ferrari turns land more than nature (tanh around flat)
    v_lit = 3.0 + 3.0 * np.clip(lit_base + 0.45 * np.tanh(2.5 * (r - 1)), 0, 0.999)
    # skylight also turns the lit family a little (faces toward the bright sky read warmer-lighter)
    v_lit = v_lit + 0.5 * (L['sky'] - 0.55)""")
open('ground.py','w').write(s)
EOF
OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s11_sweeps.py

# output:
(Bash completed with no output)
Read: elements/ground/stages/s11_sweeps_3x.png

elements/ground/stages/s11_sweeps_3x.png 119. elements/ground/stages/s11_sweeps_3x.png

Bash: Fair off/on comparison with more relief
cd ~/work/pixelart-studies/elements/ground/src && sed -i '' "s/crest_band=0, mass_turn=0, \*\*{\*\*kw/crest_band=0, mass_turn=0, lit_key=0, shadow_key=0.3, **{**kw/; s/relief=1.0, shade=0.3, shade_scale=5,/relief=1.8, shade=0.3, shade_scale=5,/" s11_sweeps.py && grep -n lit_key s11_sweeps.py && OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s11_sweeps.py && python3 -c "import ground; print(ground.ramp('turf','golden').tolist())"

# output:
10:    a = ground.paint_ground(300, 180, crest_band=0, mass_turn=0, lit_key=0, shadow_key=0.3, **{**kw, 'seed': sd}).rgb
[[1, 10, 10], [8, 23, 16], [38, 75, 59], [81, 79, 37], [127, 107, 36], [186, 137, 45]]
Read: elements/ground/stages/s11_sweeps_3x.png

elements/ground/stages/s11_sweeps_3x.png 120. elements/ground/stages/s11_sweeps_3x.png

Bash: Regenerate sheets after keying changes
cd ~/work/pixelart-studies/elements/ground/src && (OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 nice -n 10 python3 sheets.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 compare.py; OPENBLAS_NUM_THREADS=1 nice -n 10 python3 s6_driftwood.py) 2>&1 | tail -1

# output:
wrote F_scenes
Read: elements/ground/sheets/F_scenes.png

elements/ground/sheets/F_scenes.png 121. elements/ground/sheets/F_scenes.png

Bash: Add stage 5 and voxsim note to LESSONS
cd ~/work/pixelart-studies/elements/ground && python3 - <<'EOF'
p='LESSONS.md'
s=open(p).read()
add=r'''
## Stage 5: the two-value far band, and keying each family (+ a note for voxsim's near-paint lane)

**The pale-teal band, diagnosed by measurement.** On the hummock test my shadow was 83% step 2, while his is roughly 15% L2, 47% L6 and 38% L25. There were three causes:

1. The far-band haze lifted the black point so far that steps 0 and 1 became nearly the same colour.
2. Skylight on flat ground put the shadow body right at the top of its family.
3. My grass shadow ramp had a small gap between steps 1 and 2. His has a big one: L6 to L25.

Fixes:

- The haze lift is now gentle on the darks.
- The turf shadow ramp is L\* 3/8/29, with self-occlusion darkening applied to steps 0 and 1 only.
- **Each family is keyed by its own quantiles.** The shadow's 3rd, 50th and 97th percentiles map to steps 0.15, 1.3 and 2.3. The result is 13/31/56% against his 15/47/38%. `stages/s10_hummocks_4x.png` now reads as V16's mechanism: bright crest bands in a big-gap checker over dark feet.

This is a value plan rather than a lighting formula: whatever turning the land and light give, the family is stretched to fill its designed range.

**Sweeps in a lit meadow** (prompted by voxsim `renders/near/st8/dusk.png`, whose sward has lovely long tree shadows but a flat lit field). My lit family had the same flaw. Grass takes a weak N·L, so a sunny meadow on gentle swells came out as one ochre. Fixes:

- The lit turn is now exaggerated around flat, as the skylight is: `0.45·tanh(2.5·(N·L/sun_y − 1))`.
- Lit faces toward the bright sky get a little lift.
- The lit family is quantile-keyed too (`lit_key`, default 0.8): 3rd/50th/97th percentiles map to 3.05/4.35/5.75.

`stages/s11_sweeps_3x.png` uses a high camera like theirs, golden hour, with off-frame tree shadows. The left column has none of the new terms; the right has all of them. On the right, halftone swales and lit rises sweep across the meadow following the land, and the shadows turn with the skylight.

Honest note: the effect is real but modest where the land is gentle (bottom row). At 8× I also see a ruled edge where a sharp ridge crosses a shadow (top right, the skylight tanh), which needs softening.

### For voxsim's near-paint lane (the terms, in world space, iso-safe)

All of these are per-pixel values computed from the height field. Apply them *before* marks, inside each family, then quantize with ordered 2×2 plus jitter.

1. **convex** `= tanh(2.5·(h − mean(h on a 3 m ring)) / 0.6)`. Add about 0.9 step in shadow and 0.5 in light. Mound tops take the sky, feet sink. Scale the ring to the land's feature size.
2. **skylight turn**: `sky = 0.55 + 0.45·tanh(6·(n·s_sky − s_sky.y))`, where `s_sky` is up tilted about 40° toward the sun's azimuth. This is the whole form language of the shadow family, and at dusk the only one.
3. **lit turn**: `0.45·tanh(2.5·(n·sun / sun.y − 1))` steps, even for grass. The exaggeration is the point.
4. **crest band** (screen space, below occlusion edges and skylines): the V16 profile. There's a darker accent on the crest, the band peaks 25% of the way down, and it falls to a dark base where the next unit occludes. In iso, every place the land hides land behind it is such an edge.
5. **mass turn**: lit pools are hot inside and dither down about 6% of the frame toward their edge. Shadows get a dark core line about 3 px wide just inside the terminator, then lift inside.
6. **key each family by quantiles** so whatever of 1–5 exists fills the family's range. Target shares near Ferrari's: shadow about 15/45/40 across its three steps; light centred on the middle step with about 10% top light.

Code: `turn_masses`, `crest_profile`, and the keying block in `paint_ground` (search "key the").
'''
s=s.replace("\n## Honest comparison with Ferrari", add + "\n## Honest comparison with Ferrari",1)
open(p,'w').write(s)
EOF
grep -n "^## " LESSONS.md

# output:
16:## Stage 0: what Ferrari's ground is made of (read from his index maps)
27:## Stage 1: masses from landform and light (the core of the technique)
46:## Stage 2: texture inside the masses, by material
57:## Stage 3: distance
62:## Stage 4: turning the masses (the V16 hummocks and the "construction paper" problem)
82:## Stage 5: the two-value far band, and keying each family (+ a note for voxsim's near-paint lane)
118:## Honest comparison with Ferrari (`stages/compare_ferrari_4x.png`, 4×)
132:## Using this ground (for cobbles, driftwood, willow, channel)
165:## Requests
SubagentHandback:

The two-colour far band is in and I've sent the report. The flat lit meadow in the voxsim frame is improved too, but only a little where the land is gentle. I haven't checked any of this at 8×.

  • Far band: My distant hummock shadows were 83% one colour. Ferrari's use about 15% near-black, 47% dark and 38% bright. I found three causes:
  • Distance haze was washing the darkest steps into one colour.
  • Skylight pushed the whole shadow to the top of its range.
  • My grass shadow colours had too small a gap between the upper two steps.

The main fix is that each value family (lit and shadow) is now scaled by its own spread. Whatever shape the land gives then fills the whole range instead of piling into one colour. My shares are now 13 / 31 / 56% against his 15 / 47 / 38%. stages/s10_hummocks_4x.png now works like V16: bright bands under each crest, checkered against dark feet. - Lit meadow sweeps: My own lit meadow had the same flaw as the voxsim render. Grass barely responds to slope, so gentle swells came out one flat ochre. I exaggerated the lit side's response to slope and scaled the lit family the same way. stages/s11_sweeps_3x.png uses a high camera like theirs, before on the left and after on the right. The after column shows darker hollows and lit rises sweeping across along the land. Two limits: - On gentle land (bottom row) the effect is small. - A sharp ridge crossing a shadow leaves a hard straight edge (top right) that needs softening. - For the renderer: I can only write in my own folder, so I changed nothing in the voxsim worktree. The Stage 5 section of LESSONS.md ends with a self-contained note for the near-paint lane. It lists six terms computed from the height field, each with its formula and pointers into my code. - Still open: The forest pool of light is still the wrong shape. It should be a narrow ribbon along a path, as in Ferrari's V09.

All the sheets, the Ferrari comparison and the driftwood-on-ground sheet are regenerated. The noon hill and dusk terraces in sheets/F_scenes.png now show more form inside their shadows. Everything is in ~/work/pixelart-studies/elements/ground/.