Paint the real mountain first by The snow-peak student

The working thread

Saturday 26 September 2026, 12:35 (PDT), for 3 h 54 min. 311 steps, 109 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

Your full brief is in the file ~/work/pixelart-studies/manager/briefs/study_snowpeak.txt. Read it first and follow it.

The thread

Bash: Read the study brief
cat ~/work/pixelart-studies/manager/briefs/study_snowpeak.txt

# output:
You're a student learning one subject deeply enough to both understand it and draw it beautifully in painterly pixel art: **snowy mountains at distance: snowfields, couloirs, cornices and glacier over rock, seen from miles away**.

voxsim has never had a convincing snowy mountain. The five real places see them constantly:

- Mount Stuart and Dragontail over the Enchantments;
- Rainier and Baker on clear horizons;
- the Olympics behind the coast;
- Glacier Peak from the Skykomish.

The work: how snow really lies on steep rock (it sheds from steep faces and collects in gullies, on ledges and in bowls), seasonal snowline and glaciers, cornices and wind scour, and how a snow peak reads in value and colour from 5, 20 and 80 km through haze. Then draw it, matching real photos of named peaks.

**Two halves, done together.**

- **Observe how it works.** Study the real thing from real sources: photos, video stills, field guides, scientific and historical literature. Find out what it is, why it looks the way it does, how it changes with season, weather and time, and how it behaves.
- **Translate it into pixels.** Find a method that draws it at every scale voxsim shows: close scene (~0.1 m/px), landscape, 30° iso and region (~1 m/px). Draw from what you observed.

The observation should be good enough that a simulation could use it, and the drawing good enough to sit beside the photos.

**The ambition, stated plainly:** match the reference: first its form (the real silhouettes, where the big lights and darks fall, what sits where), then its finer detail. Don't settle for "approximate it with reasonable effort". This is a safe space to fail many times on the way.

**Ways in that have worked here**, yours to use:

- **Break it down and solve bottom-up.** One crop, one form, one mass at a time, each right against the photo before combining.
- **Measure deeply.** Downsample the photo to your target resolution and treat it as an answer key. Extract its notan (2–3 value groups), value histogram, edge and orientation maps. Diff your work against it. Build any small tools that help you see. Numbers are instruments, not judges: the willow student's best-scoring version was its worst foliage.
- **Call critics.** You have the Agent tool: spawn a fresh critic sub-agent whenever you want cold eyes on a photo | yours pair. Give it just the images and ask what's wrong, most important first. Do it often.

**Photos are the standard.**

- Close-read real photos at every distance: ground-level, oblique aerial (Bing Maps bird's-eye, Google Earth 3D, WA Ecology's shoreline obliques; view for study, don't bulk-scrape) and top-down.
- `python3 ~/work/pixelart-studies/tools/imgfetch.py "<query>" -n 12 -o <dir> --sheet` fetches openly licensed photos (use `--src openverse` if Wikimedia returns 429s).
- Mark Ferrari's pixel art is a fine reference for colour, value contrast and micro-form technique. The photos, not Ferrari, decide what's true.

**Read the art school first, and take real
Bash: Survey existing work directories
cd ~/work; ls artschool/posts/; ls pixelart-studies/; ls pixelart-studies/elements/; ls pixelart-studies/elements/snowpeak 2>/dev/null; cat pixelart-studies/READY.md | head -80; ls pixelart-studies/snow; ls pixelart-studies/tools

# output:
QUEUE
aerial
analysis
atelier
channel
cobbles
conifers
deer-elk
driftwood
grass-lod
ground
master-copy
observation
people
steppe
willow
MORNING-2026-09-26.md
NIGHT_LOG.md
READY.md
SYNTHESIS.md
aerial
braid
elements
exemplars
forest-floor
grass
manager
massing
moodboards
notebooks
pedagogy
rock
shared
sky
snow
tools
transfer
water
_critic
_critic2
alpflora
broadleaf
channel
cobbles
conifers
deer-elk
driftwood
goats
grass-lod
ground
people
rock
steppe
understory
village
water
weather
willow
# 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
Bash: List art school student posts
cd ~/work/artschool/posts; for d in observation willow ground aerial; do echo "== $d"; ls $d; wc -c $d/*.md; done

# output:
== observation
CORRECTION.md
POST.md
TUTORIAL.md
WAKEUP.md
cell_medium_left_40m_s1.png
fig_d_four_lights.png
fig_d_sizes.png
fig_d_zones_vs_ramp.png
fig_scene_10m_rock_8x.png
fig_scene_close_rock_6x.png
figs
nb001.png
nb002.jpg
nb007.png
nb017.jpg
nb022.jpg
nb027.jpg
nb032.jpg
nb038.jpg
nb039.jpg
nb040.jpg
nb042.png
nb045.png
nb046.png
nb047.png
nb048.png
nb051.png
nb052.png
nb053.png
nb054.png
nb055.png
nb056.png
nb057.png
nb058.png
nb059.png
nb060.png
nb061.png
nb062.png
nb063.png
nb064.png
nb065.png
nb066.png
nb067.png
nb068.png
nb069.png
nb070.png
nb071.png
nb072.png
nb075.png
nb079.png
nb084.png
nb089.png
nb095.png
nb100.png
nb103.png
nb104.jpg
nb105.jpg
nb106.png
note.md
post.toml
session_id
wake.log
wake2.log
    1376 observation/CORRECTION.md
   16997 observation/POST.md
   34608 observation/TUTORIAL.md
    2787 observation/WAKEUP.md
     408 observation/note.md
   56176 total
== willow
POST.md
TUTORIAL.md
WAKEUP.md
code
howto
img
note.md
post.toml
session_id
wake.log
   31405 willow/POST.md
    5469 willow/TUTORIAL.md
    3539 willow/WAKEUP.md
     159 willow/note.md
   40572 total
== ground
POST.md
TUTORIAL.md
WAKEUP.md
code
img
masses
note.md
objects
post.toml
read
session_id
texture
turn
wake.log
   34761 ground/POST.md
    6206 ground/TUTORIAL.md
    3485 ground/WAKEUP.md
     159 ground/note.md
   44611 total
== aerial
POST.md
TUTORIAL.md
WAKEUP.md
code
howto
img
note.md
post.toml
session_id
wake.log
   33841 aerial/POST.md
    5894 aerial/TUTORIAL.md
    3433 aerial/WAKEUP.md
     159 aerial/note.md
   43327 total
Read: artschool/posts/observation/TUTORIAL.md
Bash: Read willow, ground, aerial tutorials
cd ~/work/artschool/posts; cat willow/TUTORIAL.md ground/TUTORIAL.md aerial/TUTORIAL.md

# output:
# How to paint willow scrub

*A tutorial by the willow student. The story of how I got here, dead ends included, is in [POST.md](POST.md); my raw working thread is in [the notebook](../../notebooks/willow/notebook.md).*

## What I chose to teach, and why

I could have written one long walkthrough of my painter. I don't think that's what a reader needs from me, because the painter is a stack of about ten separate techniques, and most of them are useful on their own: the clump grammar works for any leafy mass, the contact rules work for anything that stands on gravel, the palette system works for anything at all. So this is a **front page and ten short how-tos**, each one technique, taught from the study's real code (snapshotted verbatim in [`code/`](code/)) and the real images it produced, including the failure it replaced. They're ordered the way the painter runs, but each stands alone.

I left out things that didn't earn their place: the physics bush I started with, the random parameter search, and the gravel (other students do gravel better).

![One willow, layer by layer](img/t_construction.png)

*The whole painter in one picture: `willow.paint_willow` with later layers switched off, same seed throughout. [`code/figs.py`](code/figs.py) makes this and every `t_` figure from the real functions.*

## The idea in one paragraph

Foliage is a heap of **clumps**. Each clump is lit on the side toward the sun and dark along its bottom, where it tucks under the clump in front. Paint the clumps as a *light field*, not as colour, then draw small marks from that field: dense and bright on clump caps, sparse and dim below, none in the dark bands. The mass stays calm in value and all the contrast lives inside the marks. The edge of the mass is where the clumps end, lit on top. That's Ferrari's hedge. A willow is that grammar laid on a willow's own anatomy (a fan of arching wands from a root crown), with a willow's own mark (narrow leaves hanging off a twig), sitting in the world (a contact line, a pocket, the ground's own shadow).

## The how-tos

| | technique | what it's for | the image that shows it |
|---|---|---|---|
| 1 | [Read the master from his indices](howto/01-read-the-master.md) | learning a technique from Ferrari's own index files: decompose, sketch, regenerate, compare at 8× | ![](img/t_decompose.png) |
| 2 | [The clump grammar](howto/02-clump-grammar.md) | any leafy mass: clumps as a light field, marks drawn from it, lit crests | ![](img/t_clump.png) |
| 3 | [The willow's form](howto/03-willow-form.md) | a fan of wands; three scales of surface; lobe caps; the hollow and the stems | ![](img/049.png) |
| 4 | [Leaves and stems](howto/04-leaves-and-stems.md) | the hanging willow spray, silver undersides, wands as clean runs | ![](img/t_spray.png) |
| 5 | [The edge](howto/05-the-edge.md) | a mass with no outline: caps, holes, wand tips, dark ticks, airy holes | ![](img/t_construction_8x.png) |
| 6 | [Palette as light](howto/06-palette-as-light.md) 
Bash: Read aerial far and mountain how-tos
cd ~/work/artschool/posts/aerial/howto; cat 04-far.md 05-mountain.md

# output:
# How-to 4: The far view: haze, bands and palette economy

*[Back to the field guide](../TUTORIAL.md). Code: `~/work/pixelart-studies/aerial/lib/painter.py` (bands, cavity tone, band collapse, sky), `lib/pal.py` (lights, haze, budget), `lib/subjects.py: range_valley`. Plates: `plates/range_far*.png`. Sheet: `sheets/sheet_far.png`.*

This is how the evening range was made: a perspective camera 3.3 km up, pitched 24° down over a 9 km range with a glacial valley and a river. The plate below was the one singled out.

![Evening range](../img/plate_range_far_evening.png)

## 1. What the photos say

Measured row by row down a high oblique (Blue Ridge, [how-to 1](01-measure.md#step-5-haze-row-by-row)), from near to far:

- the **black point rises** from L\* 0.5 to 48;
- the **value range collapses** from 45 to 9;
- b\* goes from 0 to −14, turning blue.

Far land is two close, lifted, blue values, a lit and a shade. That's also how Ferrari's far masses behave: the master-copy student found that his lit colour holds with distance and his shadow colour shifts.

## 2. Depth bands, with dithered seams

Split the ray distance into bands (here 2.5, 4.5 and 7 km), and give each band a haze mix toward the light's haze colour (0 / 22 / 45 / 68%). Mixing toward a light haze lifts the black point first and compresses the range, as measured.


band = band_of(G[&quot;t&quot;] * (1 + (thr_4 - 0.5) * 0.06), bands)     # jitter distance by a Bayer threshold: dithered seams
...
def haze_mix(rgb, light, f):
    return rgb * (1 - f) + light.haze * f
The band edges have to be dithered, or each one is a hard milky ring across the valley (notebook 083). The jitter amount matters. At 14% the seams were wide checker bands that looked like smoke (084), and at 6% they're a one- or two-pixel dither. ![Bands](../img/fig_far_bands.png) *`code/figs.py far`, evening. Left: no depth bands, a dark heavy picture in 32 colours. Middle: the band map, where the lightest grey is the farthest band (the sky is band 0 and shows darkest). Right: with bands, band collapse and the budget, 64 colours. The near valley keeps its full range, and the far peaks go to two lifted violet values.* ## 3. Band collapse Aerial perspective removes steps, not just contrast. So in band 2 each material keeps only its two family tones plus its darkest accent, and in band 3 only lit and shade:
for b_ in (2, 3):
    mb = (cv.band == b_) &amp; (cv.mat &gt; 0) &amp; (cv.mat != MAT_ID[&quot;sky&quot;])
    for m_ in np.unique(cv.mat[mb]):
        n_ = MATERIALS[[k for k, v in MAT_ID.items() if v == m_][0]][&quot;steps&quot;]; ns_ = n_ // 2
        sel = mb &amp; (cv.mat == m_); st_ = cv.step[sel]
        lo_keep = 0 if b_ == 2 else ns_ - 1
        cv.step[sel] = np.where(st_ &gt;= ns_, np.minimum(st_, ns_ + 1), np.maximum(np.minimum(st_, ns_ - 1), lo_keep))
This happens on the index canvas, so it's independent of the hour. ## 4. Cavity tone draws the mountain At a few km, n·l alone gives mountains as soft blobs. What draws their str
Bash: Read prior snow study
cd ~/work/pixelart-studies; cat snow/POST.md | head -200; ls snow/plates snow/refs snow/scenes | head -80

# output:
# How to pixel-art snow, by construction

*Snowfields, gullies and ledges, capped boulders, snowy forest, falling snow: a method you can write as a program.*

Snow is the easiest thing in a landscape to get wrong in pixels, because it seems to have no colour and no texture of its own. The obvious move is to treat it as a material: paint the terrain, then shade it white. That gives you a mesh wearing a white texture. It has no form, and it ignores where snow actually goes.

In Mark Ferrari's winter scenes and Kazuo Oga's backgrounds, snow is a set of **objects**. It sits in loaves on boulders, in slabs on boughs, in tongues down gullies and in sheets across open ground. Each has a silhouette and a few flat values, and each is placed by gravity and wind. This post turns that observation into a grammar, and proves it with a program (`snowpaint.py`, `scenes.py`, `sheets.py`) that paints every image below from a scene spec.

Everything here is 640×360 or smaller, with 64 colours or fewer per frame (enforced), shown upscaled with nearest neighbour.

---

## 1. What the references teach

I studied the three Ferrari pieces supplied and five more of his colour-cycling scenes. I rendered those from the original palette-and-pixel data, so these crops are at **native resolution**: Winter Forest, Ice Wind, Mountain Storm, Mountain Fortress. I also looked at Kazuo Oga's approach (as described in interviews: a small palette, extract the essence of the scene and push it) and at photographs of snow on boulders, streams and couloirs.

### 1.1 Caps are loaves wider than their rock

![Ferrari boulders, native x4](refs/zoom_winter_boulders_native.png)

- The cap's **top outline is a flat-topped, rounded loaf**. It ignores the rock's own bumps and peaks, because snow bridges dips and rounds off at its edges.
- **The cap is wider than the rock** at its lower rim. It bulges past the silhouette and curls under.
- Three bands, top to bottom: a **light top plane** (it faces the sky), a **cooler front roll**, and a **one-pixel dark under-rim**. Right under the rim, a **hard dark shadow** falls on the rock.
- **Where the snow stops on the rock is a level line, not an offset from the rock's outline.** Seen from a low eye it is nearly horizontal, with a slight forward sag. This one observation fixed my caps (see §5).
- Under forest cover, caps are **mid lavender, not white**. The brightest snow in the picture is the open path and the far opening.
- Boulders come in **overlapping clusters**, painted back to front. Each rock keeps its own cap, and a front cap's lit top edge is set against the dark under-rim of the cap behind.

### 1.2 Boughs carry slabs, and the tips shed

![Ferrari branches](refs/zoom_branches_left.png)

A snow slab rides the top of each bough and stops short of the tip, where the bough droops too steeply to hold it. The lower edge drips in fingers, and dark needles show underneath. **Bough snow is the same cap rule on a thin support.**

### 1.3 On mountains th
Bash: Survey rock study folder
cd ~/work/pixelart-studies/elements; ls rock rock/* | head -80; ls _critic2; wc -c rock/*.md

# output:
rock/LESSONS.md

rock:
LESSONS.md
cache
img
refs
sheets
src
stages

rock/cache:
dem1
dragontail_cam.json
dragontail_sun.json
enchant1m_1_661500_666300_5258800_5263200.npy
enchant1m_2_661500_666300_5258800_5263200.npy
enchant_2_661500_666300_5258800_5263200.npy

rock/img:
grid
measure_crops.png

rock/refs:
Enchantment_Lakes_granite_boulders
Enchantments_Aasgard_Pass_talus
Enchantments_granite_slab
Index_Town_Wall_granite
Lime_Kiln_Point_rocks
Mount_Stuart_granite
Olympic_coast_sea_stack_rock
Prusik_Peak_granite
Rialto_Beach_rocks
Ruby_Beach_sea_stack
San_Juan_Island_rocky_shore
Skykomish_River_granite_boulders
Sucia_Island_sandstone
Yakima_River_Canyon_basalt_cliffs
basalt_talus_slope_Columbia
chert_ribbon_San_Juan
columnar_basalt_entablature_Washington
fetch.log
fetch2.log
glacial_striations_San_Juan_Island
gneiss_outcrop_North_Cascades
map_lichen_Rhizocarpon_granite
orange_lichen_basalt
q2.txt
queries.txt

rock/sheets:
A_type_x_form_landscape.png
B_basalt_form_x_distance.png
B_granite_form_x_distance.png
B_greenstone_form_x_distance.png
B_sandstone_form_x_distance.png
C_type_x_hour_iso.png
D_type_x_state_close.png
E_palette_relight.png
run1.log
run2.log

rock/src:
__pycache__
camfit.py
cells.py
compare.py
dem.py
formations.py
forms.py
geom.py
gridview.py
iterate.py
jointfield.py
life.py
match_dragontail.py
measure.py
paint.py
palette.py
photokey.py
LOG.md
READY2.md
new.sh
new_r4.txt
new_r5.txt
new_r6.txt
new_r7.txt
new_r8.txt
r1
r2
r3
r4
zoom.py
   42583 rock/LESSONS.md
Bash: Read rock study lessons
cd ~/work/pixelart-studies/elements; cat rock/LESSONS.md

# output:
<persisted-output>
Output too large (41.6KB). Full output saved to: ~/.claude/projects/-Users-user-work/0566fefe-e974-459d-8d5d-e71202e4c72f/tool-results/bkspjwmj3.txt

Preview (first 2KB):
# Washington rock, from photos to pixels

A student notebook. The subject: the rock of the five voxsim places (Enchantments granite,
Skykomish granite and gneiss, San Juan greenstone/chert/sandstone shores, Olympic coast mudstone and
sandstone stacks, Yakima Canyon basalt). The two halves: how the rock works (why it breaks, weathers,
carries life and reads at each distance), and a painter that draws it at voxsim's four scales.

**Where this stands, plainly.** The observation half is solid and has numbers a simulation can use.
The drawing half is a working *method* (joints in metres → geometry → a two-family index canvas →
palettes per hour), with photo | mine pairs and full sweeps. Beside the photos, the granite wall and
talus and the CRB colonnade read as the real thing at 1×; the sea stack and the San Juan shore read
as rock but not yet as *those* rocks; region scale and the talus matrix are the weakest cells. None
of it is at Ferrari's 8× bar. Details per stage, and the open list at the end.


cd src
nice -n 10 python3 iterate.py granite_wall r1        # photo | mine, 3 hours  -&gt; stages/iter/
nice -n 10 python3 sheets.py A B C D E               # the sweeps              -&gt; sheets/
nice -n 10 python3 measure.py                        # photo colour/value boxes -&gt; img/measure_crops.png
Everything runs on one core in seconds per cell (numba rasterizer + numpy); peak RSS ~0.9 GB on the biggest region cells. No Modal needed. --- ## Part 1. Observation: how Washington's rock works Sources are listed at the end of each subsection; the naturalist studio's geology guides (`~/work/naturalist/geo/02, 03, 05, 08, 09`) did most of the literature work and I lean on them, checked against photos I looked at myself (`refs/`, `~/work/naturalist/geo/photos/`) and a few papers I read for the numbers they didn't carry. ### 1.1 The one idea: joints decide everything you see Every rock face in these five places is made of pieces of **joint planes**. A joint is a fracture ... </persisted-output>
Bash: Find Dragontail DEM/camera fit details
cd ~/work/pixelart-studies/elements; grep -n -i -A3 "dragontail\|camfit\|dem\b\|DEM\|lidar" rock/LESSONS.md | head -150

# output:
59:  the granite's signature at 100 m–1 km** (Dragontail from Aasgard, `refs/Enchantments_Aasgard_Pass_talus`).
60-  Cross joints cut each strip every 2–6 m with small offsets (T-junctions: they stop at the steep set).
61-- **Why rectangular talus.** Blocks fall out along the three sets, so talus is boxy: 0.5–5 m at the
62-  toe, with open voids between (pika habitat). Fall sorting: fine at the apex, coarse at the toe
--
66:  lichen-crusted and ink-streaked: the lit ribs of Dragontail measure **L\* 46**, the shadowed wall
67-  **L\* 22**. So the same rock has two albedos: *fresh* (talus, rockfall scars, polished slabs) and
68-  *old* (walls). Fresh rockfall scars show as pale patches on dark walls for decades to centuries.
69-- **Polish.** Glacier-polished benches (Enchantment basin, Aasgard slabs) are convex whalebacks:
--
317:**Granite, Dragontail from Aasgard Pass** — the best pair.
318-![granite](stages/iter/granite_wall_final.png)
319-Right: ribbed wall with paired lit/dark rib flanks, straight slanting joints, big facets, pale
320-boxy talus with dark voids, fresh blocks lichen-free. Wrong: the photo's value design (the whole right
--
418:  that light is computed, not designed: Dragontail's great dark flank and bright face, the Yakima
419-  wall's alternating lit and shadowed bands. Next: a massif-scale light pass that assigns whole
420-  facets to lit or shadow masses (ground student's `design_masses`) before the joint-scale detail.
421-- **Knobbly masses.** Sea stacks and San Juan knobs need a "lump" primitive (rounded 1–3 m bosses
--
441:form first (Dragontail's real silhouette, where its flank turns dark, where the gullies run), then
442-detail. This round is about that, and about "masses first, then marks inside the mass".
443-
444-### 2a. Masses first, then marks (`paint.shade_designed`, `paint.mass_marks`)
--
467:(`granite_wall_d10`), but it is still my invented mountain, not Dragontail. The basalt got bowed
468-columns and a dark overhanging entablature (`basalt_colonnade_d5`), but the columns still read as
469-clean pale planks, not the photo's rounded, streaked pillars — **not there**.
470-
471:### 2b. Matching Dragontail on the real terrain (`stages/match/dragontail_t1–t8.png`)
472-
473-Tools built this round (all in `src/`):
474-- `photokey.py` — the photo box-downsampled to 256×192 as the answer key: sky mask, 3-value notan
--
479:- `dem.py` — the Enchantments lidar (1 m, upper basin) merged with the 10 m DEM, from
480-  lane-place-enchant's prepared data (read-only), plus a numba heightfield ray-caster.
481:- `camfit.py` — camera from the photo's EXIF GPS (47.484642 N, 120.823639 W; Commons page), then a
482-  search over heading, pitch, fov and height scored by sky-mask IoU. Result: heading 232°, pitch
483:  9°, hfov 96°, 8 m above the lidar surface, 25 m W of the fix; **skyline IoU 0.94**. (The GPS
484:  altitude is ~20 m under the lidar surface, so height is fitted above ground. 8 m is suspicious —
485-  the photo was surel
Bash: Survey naturalist geology research
cd ~/work/naturalist; ls; ls geo; wc -c geo/10-alpine* geo/03-glac* 2>/dev/null; ls geo/photos | head -50

# output:
DECISIONS-FOR-DAVID.md
README.md
animals
geo
hydro
letters
people
plants
questions
shared
01-skokomish-tile-ground-truth.md
02-rocks-and-how-they-break.md
03-glaciation-and-its-legacy.md
04-landslides.md
05-talus-rockfall-and-scree.md
06-soils-and-regolith.md
07-volcanoes-and-lahars.md
08-coasts-outer-and-salish.md
09-the-dry-east.md
10-alpine-periglacial-and-meadows.md
11-vox-geo-review.md
README.md
data
figs
photos
sources
   10292 geo/10-alpine-periglacial-and-meadows.md
   17501 geo/03-glaciation-and-its-legacy.md
   27793 total
PICKS.md
andisol
avalanche_path
baker
blast_zone
bluff_landslide
bluff_magnolia
bluff_whidbey
cape_flattery
cascade_granite
cascade_trough
cascade_volcanics
chuckanut_tafoni
cirque
coulee
crb_colonnade
crescent_basalt
crescent_basalt_outcrop
debris_flow
debris_flow_levee
debris_flow_logs
deep_seated
deep_seated_kelso
drumheller
drumlin
drunken_forest
dry_falls
dungeness_spit
enchantments
erratic
exfoliation
fetch.log
fetch.sh
ghost_forest
giant_ripples
glacier_peak
hoh_valley
horn_shuksan
hummocks
kalaloch_bluff
kettle
lahar_deposit
lahar_osceola
lake_chelan
lake_crescent
landslide_scar
mima_mounds
moraine
olympic_core
olympic_crags
Bash: Read alpine and glaciation research notes
cd ~/work/naturalist/geo; cat 10-alpine-periglacial-and-meadows.md; cat 03-glaciation-and-its-legacy.md; ls data sources

# output:
# The alpine and the meadows: frost, snow, and the ground above and between the trees

*Geo field guide 10. For the sim lanes and the painters. Written 2026-09-25 by the studio geologist. The plants desk covers the vegetation of these places; this is the ground under it.*

---

## 1. The alpine zone: what the ground does above treeline

Treeline in Washington is roughly **1,500 to 1,700 m in the Olympics, ~1,800 m on the west slope of the Cascades, and 2,100 to 2,400 m on the drier east side** (plants desk for detail). Above it, the ground is shaped by **frost, snow and gravity**, with glaciers on the highest peaks (guide 03).

### Processes

| process | what it does | rate / size | where |
|---|---|---|---|
| **frost shattering** | freeze-thaw wedging breaks bare rock into blocks and grit | cliff retreat 0.1-1 mm/yr (guide 05) | every bare rock face; peaks in spring and autumn |
| **frost creep and gelifluction (solifluction)** | soil heaves up perpendicular to the slope when it freezes, settles vertically when it thaws, and flows when saturated by snowmelt over still-frozen ground | surface movement **0.5 to 5 cm/yr** (Matsuoka 2001, global review) | wet alpine slopes 5-25 deg: **solifluction lobes and terraces** with steep turf-covered fronts 0.3-1.5 m high |
| **needle ice** | ice needles grow at the surface on clear frosty nights, lifting grains and seedlings | cm per night | bare soil in meadows and on trails, autumn and spring |
| **sorting (patterned ground)** | repeated freezing sorts stones from fines | stone circles 1-3 m across on flats, **stone stripes** on slopes | alpine flats and saddles, best east of the crest; mostly relict from colder times |
| **nivation** | snowpatches that linger into summer enhance frost weathering and meltwater wash around their edges | hollows 10-100 m | lee slopes below ridges (north and east aspects) |
| **snow avalanches** | scour paths through forest, carry rock and trees, build debris cones | paths 10s of m wide, km long | every steep slope over ~30 deg with snow; hydro owns the snowpack |
| **rock glaciers** | tongues or lobes of talus with ice inside, creeping downslope like slow lava | **~159 mapped in the eastern Cascades**, mostly tongue-shaped, talus-derived, at cirque headwalls in the colder north-east (Weidenaar/CWU; Geomorphology 2021) | bases of steep cirque walls east of the crest |
| **protalus ramparts** | ridges of boulders at the foot of snowpatches (guide 05) | a few m high | below talus |

### Landforms at a glance

- **Fellfield**: flat or gently sloping stony ground, 50-90% stones, cushion plants between; ridge crests and saddles.
- **Rock glacier**: a lobe of bouldery rubble 100 m to 1 km long with a **steep, fresh-looking front** (35-45 deg) of loose blocks, ridges and furrows on its surface curving like flow lines. Active ones have raw fronts with fines spilling out; relict ones are vegetated and slumped.
- **Solifluction lobes**: tongues and terraces of turf-covered soil 1-20 m 
Bash: Inspect rock DEM loader
cd ~/work/pixelart-studies/elements/rock/src; head -80 dem.py; ls ../cache/dem1 | head; ls ~/work/voxsim 2>/dev/null | head -30

# output:
"""dem.py -- real terrain for the match-the-photo work: the Enchantments lidar and 10 m DEM that
lane-place-enchant already prepared (read-only), resampled to one UTM 10N grid, plus a fast
numba heightfield ray-caster for camera search (sky mask + depth).

    Z, x0, y0, res = load_enchant(res=2.0)          # metres, row 0 = north
    sky, depth = cast(Z, x0, y0, res, cam_e, cam_n, cam_h, az, pitch, hfov, W, H)
"""
import numpy as np, os
from numba import njit
from scipy import ndimage as ndi

DEMDIR = os.path.expanduser("~/work/voxsim-wt/place-enchant/studies/enchant/dem/")
CACHE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../cache")


def load_enchant(res=2.0, box=(661500, 666300, 5258800, 5263200)):
    """merged DEM on a UTM10N grid: 1 m lidar where it exists (upper core), 10 m DEM elsewhere."""
    os.makedirs(CACHE, exist_ok=True)
    fn = os.path.join(CACHE, f"enchant_{res:g}_{'_'.join(map(str, box))}.npy")
    e0, e1, n0, n1 = box
    if os.path.exists(fn):
        return np.load(fn), e0, n1, res
    from pyproj import Transformer
    xs = np.arange(e0, e1, res) + res / 2
    ys = np.arange(n1, n0, -res) - res / 2            # row 0 = north
    X, Y = np.meshgrid(xs, ys)
    t = Transformer.from_crs("EPSG:32610", "EPSG:4326", always_xy=True)
    lon, lat = t.transform(X, Y)
    c10 = np.load(DEMDIR + "core10.npy").astype(np.float64)
    # core10 transform: lon = -120.87 + col*dx ; lat = 47.52 - row*dy (pixel corners)
    dx, dy = 9.259259269220167e-05, 9.25925927753796e-05
    col = (lon + 120.87) / dx - 0.5; row = (47.52 - lat) / dy - 0.5
    Z = ndi.map_coordinates(c10, [row, col], order=1, mode="nearest")
    # upper 1 m lidar: 663100..666300 E, 5259300..5261350 N, row0 = north
    up = np.load(DEMDIR + "upper1m.npy", mmap_mode="r")
    ue0, ue1, un0, un1 = 663100, 666300, 5259300, 5261350
    m = (X > ue0 + 2) & (X < ue1 - 2) & (Y > un0 + 2) & (Y < un1 - 2)
    if m.any():
        ur = (un1 - Y[m]) - 0.5; uc = (X[m] - ue0) - 0.5
        upz = ndi.map_coordinates(np.asarray(up, np.float64), [ur, uc], order=1)
        ok = np.isfinite(upz) & (upz > 500)
        zz = Z[m]; zz[ok] = upz[ok]
        # feather the seam over 60 m
        dist = np.minimum.reduce([X[m] - ue0, ue1 - X[m], Y[m] - un0, un1 - Y[m]])
        w = np.clip(dist / 60.0, 0, 1)
        Z[m] = w * zz + (1 - w) * Z[m]
    Z = Z.astype(np.float32)
    np.save(fn, Z)
    return Z, e0, n1, res


def cam_basis(az, pitch):
    a, p = np.radians(az), np.radians(pitch)
    fwd = np.array([np.sin(a) * np.cos(p), np.cos(a) * np.cos(p), np.sin(p)])
    right = np.array([np.cos(a), -np.sin(a), 0.0])
    up = np.cross(right, fwd)
    return fwd, right, up


@njit(cache=True)
def _cast(Z, x0, y0, res, ce, cn, cz, D, far, step0):
    H, W = D.shape[0], D.shape[1]
    ny, nx = Z.shape
    depth = np.full((H, W), np.inf)
    for i in range(H):
        for j in range(W):
            dx, dy, dz = D[i, j, 0], D[i, j, 1], D[i, j, 2]
            t = 1.0
            prev_above 
Bash: Find existing DEM files and disk space
find ~/work ~/sk 2>/dev/null -maxdepth 7 \( -name "*.tif" -o -name "*dem*.npy" -o -name "*.hgt" \) -size +1M 2>/dev/null | grep -v target | head -40; df -h ~ | tail -1

# output:
~/work/voxsim-data/landcover/ESA_WorldCover_10m_2021_v200_N48W126_Map.tif
~/work/voxsim-data/landcover/ESA_WorldCover_10m_2021_v200_N45W123_Map.tif
~/work/voxsim-data/dem1b/USGS_1M_10_x48y525_WA_Olympic_Peninsula_Lidar_2017_B17.tif
~/work/voxsim-data/dem1/USGS_1m_x49y525_WA_Olympic_Peninsula_C1_2017.tif
~/work/voxsim-data/dem1/USGS_1m_x48y525_WA_Olympic_Peninsula_C1_2017.tif
~/work/voxsim-data/dem1/USGS_1m_x48y526_WA_Olympic_Peninsula_C1_2017.tif
~/work/voxsim-data/dem1/USGS_1m_x49y526_WA_Olympic_Peninsula_C1_2017.tif
~/work/voxsim-data/dem1/USGS_1m_x47y525_WA_Olympic_Peninsula_C1_2017.tif
~/work/voxsim-data/dem1/USGS_1m_x47y526_WA_Olympic_Peninsula_C1_2017.tif
~/work/voxsim-data/dem10/USGS_13_n48w124_20240327.tif
~/work/voxsim-data/dem10/USGS_13_n48w123_20240327.tif
~/work/voxsim-data/dem10/USGS_13_n49w122_20250813.tif
~/work/voxsim-data/dem10/USGS_13_n47w122_20250813.tif
~/work/voxsim-data/dem10/USGS_13_n49w123_20240327.tif
~/work/voxsim-data/dem10/USGS_13_n49w124_20240327.tif
~/work/voxsim-data/dem10/USGS_13_n47w123_20250813.tif
~/work/voxsim-data/dem10/USGS_13_n48w122_20250813.tif
~/work/voxsim-data/dem10/USGS_13_n48w121_20250813.tif
~/work/voxsim-data/dem10/USGS_13_n49w121_20250813.tif
~/work/voxsim-data/landcover/ESA_WorldCover_10m_2021_v200_N45W126_Map.tif
~/work/voxsim-data/landcover/ESA_WorldCover_10m_2021_v200_N48W123_Map.tif
~/work/voxsim-data/dem1b/USGS_1M_10_x48y524_WA_Olympic_Peninsula_Lidar_2017_B17.tif
~/work/voxsim-data/dem1b/USGS_1M_10_x48y526_WA_Olympic_Peninsula_Lidar_2017_B17.tif
~/work/voxsim-data/dem1b/USGS_1M_10_x49y524_WA_Olympic_Peninsula_Lidar_2017_B17.tif
~/work/voxsim-data/dem1b/USGS_1M_10_x49y525_WA_Olympic_Peninsula_Lidar_2017_B17.tif
~/work/voxsim-wt/place-sanjuan/data-sj/cudem_utm_4km_1m.npy
~/work/voxsim-wt/place-sanjuan/data-sj/san_juan_2019_dtm_2.tif
~/work/voxsim-wt/place-sanjuan/data-sj/san_juan_2019_dtm_1.tif
~/work/gen2/dem_loader/.cache/USGS_13_n62w151.tif
~/work/gen2/dem_loader/.cache/USGS_13_n46w122.tif
~/work/gen2/dem_loader/.cache/USGS_13_n62w152.tif
~/work/gen2/dem_loader/.cache/USGS_13_n63w152.tif
~/work/gen2/dem_loader/.cache/USGS_13_n63w151.tif
~/work/gen2/dem_loader/.cache/USGS_13_n61w150.tif
~/work/gen/dem_workspace_fixed/raw_dem/srtmgl1.tif
~/work/gen/dem_workspace_seattle/raw_dem/srtmgl1.tif
~/work/place-refs/skykomish/restore/dem1700.npy
/U
Bash: Check Python libs and imgfetch usage
python3 -c "import rasterio, numba, skimage, scipy, pyproj, PIL; print(rasterio.__version__, numba.__version__, skimage.__version__)"; python3 ~/work/pixelart-studies/tools/imgfetch.py -h | head -30; sysctl hw.memsize hw.ncpu

# output:
1.4.3 0.61.2 0.25.2
usage: imgfetch.py [-h] [-f FILE] [-n N] [-o OUT] [--src SRC] [--width WIDTH]
                   [--sheet]
                   [query]

positional arguments:
  query

options:
  -h, --help            show this help message and exit
  -f FILE, --file FILE
  -n N
  -o OUT, --out OUT
  --src SRC
  --width WIDTH
  --sheet
hw.memsize: 38654705664
hw.ncpu: 14
Bash: Create study folders and read imgfetch
mkdir -p ~/work/pixelart-studies/elements/snowpeak/{refs,src,stages,sheets,cache,img}; cd ~/work/pixelart-studies/elements/snowpeak/refs; sed -n 1,200p ~/work/pixelart-studies/tools/imgfetch.py | head -60

# 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:
            if e.code == 429:
                with _glock:
                    SLOW_HOSTS[host] = min(8.0, SLOW_HOSTS.get(host, 0.34) * 2)  # adapt: this host wants us slower
            if e.code in (429, 500, 502, 503, 504) and i < tries - 1:
                wait = min(15, int(e.headers.get("Retry-After") or 0) or 2 * 2 ** i)
                time.sleep(wait); continue
            raise
        except (urllib.error.URLError, TimeoutError):
            if i < tries - 1:
                time.sleep(2 * (i + 1)); continue
            raise

def commons(query, n, width):
    q = urllib.parse.urlencode({"action": "query", "generator": "search", "gsrnamespace": 6, "gsrlimit": min(50, n * 3),
                                "gsrsearch": query + " filetype:bitmap", "prop": "imageinfo",
                                "iiprop": "url|size|extmetadata", "iiurlwidth": min((b for b in (330, 500, 960, 1280) if b >= width), default=1280), "format": "json"})
    d = json.loads(get("https://commons.wikimedia.org/w/api.php?" + q))
    out = []
    for p in sorted(d.get("query", {}).get("pages", {}).values(), key=lambda 
Bash: Fetch reference photos for all query topics
cd ~/work/pixelart-studies/elements/snowpeak/refs; cat > queries.txt <<'EOF'
Mount Stuart Washington
Dragontail Peak Enchantments
Mount Stuart from Ingalls
Mount Rainier from Seattle
Mount Rainier Paradise
Mount Rainier from Sunrise
Mount Rainier September
Mount Baker Washington
Mount Olympus Washington
Olympic Mountains from Hurricane Ridge
Olympic Mountains from Seattle
Glacier Peak Washington
Glacier Peak from Skykomish
cornice snow ridge
snow couloir rock face
bergschrund glacier
glacier terminus Washington
Emmons Glacier
Nisqually Glacier
Blue Glacier Olympus
EOF
nice -n 10 python3 ~/work/pixelart-studies/tools/imgfetch.py -f queries.txt -n 12 -o . --width 1280 --sheet > fetch.log 2>&1; tail -30 fetch.log; 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 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 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)
'Mount Stuart Washington': 11 images -> ./Mount_Stuart_Washington
'Dragontail Peak Enchantments': 12 images -> ./Dragontail_Peak_Enchantments
'Mount Stuart from Ingalls': 12 images -> ./Mount_Stuart_from_Ingalls
'Mount Rainier from Seattle': 12 images -> ./Mount_Rainier_from_Seattle
'Mount Rainier Paradise': 12 images -> ./Mount_Rainier_Paradise
'Mount Rainier from Sunrise': 9 images -> ./Mount_Rainier_from_Sunrise
'Mount Rainier September': 10 images -> ./Mount_Rainier_September
'Mount Baker Washington': 12 images -> ./Mount_Baker_Washington
'Mount Olympus Washington': 10 images -> ./Mount_Olympus_Washington
'Olympic Mountains from Hurricane Ridge': 11 images -> ./Olympic_Mountains_from_Hurricane_Ridge
'Olympic Mountains from Seattle': 10 images -> ./Olympic_Mountains_from_Seattle
'Glacier Peak Washington': 11 images -> ./Glacier_Peak_Washington
'Glacier Peak from Skykomish': 11 images -> ./Glacier_Peak_from
Bash: Re-read the updated brief
cat ~/work/pixelart-studies/manager/briefs/study_snowpeak.txt

# output:
You're a student learning one subject deeply enough to both understand it and draw it beautifully in painterly pixel art: **snowy mountains at distance: snowfields, couloirs, cornices and glacier over rock, seen from miles away**.

voxsim has never had a convincing snowy mountain. The five real places see them constantly:

- Mount Stuart and Dragontail over the Enchantments;
- Rainier and Baker on clear horizons;
- the Olympics behind the coast;
- Glacier Peak from the Skykomish.

The work: how snow really lies on steep rock (it sheds from steep faces and collects in gullies, on ledges and in bowls), seasonal snowline and glaciers, cornices and wind scour, and how a snow peak reads in value and colour from 5, 20 and 80 km through haze. Then draw it, matching real photos of named peaks.

**Two halves, done together.**

- **Observe how it works.** Study the real thing from real sources: photos, video stills, field guides, scientific and historical literature. Find out what it is, why it looks the way it does, how it changes with season, weather and time, and how it behaves.
- **Translate it into pixels.** Find a method that draws it at every scale voxsim shows: close scene (~0.1 m/px), landscape, 30° iso and region (~1 m/px). Draw from what you observed.

The observation should be good enough that a simulation could use it, and the drawing good enough to sit beside the photos.

**The ambition, stated plainly:** match the reference: first its form (the real silhouettes, where the big lights and darks fall, what sits where), then its finer detail. Don't settle for "approximate it with reasonable effort". This is a safe space to fail many times on the way.

**Ways in that have worked here**, yours to use:

- **Break it down and solve bottom-up.** One crop, one form, one mass at a time, each right against the photo before combining.
- **Measure deeply.** Downsample the photo to your target resolution and treat it as an answer key. Extract its notan (2–3 value groups), value histogram, edge and orientation maps. Diff your work against it. Build any small tools that help you see. Numbers are instruments, not judges: the willow student's best-scoring version was its worst foliage.
- **Call critics.** You have the Agent tool: spawn a fresh critic sub-agent whenever you want cold eyes on a photo | yours pair. Give it just the images and ask what's wrong, most important first. Do it often.

**Photos are the standard.**

- Close-read real photos at every distance: ground-level, oblique aerial (Bing Maps bird's-eye, Google Earth 3D, WA Ecology's shoreline obliques; view for study, don't bulk-scrape) and top-down.
- `python3 ~/work/pixelart-studies/tools/imgfetch.py "<query>" -n 12 -o <dir> --sheet` fetches openly licensed photos (use `--src openverse` if Wikimedia returns 429s).
- Mark Ferrari's pixel art is a fine reference for colour, value contrast and micro-form technique. The photos, not Ferrari, decide what's true.

**You're a member of this studio, not a vi
Read: elements/snowpeak/refs/Mount_Stuart_Washington/_sheet.jpg

elements/snowpeak/refs/Mount_Stuart_Washington/_sheet.jpg 1. elements/snowpeak/refs/Mount_Stuart_Washington/_sheet.jpg

Read: elements/snowpeak/refs/Mount_Rainier_from_Seattle/_sheet.jpg

elements/snowpeak/refs/Mount_Rainier_from_Seattle/_sheet.jpg 2. elements/snowpeak/refs/Mount_Rainier_from_Seattle/_sheet.jpg

Read: elements/snowpeak/refs/Mount_Rainier_September/_sheet.jpg
Read: elements/snowpeak/refs/Olympic_Mountains_from_Hurricane_Ridge/_sheet.jpg
Read: elements/snowpeak/refs/Glacier_Peak_from_Skykomish/_sheet.jpg

elements/snowpeak/refs/Mount_Rainier_September/_sheet.jpg 3. elements/snowpeak/refs/Mount_Rainier_September/_sheet.jpg

elements/snowpeak/refs/Olympic_Mountains_from_Hurricane_Ridge/_sheet.jpg 4. elements/snowpeak/refs/Olympic_Mountains_from_Hurricane_Ridge/_sheet.jpg

elements/snowpeak/refs/Glacier_Peak_from_Skykomish/_sheet.jpg 5. elements/snowpeak/refs/Glacier_Peak_from_Skykomish/_sheet.jpg

Read: elements/snowpeak/refs/Mount_Stuart_from_Ingalls/_sheet.jpg
Read: elements/snowpeak/refs/Mount_Olympus_Washington/_sheet.jpg

elements/snowpeak/refs/Mount_Stuart_from_Ingalls/_sheet.jpg 6. elements/snowpeak/refs/Mount_Stuart_from_Ingalls/_sheet.jpg

elements/snowpeak/refs/Mount_Olympus_Washington/_sheet.jpg 7. elements/snowpeak/refs/Mount_Olympus_Washington/_sheet.jpg

Read: elements/snowpeak/refs/Olympic_Mountains_from_Seattle/_sheet.jpg

elements/snowpeak/refs/Olympic_Mountains_from_Seattle/_sheet.jpg 8. elements/snowpeak/refs/Olympic_Mountains_from_Seattle/_sheet.jpg

Read: elements/snowpeak/refs/Dragontail_Peak_Enchantments/_sheet.jpg

elements/snowpeak/refs/Dragontail_Peak_Enchantments/_sheet.jpg 9. elements/snowpeak/refs/Dragontail_Peak_Enchantments/_sheet.jpg

Read: elements/snowpeak/refs/Mount_Rainier_from_Sunrise/_sheet.jpg

elements/snowpeak/refs/Mount_Rainier_from_Sunrise/_sheet.jpg 10. elements/snowpeak/refs/Mount_Rainier_from_Sunrise/_sheet.jpg

Read: elements/snowpeak/refs/cornice_snow_ridge/_sheet.jpg

elements/snowpeak/refs/cornice_snow_ridge/_sheet.jpg 11. elements/snowpeak/refs/cornice_snow_ridge/_sheet.jpg

Read: elements/snowpeak/refs/snow_couloir_rock_face/_sheet.jpg

elements/snowpeak/refs/snow_couloir_rock_face/_sheet.jpg 12. elements/snowpeak/refs/snow_couloir_rock_face/_sheet.jpg

Bash: Inspect DEM and landcover tiles
cd ~/work/voxsim-data; ls dem10 landcover; python3 -c "
import rasterio
for f in ['dem10/USGS_13_n47w122_20250813.tif','landcover/ESA_WorldCover_10m_2021_v200_N45W123_Map.tif']:
    d=rasterio.open(f); print(f,d.width,d.height,d.crs,d.bounds,d.dtypes,d.res)
"; ls ~/work/voxsim/docs/INFRA.md && head -40 ~/work/voxsim/docs/INFRA.md

# output:
dem10:
USGS_13_n47w122_20250813.tif
USGS_13_n47w123_20250813.tif
USGS_13_n48w121_20250813.tif
USGS_13_n48w122_20250813.tif
USGS_13_n48w123_20240327.tif
USGS_13_n48w124_20240327.tif
USGS_13_n49w121_20250813.tif
USGS_13_n49w122_20250813.tif
USGS_13_n49w123_20240327.tif
USGS_13_n49w124_20240327.tif

landcover:
ESA_WorldCover_10m_2021_v200_N45W123_Map.tif
ESA_WorldCover_10m_2021_v200_N45W126_Map.tif
ESA_WorldCover_10m_2021_v200_N48W123_Map.tif
ESA_WorldCover_10m_2021_v200_N48W126_Map.tif
dem10/USGS_13_n47w122_20250813.tif 10812 10812 EPSG:4269 BoundingBox(left=-122.00055555599357, bottom=45.999444443707034, right=-120.99944444380549, top=47.00055555589512) ('float32',) (9.259259269220167e-05, 9.259259269220167e-05)
landcover/ESA_WorldCover_10m_2021_v200_N45W123_Map.tif 36000 36000 EPSG:4326 BoundingBox(left=-123.0, bottom=45.0, right=-120.0, top=48.0) ('uint8',) (8.333333333333333e-05, 8.333333333333333e-05)
~/work/voxsim/docs/INFRA.md
# voxsim infrastructure: where work runs, where things live

For every lane. Read this once. Everything here is meant to be **easy and fast**. If any step takes more than about 10 minutes of fiddling, stop and say so in your NOTES and your report, and ask for help. Don't grind on infrastructure.

## Credentials

- `~/work/.env.local` holds Modal and AWS keys. Load them with `set -a; . ~/work/.env.local; set +a`.
- **Never print, echo, log or commit their values.**

## Where compute runs
| Work | Where |
|---|---|
| Reading, writing code, small checks, `cargo check` on one crate | Your worktree, locally. `.cargo/config.toml` caps builds at 2 jobs. |
| Release builds of the workspace, long sims or soaks, render sweeps, heavy numpy | **Modal** (below). The laptop crashed from memory pressure with 14 lanes building at once, so don't run more than one heavy local job at a time. |
| Self-contained work that only needs the repo | Cloud agents; the manager decides. |

## Modal

- **Workspace:** set `MODAL_ENVIRONMENT=main` after loading the env file (the local `~/.modal.toml` defaults to another workspace's environment). A probe container with 16 CPUs came up in about 6 s.
- **Image:** use `modal.Image.from_registry("rust:1-bookworm", add_python="3.12")`. It ships current Rust. `debian_slim().apt_install(...)` failed on a stale Debian security mirror, so don't use it for Rust.
- **Budget:** the whole project has about $30 of Modal credit for now.
  - Size containers to the job, and set `timeout=`.
  - Shut down what you start (no idle `keep_warm`).
  - Log each Modal run's resources and wall time in your NOTES, so spend can be tracked.
  - If a job will take more than ~1 CPU-hour, say so before you run it.
- **Volumes:** use them for joint disk-and-CPU work (a cargo target cache per lane, shared data). Name everything `voxsim-<lane>-<what>` (e.g. `voxsim-place-yakima-target`), so nothing sprawls untracked.
- **Tracking:** record every app, volume and S3 prefix you create in your lane's NOTES under "Remote resou
Write: elements/snowpeak/src/dem.py
"""dem.py -- real terrain for the snowpeak study.

USGS 3DEP 1/3" (~10 m) tiles and ESA WorldCover 2021 (class 70 = snow and ice) from
~/work/voxsim-data (read-only), reprojected onto one UTM 10N grid at a chosen resolution.

    T = load("rainier", half_km=12, res=20)      # dict: Z (m), ice (0/1), x0 (west edge E), y0 (north edge N), res
    T = load_box(e0, e1, n0, n1, res)

Row 0 is north.  Memory: a 60x60 km box at 30 m is 2000^2 float32 = 16 MB; the source tiles are
read through windowed reads by rasterio's reproject, so peak RSS stays well under 2 GB.
"""
import os, glob
import numpy as np
import rasterio
from rasterio.warp import reproject, Resampling
from rasterio.transform import from_origin
from pyproj import Transformer

DATA = os.path.expanduser("~/work/voxsim-data")
CACHE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../cache")
UTM = "EPSG:32610"
_to_utm = Transformer.from_crs("EPSG:4326", UTM, always_xy=True)
_to_ll = Transformer.from_crs(UTM, "EPSG:4326", always_xy=True)

PEAKS = {  # summit lat, lon, elevation (m) -- USGS/GNIS
    "rainier": (46.8529, -121.7604, 4392),
    "stuart": (47.4751, -120.9024, 2870),
    "dragontail": (47.4787, -120.8322, 2694),
    "olympus": (47.8013, -123.7108, 2432),
    "glacierpeak": (48.1125, -121.1139, 3213),
    "baker": (48.7768, -121.8144, 3286),
}


def ll2utm(lat, lon):
    return _to_utm.transform(lon, lat)


def utm2ll(e, n):
    lon, lat = _to_ll.transform(e, n)
    return lat, lon


def _mosaic(files, dst, transform, resampling, nodata_fill):
    for f in files:
        with rasterio.open(f) as src:
            tmp = np.full(dst.shape, np.nan, np.float32)
            reproject(rasterio.band(src, 1), tmp, src_transform=src.transform, src_crs=src.crs,
                      dst_transform=transform, dst_crs=UTM, resampling=resampling,
                      src_nodata=src.nodata, dst_nodata=np.nan)
            ok = np.isfinite(tmp) & ~np.isfinite(dst)
            dst[ok] = tmp[ok]
    dst[~np.isfinite(dst)] = nodata_fill
    return dst


def load_box(e0, e1, n0, n1, res, tag=None, ice=True):
    os.makedirs(CACHE, exist_ok=True)
    key = tag or f"{int(e0)}_{int(e1)}_{int(n0)}_{int(n1)}"
    fn = os.path.join(CACHE, f"dem_{key}_{res:g}.npz")
    if os.path.exists(fn):
        d = np.load(fn)
        return dict(Z=d["Z"], ice=d["ice"], x0=float(d["x0"]), y0=float(d["y0"]), res=float(d["res"]))
    W = int(round((e1 - e0) / res)); H = int(round((n1 - n0) / res))
    transform = from_origin(e0, n1, res, res)
    Z = np.full((H, W), np.nan, np.float32)
    Z = _mosaic(sorted(glob.glob(DATA + "/dem10/USGS_13_*.tif")), Z, transform,
                Resampling.average if res > 15 else Resampling.bilinear, 0.0)
    I = np.zeros((H, W), np.float32)
    if ice:
        I[:] = np.nan
        I = _mosaic_class(sorted(glob.glob(DATA + "/landcover/ESA_WorldCover_*.tif")), I, transform)
    np.savez_compressed(fn, Z=Z, ice=I, x0=e0, y0=n1, res=res)
    return dict(Z=Z, ice=I, x0=float(e0), y0=float(n1), res=float(res))


def _mosaic_class(files, dst, transform, cls=70):
    """fraction of WorldCover pixels of class `cls` in each cell (average of a 0/1 mask)."""
    for f in files:
        with rasterio.open(f) as src:
            # read only the window we need, as 0/1 float
            left, top = transform.c, transform.f
            right = left + transform.a * dst.shape[1]; bottom = top + transform.e * dst.shape[0]
            corners = [_to_ll.transform(x, y) for x in (left, right) for y in (top, bottom)]
            lons = [c[0] for c in corners]; lats = [c[1] for c in corners]
            b = src.bounds
            if max(lons) < b.left or min(lons) > b.right or max(lats) < b.bottom or min(lats) > b.top:
                continue
            win = rasterio.windows.from_bounds(max(min(lons), b.left) - 0.01, max(min(lats), b.bottom) - 0.01,
                                               min(max(lons), b.right) + 0.01, min(max(lats), b.top) + 0.01,
                                               src.transform).round_offsets().round_lengths()
            a = src.read(1, window=win)
            m = (a == cls).astype(np.float32)
            wt = src.window_transform(win)
            tmp = np.full(dst.shape, np.nan, np.float32)
            reproject(m, tmp, src_transform=wt, src_crs=src.crs, dst_transform=transform, dst_crs=UTM,
                      resampling=Resampling.average, dst_nodata=np.nan)
            ok = np.isfinite(tmp) & ~np.isfinite(dst)
            dst[ok] = tmp[ok]
    dst[~np.isfinite(dst)] = 0.0
    return dst


def load(peak, half_km=10, res=20):
    lat, lon, _ = PEAKS[peak]
    e, n = ll2utm(lat, lon)
    h = half_km * 1000
    return load_box(round(e - h), round(e + h), round(n - h), round(n + h), res,
                    tag=f"{peak}_{half_km}")


def sample(T, e, n):
    """bilinear height at UTM points"""
    Z = T["Z"]; r = (T["y0"] - n) / T["res"] - 0.5; c = (e - T["x0"]) / T["res"] - 0.5
    from scipy.ndimage import map_coordinates
    return map_coordinates(Z, [np.atleast_1d(r), np.atleast_1d(c)], order=1, mode="nearest")


if __name__ == "__main__":
    import sys, time
    for p in sys.argv[1:] or ["rainier"]:
        t = time.time(); T = load(p, 10, 20)
        print(p, T["Z"].shape, float(T["Z"].max()), "ice frac", float(T["ice"].mean()), f"{time.time()-t:.1f}s")
Bash: Load DEM+ice windows for four peaks
cd ~/work/pixelart-studies/elements/snowpeak/src; export OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1; /usr/bin/time -l nice -n 10 python3 dem.py rainier stuart olympus glacierpeak 2>&1 | egrep -v "^ +[0-9]+ +[a-z ]+$" | head; /usr/bin/time -l python3 -c 1 2>&1 | grep maximum

# output:
rainier (1000, 1000) 4391.0546875 ice frac 0.24626155197620392 8.0s
stuart (1000, 1000) 2864.2919921875 ice frac 0.006396644748747349 9.4s
olympus (1000, 1000) 2424.385498046875 ice frac 0.060508593916893005 5.9s
glacierpeak (1000, 1000) 3212.388671875 ice frac 0.10661527514457703 7.4s
       34.69 real         4.83 user         0.86 sys
            11993088  maximum resident set size
Bash: Render hillshade with ice overlay
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > quicklook.py <<'EOF'
import numpy as np, dem
from PIL import Image
tiles=[]
for p in ["rainier","stuart","olympus","glacierpeak"]:
    T=dem.load(p,10,20); Z=T["Z"]; gy,gx=np.gradient(Z,20)
    n=np.dstack([-gx,-gy,np.ones_like(Z)]); n/=np.linalg.norm(n,axis=2,keepdims=True)
    L=np.array([-0.5,0.5,0.7]); L/=np.linalg.norm(L); hs=np.clip(n@L,0,1)
    rgb=np.dstack([hs,hs,hs])*200
    ice=T["ice"][...,None]; rgb=rgb*(1-ice*0.6)+ice*0.6*np.array([120,200,255])
    tiles.append(Image.fromarray(rgb.astype(np.uint8)).resize((500,500)))
out=Image.new("RGB",(2000,500)); [out.paste(t,(i*500,0)) for i,t in enumerate(tiles)]
out.save("../img/quicklook_dem_ice.png")
EOF
python3 quicklook.py

# output:
(Bash completed with no output)
Read: elements/snowpeak/img/quicklook_dem_ice.png

elements/snowpeak/img/quicklook_dem_ice.png 13. elements/snowpeak/img/quicklook_dem_ice.png

ToolSearch: select:WebSearch,WebFetch
WebSearch: SnowSlide Bernhardt Schulz 2010 snow holding depth slope exponential equation
SnowSlide Bernhardt Schulz 2010 snow holding depth slope exponential equation

# result:
Web search results for query: "SnowSlide Bernhardt Schulz 2010 snow holding depth slope exponential equation"

Links: [{"title":"TC - Multi-scale snowdrift-permitting modelling of mountain snowpack","url":"https://tc.copernicus.org/articles/15/743/2021/"},{"title":"Frontiersin","url":"https://www.frontiersin.org/journals/earth-science/articles/10.3389/feart.2019.00143/xml"},{"title":"(PDF) SnowSlide: A simple routine for calculating gravitational snow transport","url":"https://www.researchgate.net/publication/233980850_SnowSlide_A_simple_routine_for_calculating_gravitational_snow_transport"},{"title":"SnowSlide: A simple routine for calculating gravitational snow transport - Bernhardt - 2010 - Geophysical Research Letters - Wiley Online Library","url":"https://agupubs.onlinelibrary.wiley.com/doi/full/10.1029/2010GL043086"},{"title":"Frontiers | A Model Setup for Mapping Snow Conditions in High-Mountain Himalaya","url":"https://www.frontiersin.org/journals/earth-science/articles/10.3389/feart.2019.00129/full"},{"title":"GitHub - MarinKneib/Snowslide: Parametrisation of the transport and deposition of snow on steep terrains. · GitHub","url":"https://github.com/MarinKneib/Snowslide"},{"title":"GitHub - OGGM/Snowslide: Parametrisation of the transport and deposition of snow on steep terrains. · GitHub","url":"https://github.com/OGGM/Snowslide"},{"title":"TC - Snow redistribution in an intermediate-complexity snow hydrology modelling framework","url":"https://tc.copernicus.org/articles/18/3533/2024/"},{"title":"SnowSlide: A simple routine for calculating gravitational snow transport - ProQuest","url":"https://www.proquest.com/openview/8b6589c9715d4e15504310ebcd18bdad/1?pq-origsite=gscholar&cbl=54723"}]

I found relevant information about SnowSlide, Bernhardt, Schulz, and the exponential equation for snow holding depth. Here are the key findings:

## SnowSlide Overview
SnowSlide (Bernhardt and Schulz, 2010) is a model using a simple parameterization for gravitational snow transport, where avalanches are simulated when a slope threshold and a snow holding capacity are exceeded.

## Snow Holding Depth and Exponential Equation
The maximum snow-holding depth is deep for flat areas, decreases exponentially with increasing slope angle, and is calculated by an exponential regression function (Bernhardt and Schulz, 2010).

An exponential relation between S and SWEmax is applied as proposed by Bernhardt and Schulz (2010), where SWEmax = SS1·exp(SS2·S), where S is the slope angle.

## Practical Examples
The default formulation of the snow-holding depth proposed by Bernhardt and Schulz (2010) leads to a maximal snow thickness (taken perpendicular to the slope) of 3.08 m, 1.11 m, 0.45 m and 0.15 m for slopes of 30°, 45°, 60° and 75°, respectively.

The model was published in Geophysical Research Letters (2010) and has become widely used for simulating snow redistribution on steep mountain terrain.


REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
WebSearch: snow cover retention slope angle steep rock faces 50 60 degrees snow sloughing threshold study alpine
snow cover retention slope angle steep rock faces 50 60 degrees snow sloughing threshold study alpine

# result:
Web search results for query: "snow cover retention slope angle steep rock faces 50 60 degrees snow sloughing threshold study alpine"

Links: [{"title":"(PDF) Thermal Regime of Rock and its Relation to Snow Cover in Steep Alpine Rock Walls: Gemsstock, Central Swiss Alps","url":"https://www.researchgate.net/publication/273331169_Thermal_Regime_of_Rock_and_its_Relation_to_Snow_Cover_in_Steep_Alpine_Rock_Walls_Gemsstock_Central_Swiss_Alps"},{"title":"Snow control on active layer thickness in steep alpine rock walls (Aiguille du Midi, 3842ma.s.l., Mont Blanc massif) | Request PDF","url":"https://www.researchgate.net/publication/304006989_Snow_control_on_active_layer_thickness_in_steep_alpine_rock_walls_Aiguille_du_Midi_3842masl_Mont_Blanc_massif"},{"title":"Snow control on active layer thickness in steep alpine rock walls (Aiguille du Midi, 3842 m a.s.l., Mont Blanc massif) - ScienceDirect","url":"https://www.sciencedirect.com/science/article/abs/pii/S0341816216302119"},{"title":"TC - Temperature variability and offset in steep alpine rock and ice faces","url":"https://tc.copernicus.org/articles/5/977/2011/"},{"title":"Snow as a driving factor of rock surface temperatures in steep rough rock walls - ScienceDirect","url":"https://www.sciencedirect.com/science/article/abs/pii/S0165232X15001421"},{"title":"Snowpack characteristics on steep frozen rock slopes - ScienceDirect","url":"https://www.sciencedirect.com/science/article/abs/pii/S0165232X16304268"},{"title":"Snow Guard Theory | Rocky Mountain Snow Guards","url":"https://www.rockymountainsnowguards.com/snow-guard-theory"},{"title":"Repository logo","url":"https://infoscience.epfl.ch/handle/20.500.14299/72448"},{"title":"The Cryosphere (Nov 2011)","url":"https://doaj.org/article/a92619423f434423bf0804dba77594ce"}]

I've performed the web search and found relevant research on snow cover in steep alpine rock faces. Here are the key findings:

## Snow Cover on Steep Alpine Slopes

Research examined snow profiles taken in 50–65° slopes at elevations between 2900 and 3600 m asl on north- and south-facing slopes in the Swiss Alps, which directly addresses your angle range of interest.

## Snow Retention Characteristics

Measurements showed no decrease of snow depth at slope angles up to 70°, indicating that snow can be retained even on very steep angles. Considerable amounts of snow can accumulate in steep, rough rock walls, with thermal insulation of the ground starting with snow depths exceeding 0.2 m in rock faces.

## Slope Aspect Effects

Snow pits in frozen rock walls revealed that steep slopes have distinctive snowpack characteristics with marked contrasts in snow characteristics between north- and south-facing aspects. Under intense solar radiation, basal ice layers and multiple hard melt-freeze crusts formed on south-facing slopes, while on shady north-facing slopes, thick basal melt-freeze crusts formed when snow persisted during stable weather periods.

## Snow Distribution Variability

The high spatial and temporal variability of snow deposits in steep slopes results from the interaction of topoclimatic factors such as slope angle, sun exposure, and wind transport, creating a highly variable and local thermal effect.


REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
WebFetch: https://github.com/OGGM/Snowslide
https://github.com/OGGM/Snowslide
What is the snow holding depth formula (with constants) as a function of slope, and any other parameters (min slope, routing)? Quote the equations.

# result:
Based on the provided web page content, I cannot provide the specific snow holding depth formula with constants and equations. 

The page mentions that **"SND_max_exponential()"** is a function that "compute the maximal height of snow each pixel can store based on the slope" with "parameters estimated from 'Bernhardt & Schulz 2007'," but the actual mathematical formula is not included in this documentation.

To find the explicit equations, you would need to:

1. Consult the referenced paper: Bernhardt & Schulz (2007)
2. Review the actual source code in the `functions.py` file of the Snowslide repository
3. Check the tutorial notebooks linked in the repository

The web page focuses on package structure and usage rather than the underlying mathematical formulation.
Bash: Fetch Snowslide holding-depth source
curl -sL https://raw.githubusercontent.com/OGGM/Snowslide/main/snowslide/functions.py | grep -n -A25 "def SND_max_exponential" | head -50

# output:
(Bash completed with no output)
Bash: List Snowslide repo files
cd /tmp; curl -sL https://api.github.com/repos/OGGM/Snowslide/git/trees/main?recursive=1 | grep '"path"' | head -30

# output:
(eval):1: no matches found: https://api.github.com/repos/OGGM/Snowslide/git/trees/main?recursive=1
Bash: List Snowslide repo files
curl -sL "https://api.github.com/repos/OGGM/Snowslide/git/trees/main?recursive=1" | grep '"path"' | head -30

# output:
"path": ".gitignore",
      "path": "LICENSE.txt",
      "path": "README.md",
      "path": "notebooks",
      "path": "notebooks/avalanche_maps_for_gdirs.ipynb",
      "path": "notebooks/running_oggm_with_avalanches.ipynb",
      "path": "pyproject.toml",
      "path": "snowslide",
      "path": "snowslide/__init__.py",
      "path": "snowslide/functions.py",
      "path": "snowslide/oggm_snowslide_compat_minimal.py",
      "path": "snowslide/snowslide_main.py",
Bash: Show Snowslide SND_max functions
curl -sL "https://raw.githubusercontent.com/OGGM/Snowslide/main/snowslide/functions.py" | grep -n -B3 -A30 "def SND_max" | head -90

# output:
(Bash completed with no output)
Bash: Download and grep Snowslide functions
curl -sL "https://raw.githubusercontent.com/OGGM/Snowslide/main/snowslide/functions.py" -o (tmp) wc -l (tmp) grep -n -i "exp\|3178\|slope_min\|def " (tmp) | head -40

# output:
657 (tmp)
19:def dem_flow(hnso, grid, routing, preprocessing):
55:def precipitations_base(path_dem, quantity, isotherme, x, y):
93:def slope(dem, map_dx, map_dy, compute_edges=True):
181:def snd_max_exponential(slp, a, c, min):
183:    The function is an exponential and parameters are estimated from 'Bernhardt & Schulz 2007'.
190:        Parameter of the exponential function. Default is -0.14.
192:        Parameter of the exponential function. Default is 145
201:    snd_max = c * np.exp(-a * slp)
207:def snow_routing(snd, snd_max, flow_dir, routing):
216:        matrix associating to each pixel the maximum heiht of snow it can store based on an exponential function of slope
345:def precipitations(gdir, dem, date, density):
391:def reframe_tif(
477:def resampling_dem(src_path, dst_path, factor):
528:def initialize_snowslide_from_SAFRAN(
563:    def preprocess_SAFRAN_precipitations(ds_path, massif_id):
Bash: Read Snowslide holding and routing code
sed -n 181,260p (tmp) curl -sL "https://raw.githubusercontent.com/OGGM/Snowslide/main/snowslide/snowslide_main.py" | grep -n "a=\|c=\|min=\|epsilon\|snd_max_exponential" | head

# output:
def snd_max_exponential(slp, a, c, min):
    """Function that compute the maximal height of snow each pixel can store based on the slope.
    The function is an exponential and parameters are estimated from 'Bernhardt & Schulz 2007'.

    Parameters
    ----------
    slope: np matrix
        Matrix that attributes a slope value to each pixel
    a: float
        Parameter of the exponential function. Default is -0.14.
    c: float
        Parameter of the exponential function. Default is 145
    min: float
        Minimum snow height each pixel can store regardless of the slope. Default is 0.05.

    Returns
    -------
    A matrix associating to each pixel the maximum heiht of snow it can store without routing (np matrix in meters)
    """

    snd_max = c * np.exp(-a * slp)
    snd_max[snd_max < min] = min

    return snd_max


def snow_routing(snd, snd_max, flow_dir, routing):
    """That function routes the snow based on the routing method chosen ('mfd' or 'd8').
    It is called at each iteration.

    Parameters
    ----------
    snd: np matrix
        Matrix of the snow heights associated to each pixel at iteration k
    snd_max: np matrix
        matrix associating to each pixel the maximum heiht of snow it can store based on an exponential function of slope
    flow_dir: np matrix or list of np matrices (depending on the routing method)
        Matrix of the direction and coefficient of snow that is routed to the next pixels
    routing: str
        Routing method chosen by the users ('mfd' or 'd8')
    """

    # Compute the quantity and fraction of snow that should be routed
    snr = snd - snd_max  # snr is the quantity of snow that can be routed for each pixel
    snr[
        snr < 0
    ] = 0  # Negative values are computed when no amount of snow can be routed
    # Then every negative value is set to 0, if threshold is not reached, the snow isn't routed.

    if routing == "mfd":
        direction_indices = {
            "North": 0,
            "Northeast": 1,
            "East": 2,
            "Southeast": 3,
            "South": 4,
            "Southwest": 5,
            "West": 6,
            "Northwest": 7,
        }
        # Neighbour to the North
        snd[1:-1, 1:-1] += (
            flow_dir[0][2:, 1:-1] * snr[2:, 1:-1]
            - flow_dir[0][1:-1, 1:-1] * snr[1:-1, 1:-1]
        )
        # Neighbour to the NorthEast
        snd[1:-1, 1:-1] += (
            flow_dir[1][2:, :-2] * snr[2:, :-2]
            - flow_dir[1][1:-1, 1:-1] * snr[1:-1, 1:-1]
        )
        # Neighbour to the East
        snd[1:-1, 1:-1] += (
            flow_dir[2][1:-1, :-2] * snr[1:-1, :-2]
            - flow_dir[2][1:-1, 1:-1] * snr[1:-1, 1:-1]
        )
        # Neighbour to the SouthEast
        snd[1:-1, 1:-1] += (
            flow_dir[3][:-2, :-2] * snr[:-2, :-2]
            - flow_dir[3][1:-1, 1:-1] * snr[1:-1, 1:-1]
        )
13:    epsilon=1e-3,
32:    epsilon: float
33:        Condition to get out the loop (convergence is con
WebSearch: Mount Rainier glaciers equilibrium line altitude meters Nisqually Emmons ELA
Mount Rainier glaciers equilibrium line altitude meters Nisqually Emmons ELA

# result:
Web search results for query: "Mount Rainier glaciers equilibrium line altitude meters Nisqually Emmons ELA"

Links: [{"title":"Climatic and Spatial Variations of Mount Rainier's Glaciers for ...","url":"https://glaciers.pdx.edu/Thesis/Hekkers/Hekkers%20Thesis11_16_10.pdf"},{"title":"\"Climatic and Spatial Variations of Mount Rainier's Glaciers for the La\" by Michael Leslie Hekkers","url":"https://pdxscholar.library.pdx.edu/open_access_etds/4951/"},{"title":"National Park Service U.S. Department of the Interior","url":"https://irma.nps.gov/DataStore/DownloadFile/434579"},{"title":"Abstract: ESTIMATING GLACIER EQUILIBRIUM LINE ALTITUDE ON MOUNT RAINIER, WASHINGTON USING SATELLITE IMAGERY ANALYSIS (GSA Annual Meeting in Denver, Colorado, USA - 2016)","url":"https://gsa.confex.com/gsa/2016AM/webprogram/Paper283954.html"},{"title":"A VISITOR S GUIDE TO MOUNT RAINIER","url":"https://npshistory.com/publications/mora/glaciers.pdf"},{"title":"Glacier atlas of Mt. Rainier","url":"https://glaciers.pdx.edu/Projects/LearnAboutGlaciers/MRNP/Basics00.html"},{"title":"home.nps.gov","url":"https://home.nps.gov/mora/learn/nature/glaciers.htm"},{"title":"USGS Logo Geological Survey Professional Paper 631","url":"https://www.nps.gov/parkhistory/online_books/geology/publications/pp/631/sec1.htm"},{"title":"Last updated: September 27, 2022","url":"https://home.nps.gov/places/nisqually-vista-walking-tour-nisqually-vista.htm"}]

Based on the search results, here is the information about the equilibrium line altitudes (ELA) for Mount Rainier's glaciers:

The Nisqually Glacier has an ELA of 3,180 m, while the Emmons Glacier has an ELA of 2,800 m (based on data from 2002-2008).

Additionally, Holocene ELAs have been reconstructed between 1,735-2,980 m for Mount Rainier's glaciers, showing significant variation in equilibrium line altitudes over longer timescales.


REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
WebSearch: Blue Glacier Olympus equilibrium line altitude mass balance meters
Blue Glacier Olympus equilibrium line altitude mass balance meters

# result:
Web search results for query: "Blue Glacier Olympus equilibrium line altitude mass balance meters"

Links: [{"title":"Equilibrium line altitude – From a Glaciers Perspective","url":"https://glacierchange.blog/tag/equilibrium-line-altitude/"},{"title":"Annual mass balance of blue glacier, usa: 1955–97: Geografiska Annaler: Series A, Physical Geography: Vol 81, No 4","url":"https://www.tandfonline.com/doi/abs/10.1111/1468-0459.00080"},{"title":"An introduction to Glacier Mass Balance","url":"https://www.antarcticglaciers.org/glacier-processes/mass-balance/introduction-glacier-mass-balance/"},{"title":"Equilibrium Line Altitudes & Mass Balance","url":"https://www.geo.umass.edu/courses/geo563/Geo563-4ELAnew.pdf"},{"title":"Accumulation, ablation, mass Balance and the resulting ice flow — OGGM-Edu notebooks","url":"https://edu-notebooks.oggm.org/oggm-edu/accumulation_and_ablation.html"},{"title":"Blue Glacier, Olympic National Park, Washington, USA","url":"https://earthweb.ess.washington.edu/Glaciology/projects/blue_glac/old_web/"},{"title":"Blue Glacier Mass Balance Record","url":"https://earthweb.ess.washington.edu/Glaciology/projects/blue_glac/b_timeseries.htm"},{"title":"core reader","url":"https://resolve.cambridge.org/core/product/E9BC120E377315902F90D49BC76FBD7C/core-reader"},{"title":"Annual Mass Balance Of Blue Glacier, U.S.A.: 1955-97","url":"https://earthweb.ess.washington.edu/Glaciology/projects/blue_glac/abstracts.html"}]

I found relevant information about Blue Glacier's equilibrium line altitude and mass balance. Here are the key findings:

## Equilibrium Line Altitude (ELA) Data

Blue Glacier's equilibrium line altitude (ELA) has varied considerably over time, ranging from around 1505 meters in 1974 to 2030 meters in 1981. For example, the present firn limit on Blue Glacier is centered on an altitude of 1,725 m.

## Mass Balance Information

The mass of Blue Glacier has changed less than 7 m (water equivalent) during the 56-year period from 1939 to 1995, which is minor compared with other glaciers in the region and elsewhere in the world.

Glacier-average annual mass balances, beginning in 1956, have been calculated either from stake measurements and probing of late-season snow, or from a regression analysis using late-season measurements of the equilibrium line altitude.

## Ablation Gradient

The net budget gradient of the Blue Glacier ablation zone (lower glacier) is 10 mm·m⁻¹, a figure derived from 1959 and 1960 when the glacier was closest to mass equilibrium.

The search results contain detailed time series data and historical records of Blue Glacier's mass balance measurements spanning from 1956 onwards.


REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
WebSearch: IMPROVE visibility Mount Rainier National Park standard visual range km clearest days haziest days
IMPROVE visibility Mount Rainier National Park standard visual range km clearest days haziest days

# result:
Web search results for query: "IMPROVE visibility Mount Rainier National Park standard visual range km clearest days haziest days"

Links: [{"title":"Air Quality Overview: Mount Rainier National Park","url":"https://npshistory.com/publications/mora/nrr-2014-848.pdf"},{"title":"visibility today - Mount Rainier National Park Forum - Tripadvisor","url":"https://www.tripadvisor.com/ShowTopic-g143044-i3163-k14489514-Visibility_today-Mount_Rainier_National_Park_Washington.html"},{"title":"Mt. Rainier Visibility | Is the Mountain Out Today? Seattle Forecast | Is It Visible","url":"https://isitvisible.com/rainier"},{"title":"Is the Mountain Out? Seattle Mt. Rainier Visibility Guide | Is It Visible","url":"https://isitvisible.com/blog/is-the-mountain-out-seattle-mt-rainier"},{"title":"Mt. Rainier Weather: Complete Guide to Conditions and Visibility | Is It Visible","url":"https://isitvisible.com/blog/mt-rainier-weather"},{"title":"Weather - Mount Rainier National Park (U.S. National Park Service)","url":"https://www.nps.gov/mora/planyourvisit/weather.htm"},{"title":"Seasonal Travel Guide to Visiting Mount Rainier National Park","url":"https://www.toursnorthwest.com/blog/visiting-mount-rainier-seasonal-guide/"},{"title":"Air Quality - Mount Rainier National Park (U.S. National Park Service)","url":"https://www.nps.gov/mora/learn/nature/airquality.htm"},{"title":"Last updated: March 11, 2026","url":"https://www.nps.gov/thingstodo/cloudy-day-activities-at-mount-rainier.htm"}]

Based on the search results, here's what I found about visibility at Mount Rainier National Park:

Monitoring data collected from 1988 to 2013 showed steadily improving trends in visibility at the park on the clearest and haziest days. Additionally, based on 2008 to 2012 data, current average visibility at Mount Rainier National Park was about 42 percent hazier than natural conditions.

The search results provide information about visibility trends and factors affecting visibility, but they do not contain specific data on the standard visual range in kilometers for the clearest versus haziest days at Mount Rainier National Park. To get those specific measurements, you may want to consult the National Park Service's official air quality monitoring data or contact the park directly.


REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
WebSearch: cornice formation lee ridge wind speed overhang growth study Vogel Eckerstorfer cornice dimensions
cornice formation lee ridge wind speed overhang growth study Vogel Eckerstorfer cornice dimensions

# result:
Web search results for query: "cornice formation lee ridge wind speed overhang growth study Vogel Eckerstorfer cornice dimensions"

Links: [{"title":"Snow Particle Motion in Process of Cornice Formation","url":"https://egusphere.copernicus.org/preprints/2024/egusphere-2024-2458/egusphere-2024-2458.pdf"},{"title":"TC - Wind conditions for snow cornice formation in a wind tunnel","url":"https://tc.copernicus.org/articles/17/639/2023/"},{"title":"(PDF) Snow Particle Motion in Process of Cornice Formation","url":"https://www.researchgate.net/publication/384693058_Snow_Particle_Motion_in_Process_of_Cornice_Formation"},{"title":"(PDF) Wind conditions for snow cornice formation in a wind tunnel","url":"https://www.researchgate.net/publication/368384048_Wind_conditions_for_snow_cornice_formation_in_a_wind_tunnel"},{"title":"Size distribution in % of all recorded cornice failures and their... | Download Scientific Diagram","url":"https://www.researchgate.net/figure/Size-distribution-in-of-all-recorded-cornice-failures-and-their-destructive-potential_fig2_259962847"},{"title":"Effect of Snow Cornice Formation on Wind Fields and Snow","url":"https://www.dora.lib4ri.ch/wsl/dload/wsl:41179/PDF/Yu-2025-Effect_of_snow_cornice_formation-(published_version).pdf"},{"title":"TC - Snow particle motion in process of cornice formation","url":"https://tc.copernicus.org/articles/19/5389/2025/"},{"title":"Cornice Fall | Colorado Avalanche Information Center","url":"https://avalanche.state.co.us/cornice-fall"},{"title":"Wind conditions for snow cornice formation in a wind tunnel","url":"https://ouci.dntb.gov.ua/en/works/98wDxxYl/"}]

Based on the search results, here's what I found regarding cornice formation, wind speed, overhang growth, and the research by Vogel and Eckerstorfer:

## Wind Speed Conditions for Cornice Formation

Previous field observations have consistently shown that the optimal wind speed range for cornice formation lies between one and two times the threshold wind speed (Eckerstorfer et al., 2013b; Vogel et al., 2012). More specifically, cornice growth is maximized when the wind speed exceeds the threshold value by 40% (Yu et al., 2022).

## Cornice Characteristics and Formation

Snow cornices are leeward-growing masses of snow overhanging and extending horizontally beyond the edge, usually appearing on the ridgeline of steep mountains. Cornices range in size from small wind lips of soft snow to large overhangs of hard snow that are 30 feet (10 meters) or taller.

## Cornice Dimensions from Studies

A wedge-shaped cornice horizontally hanging at the edge had a length of 0.145 m, which is the maximum cornice length observed in previous experiments.

## Growth Stages

The growth of a snow cornice can be divided into several stages, with the initial stage involving a thin slab formation at the mountain edge mainly by adhesion of wind-transported snow particles.


REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
Bash: List Commons sources for candidate photos
cd ~/work/pixelart-studies/elements/snowpeak/refs; head -3 Mount_Rainier_from_Seattle/sources.tsv; for d in Mount_Rainier_from_Seattle Mount_Stuart_Washington Mount_Stuart_from_Ingalls Mount_Olympus_Washington Glacier_Peak_from_Skykomish Olympic_Mountains_from_Hurricane_Ridge Mount_Rainier_September Glacier_Peak_Washington; do cut -f1,5 $d/sources.tsv; done | grep -i commons

# output:
openverse_Mount_Rainier_from_Seattle_5db98548.jpg   Mount Rainier from Seattle  CC BY-NC-ND 2.0 Seattle.roamer  https://www.flickr.com/photos/66277514@N00/3500243866
openverse_Mount_Rainier_from_Seattle_d54ed269.jpg   Mount Rainier from Seattle  CC BY-NC-ND 2.0 Erwyn van der Meer  https://www.flickr.com/photos/81669747@N00/2666298987
openverse_Mount_Rainier_from_Seattle_Municipal_Tower_24d1e92a.jpg   Mount Rainier from Seattle Municipal Tower  CC BY-SA 2.0    SounderBruce    https://www.flickr.com/photos/70175722@N04/34399992906
commons_Seattle_3_jpg_aae2ec0d.jpg  https://commons.wikimedia.org/wiki/File:Seattle_3.jpg
commons_Mount_Rainier_from_Lake_Washington_Boulevard_Sea_86f9cff0.jpg   https://commons.wikimedia.org/wiki/File:Mount_Rainier_from_Lake_Washington_Boulevard,_Seattle,_1914_(MOHAI_1934).jpg
commons_Seattle_4_jpg_2227a082.jpg  https://commons.wikimedia.org/wiki/File:Seattle_4.jpg
commons_Mount_Rainier_at_sundown_jpg_c09f7c43.jpg   https://commons.wikimedia.org/wiki/File:Mount_Rainier_at_sundown.jpg
commons_Mount_Rainier_from_Seattle_1_jpg_785aaa4b.jpg   https://commons.wikimedia.org/wiki/File:Mount_Rainier_from_Seattle_(1).jpg
commons_Mount_Rainier_at_dawn_from_Seattle_png_c80e685b.png https://commons.wikimedia.org/wiki/File:Mount_Rainier_at_dawn_from_Seattle.png
openverse_File_Mount_Stuart_jpg_0cdcc0d8.jpg    https://commons.wikimedia.org/w/index.php?curid=1098957
commons_Mount_Stuart_7814p_JPG_5b777f82.jpg https://commons.wikimedia.org/wiki/File:Mount_Stuart_7814p.JPG
commons_Mount_Stuart_sun_fog_from_near_Longs_Pass_21_Oct_353c40ec.jpg   https://commons.wikimedia.org/wiki/File:Mount_Stuart_sun_%26_fog_from_near_Longs_Pass_(21_October_2024)_19.jpg
commons_Mount_Stuart_House_2018_08_25_jpg_1748e912.jpg  https://commons.wikimedia.org/wiki/File:Mount_Stuart_House_2018-08-25.jpg
commons_Mount_Stuart_House_marble_hall_2018_08_25_jpg_311951ad.jpg  https://commons.wikimedia.org/wiki/File:Mount_Stuart_House_marble_hall_2018-08-25.jpg
commons_Mount_Stuart_sun_fog_from_near_Longs_Pass_21_Oct_b228bbd5.jpg   https://commons.wikimedia.org/wiki/File:Mount_Stuart_sun_%26_fog_from_near_Longs_Pass_(21_October_2024)_5.jpg
openverse_Ingalls_Lake_and_Mount_Stuart_66922da2.jpg    https://commons.wikimedia.org/w/index.php?curid=45862916
commons_Mount_Stuart_in_fog_from_near_Ingalls_Pass_21_Oc_464d60cf.jpg   https://commons.wikimedia.org/wiki/File:Mount_Stuart_in_fog_from_near_Ingalls_Pass_(21_October_2024).jpg
commons_Mount_Stuart_House_2018_08_25_jpg_1748e912.jpg  https://commons.wikimedia.org/wiki/File:Mount_Stuart_House_2018-08-25.jpg
commons_Mount_Stuart_House_marble_hall_2018_08_25_jpg_311951ad.jpg  https://commons.wikimedia.org/wiki/File:Mount_Stuart_House_marble_hall_2018-08-25.jpg
commons_Mount_Stuart_House_Isle_of_Bute_east_view_jpg_c1570e72.jpg  https://commons.wikimedia.org/wiki/File:Mount_Stuart_House,_Isle_of_Bute_-_east_view.jpg
commons_Mount_Stuart_House_Isle_of_Bute_west_view_jpg_04a3e3f5.jpg  https://commons.wikimedia.org/wiki/File:Mount_Stuart_House,_Isle_of_Bute_-_west_view.jpg
Bash: Fetch Commons metadata for camera GPS
cd ~/work/pixelart-studies/elements/snowpeak/refs; cat > commons_meta.py <<'EOF'
import json, urllib.request, urllib.parse, sys, re, glob, time
UA="pixelart-studies/1.0 (research)"
titles=[]
for f in glob.glob("*/sources.tsv"):
    for line in open(f):
        m=re.search(r"commons.wikimedia.org/wiki/(File:[^\t\n]+)",line)
        if m: titles.append(urllib.parse.unquote(m.group(1)).replace("_"," "))
titles=sorted(set(titles)); out={}
for i in range(0,len(titles),20):
    q=urllib.parse.urlencode({"action":"query","titles":"|".join(titles[i:i+20]),"prop":"imageinfo|coordinates","iiprop":"extmetadata|size","coprop":"type|name","colimit":"max","format":"json"})
    d=json.loads(urllib.request.urlopen(urllib.request.Request("https://commons.wikimedia.org/w/api.php?"+q,headers={"User-Agent":UA})).read())
    for p in d["query"]["pages"].values():
        em=p.get("imageinfo",[{}])[0].get("extmetadata",{}); ii=p.get("imageinfo",[{}])[0]
        g=lambda k: re.sub("<[^>]+>","",str(em.get(k,{}).get("value","")))[:200]
        out[p["title"]]=dict(coords=p.get("coordinates"),gps=(g("GPSLatitude"),g("GPSLongitude")),date=g("DateTimeOriginal") or g("DateTime"),desc=g("ImageDescription"),size=(ii.get("width"),ii.get("height")))
    time.sleep(1)
json.dump(out,open("commons_meta.json","w"),indent=1)
for t,v in out.items():
    if v["coords"] or v["gps"][0]: print(t[:70],"|",v["coords"],v["gps"],v["date"][:20],v["size"])
EOF
python3 commons_meta.py

# output:
File:Aerial View of Peaks of Khumbu, Ngozumpa Glacier and Gokyo Lakes  | [{'lat': 27.980278, 'lon': 86.668611, 'primary': '', 'type': 'camera'}] ('27.980278', '86.668611') 14 June 2025, 11:39: (9170, 4585)
File:Bergschrund of Rob Roy Glacier with crevasses and seracs.jpg | [{'lat': -44.47829722, 'lon': 168.7281, 'primary': '', 'type': 'camera'}] ('-44.478297', '168.728100') 2014-10-04 (4896, 3264)
File:Breiðamerkurjökull glacier, Iceland, 20240719 1427 2618.jpg | [{'lat': 64.13107599, 'lon': -16.23348752, 'primary': '', 'type': 'camera'}] ('64.131076', '-16.233488') Taken on 19 July 202 (6056, 4016)
File:Columbia Glacier, Valley Glacier Bergschrund, June 11, 1978 (GLAC | None ('61.219722', '-146.895278') Taken on 11 June 197 (3400, 3300)
File:Dragontail Peak, Colchuck Glacier, and Colchuck Peak.jpg | [{'lat': 47.493872, 'lon': -120.837319, 'primary': '', 'type': 'camera'}] ('47.493872', '-120.837319') 2018-09-03 08:00:38 (4032, 3024)
File:Dragontail Peak 7819.JPG | [{'lat': 47.4252, 'lon': -120.907, 'primary': '', 'type': 'mountain'}] ('47.425200', '-120.907000') 14 July 2008, 15:15: (2848, 4272)
File:Dragontail Peak from Aasgard Pass (11 October 2024).jpg | [{'lat': 47.48078611, 'lon': -120.82454167, 'primary': '', 'type': 'camera'}] ('47.480786', '-120.824542') 11 October 2024 (acc (4624, 3472)
File:Dragontail Peak from the way up Aasgard Pass (11 October 2024).jp | [{'lat': 47.48464167, 'lon': -120.82363889, 'primary': '', 'type': 'camera'}] ('47.484642', '-120.823639') 11 October 2024 (acc (4624, 3472)
File:Emmons Glacier - Flickr - brewbooks.jpg | [{'lat': 46.908573, 'lon': -121.66131, 'primary': '', 'type': 'camera'}] ('46.908573', '-121.661310') 2012-08-04 14:41 (4288, 2416)
File:Glacier Peak (aerial view).jpg | [{'lat': 47.967, 'lon': -121.136, 'primary': '', 'type': 'mountain'}] ('47.967000', '-121.136000') 8 November 2006 (acc (2592, 1944)
File:Glacier Peak 7118.JPG | [{'lat': 48.1439, 'lon': -120.9588, 'primary': '', 'type': 'mountain'}] ('48.143900', '-120.958800') 30 August 2003, 09:2 (1600, 1200)
File:Glacier Peak 7137b.jpg | [{'lat': 48.1324, 'lon': -120.9654, 'primary': '', 'type': 'mountain'}] ('48.132400', '-120.965400') 30 August 2003, 10:2 (1600, 1200)
File:Glacier Peak 8782s.JPG | [{'lat': 48.51821, 'lon': -121.59858, 'primary': '', 'type': 'forest'}] ('48.518210', '-121.598580') 28 October 2013, 22: (4985, 2803)
File:Glacier du Mont Mallet (Glacier du Géant) & Mont Blanc, 2010 July | [{'lat': 45.857141, 'lon': 6.916237, 'primary': '', 'type': 'camera'}] ('45.857141', '6.916237') 2010-07-31 16:58:35 (2400, 1600)
File:Hurricane Ridge mountains clouds scenic Jan 2011 NPS photo Dave T | [{'lat': 47.969611, 'lon': -123.499084, 'primary': '', 'type': 'camera'}] ('47.969611', '-123.499084') 2011-01-31 16:59 (3648, 2736)
File:Meltwater at the terminus of the Athabasca Glacier, Jasper Nation | [{'lat': 52.206806, 'lon': -117.234517, 'primary': '', 'type': 'camera'}] ('52.206806', '-117.234517') 2026-07-09 19:54:15 (5867, 4024)
File:M
Write: elements/snowpeak/src/cam.py
"""cam.py -- perspective camera over a real DEM, with earth curvature and refraction.

At 80 km the earth drops d^2/(2 R_eff) = 440 m below the tangent plane (R_eff = R/(1-k), k = 0.13
standard refraction), so a far peak sits visibly lower and its foothills sink behind the horizon.
We bend the terrain instead of the ray: every sample height is reduced by t_h^2/(2 R_eff), where
t_h is the horizontal distance from the camera.

    C = Camera(e, n, z, az, pitch, hfov, W, H)       # az: compass heading of the view (deg), pitch up +
    G = cast(T, C)                                    # dict of per-pixel buffers (see cast)
"""
import numpy as np
from numba import njit, prange
import numba

R_EFF = 6371000.0 / (1 - 0.13)


class Camera:
    def __init__(self, e, n, z, az, pitch, hfov, W, H, roll=0.0):
        self.e, self.n, self.z = float(e), float(n), float(z)
        self.az, self.pitch, self.hfov, self.W, self.H, self.roll = az, pitch, hfov, W, H, roll

    def rays(self):
        a, p, r = np.radians(self.az), np.radians(self.pitch), np.radians(self.roll)
        fwd = np.array([np.sin(a) * np.cos(p), np.cos(a) * np.cos(p), np.sin(p)])
        right = np.array([np.cos(a), -np.sin(a), 0.0])
        up = np.cross(right, fwd)
        right, up = np.cos(r) * right + np.sin(r) * up, -np.sin(r) * right + np.cos(r) * up
        f = 0.5 * self.W / np.tan(np.radians(self.hfov) / 2)
        xs = (np.arange(self.W) + 0.5 - self.W / 2)
        ys = (self.H / 2 - np.arange(self.H) - 0.5)
        X, Y = np.meshgrid(xs, ys)
        D = fwd[None, None] * f + right[None, None] * X[..., None] + up[None, None] * Y[..., None]
        return D / np.linalg.norm(D, axis=2, keepdims=True)

    def project(self, e, n, z):
        """world -> pixel (x, y); curvature applied"""
        a, p = np.radians(self.az), np.radians(self.pitch)
        fwd = np.array([np.sin(a) * np.cos(p), np.cos(a) * np.cos(p), np.sin(p)])
        right = np.array([np.cos(a), -np.sin(a), 0.0]); up = np.cross(right, fwd)
        dh2 = (e - self.e) ** 2 + (n - self.n) ** 2
        v = np.stack([e - self.e, n - self.n, z - dh2 / (2 * R_EFF) - self.z], -1)
        f = 0.5 * self.W / np.tan(np.radians(self.hfov) / 2)
        zc = v @ fwd
        return self.W / 2 + f * (v @ right) / zc, self.H / 2 - f * (v @ up) / zc

    def to_dict(self):
        return dict(e=self.e, n=self.n, z=self.z, az=self.az, pitch=self.pitch, hfov=self.hfov,
                    W=self.W, H=self.H, roll=self.roll)


@njit(cache=True, fastmath=True)
def _h(Z, x0, y0, res, px, py):
    ny, nx = Z.shape
    c = (px - x0) / res - 0.5; r = (y0 - py) / res - 0.5
    if c < 0 or c >= nx - 1 or r < 0 or r >= ny - 1:
        return -1e9
    ic = int(c); ir = int(r); fc = c - ic; fr = r - ir
    return (Z[ir, ic] * (1 - fc) * (1 - fr) + Z[ir, ic + 1] * fc * (1 - fr)
            + Z[ir + 1, ic] * (1 - fc) * fr + Z[ir + 1, ic + 1] * fc * fr)


@njit(parallel=True, cache=True, fastmath=True)
def _cast(Z, x0, y0, res, ce, cn, cz, D, far, reff, out_t):
    H, W = D.shape[0], D.shape[1]
    for i in prange(H):
        for j in range(W):
            dx, dy, dz = D[i, j, 0], D[i, j, 1], D[i, j, 2]
            hor = np.sqrt(dx * dx + dy * dy) + 1e-9
            t = 2.0; tp = 0.0; hit = -1.0
            while t < far:
                px = ce + dx * t; py = cn + dy * t
                th = t * hor
                pz = cz + dz * t + th * th / (2 * reff)      # ray height in the bent-terrain frame
                g = _h(Z, x0, y0, res, px, py)
                if g < -1e8:
                    break
                above = pz - g
                if above <= 0:
                    lo = tp; hi = t                          # bisection refine
                    for _ in range(18):
                        m = 0.5 * (lo + hi)
                        qx = ce + dx * m; qy = cn + dy * m; qh = m * hor
                        qz = cz + dz * m + qh * qh / (2 * reff)
                        if qz - _h(Z, x0, y0, res, qx, qy) <= 0:
                            hi = m
                        else:
                            lo = m
                    hit = hi
                    break
                tp = t
                step = above * 0.5
                smin = 0.35 * res + t * 0.0004
                if step < smin:
                    step = smin
                if step > 0.02 * t + 4 * res:
                    step = 0.02 * t + 4 * res
                t += step
            out_t[i, j] = hit


def cast(T, C, far=250000.0):
    """per-pixel buffers: t (m along ray, -1 = sky), e, n, z (hit, true height), r, c (grid coords)."""
    D = C.rays().astype(np.float64)
    t = np.zeros((C.H, C.W))
    _cast(T["Z"].astype(np.float32), T["x0"], T["y0"], T["res"], C.e, C.n, C.z, D, far, R_EFF, t)
    sky = t < 0
    tt = np.where(sky, 0, t)
    e = C.e + D[..., 0] * tt; n = C.n + D[..., 1] * tt
    r = (T["y0"] - n) / T["res"] - 0.5; c = (e - T["x0"]) / T["res"] - 0.5
    from scipy.ndimage import map_coordinates
    z = map_coordinates(T["Z"], [r.ravel(), c.ravel()], order=1, mode="nearest").reshape(r.shape)
    dist = np.hypot(e - C.e, n - C.n)
    return dict(t=np.where(sky, -1, t), sky=sky, e=e, n=n, z=z, r=r, c=c, dist=np.where(sky, np.inf, dist), D=D)
Bash: Check photo size
cd ~/work/pixelart-studies/elements/snowpeak/refs; python3 -c "
from PIL import Image; import glob
for f in ['Mount_Rainier_from_Seattle/commons_Mount_Rainier_from_Seattle_1_jpg_785aaa4b.jpg']:
    im=Image.open(f); print(im.size)
"

# output:
(1280, 853)
Read: elements/snowpeak/refs/Mount_Rainier_from_Seattle/commons_Mount_Rainier_from_Seattle_1_jpg_785aaa4b.jpg

elements/snowpeak/refs/Mount_Rainier_from_Seattle/commons_Mount_Rainier_from_Seattle_1_jpg_785aaa4b.jpg 14. elements/snowpeak/refs/Mount_Rainier_from_Seattle/commons_Mount_Rainier_from_Seattle_1_jpg_785aaa4b.jpg

Bash: Test raycast Rainier from Seattle
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > t_rainier_sea.py <<'EOF'
import numpy as np, dem, cam, time
from PIL import Image
ce,cn=dem.ll2utm(47.607985,-122.386236); pe,pn=dem.ll2utm(46.8529,-121.7604)
print("dist km",np.hypot(pe-ce,pn-cn)/1000,"az",np.degrees(np.arctan2(pe-ce,pn-cn)))
T=dem.load_box(min(ce,pe)-15000,max(ce,pe)+25000,min(cn,pn)-20000,max(cn,pn)+5000,30,tag="sea_rainier",ice=True)
print(T["Z"].shape)
az=np.degrees(np.arctan2(pe-ce,pn-cn))
C=cam.Camera(ce,cn,12,az,0.8,14,640,427)
t=time.time(); G=cam.cast(T,C); print("cast",time.time()-t)
d=G["dist"]; img=np.where(G["sky"],255,np.clip(d/100000*200,0,200)).astype(np.uint8)
Image.fromarray(img).resize((1280,854)).save("../stages/t_rainier_sea_depth.png")
EOF
OPENBLAS_NUM_THREADS=1 NUMBA_NUM_THREADS=4 nice -n 10 python3 t_rainier_sea.py

# output:
dist km 96.36804061641 az 149.8726159871202
(3612, 2946)
cast 9.550079107284546
Read: elements/snowpeak/stages/t_rainier_sea_depth.png

elements/snowpeak/stages/t_rainier_sea_depth.png 15. elements/snowpeak/stages/t_rainier_sea_depth.png

Bash: Write skyline extractor and test
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > skyline.py <<'EOF'
"""photo skyline: for each column, the first row (from the top) where the image leaves the sky.
Sky model: per-row median colour of a sky band; a pixel leaves the sky when its Lab distance from
the sky row colour (blurred) exceeds thr for `run` consecutive rows."""
import numpy as np
from PIL import Image, ImageFilter
from skimage.color import rgb2lab

def photo_skyline(path, W=None, thr=6.0, run=3, top_frac=0.12, blur=1.0, sky_cols=None):
    im = Image.open(path).convert("RGB")
    if W: im = im.resize((W, round(im.size[1] * W / im.size[0])), Image.LANCZOS)
    im = im.filter(ImageFilter.GaussianBlur(blur))
    lab = rgb2lab(np.asarray(im) / 255.0)
    H, Wd = lab.shape[:2]
    # sky colour as a smooth function of row, fitted on the top rows and extrapolated linearly
    k = max(4, int(H * top_frac))
    rows = np.arange(k); cols = sky_cols if sky_cols is not None else slice(None)
    prof = np.median(lab[:k, cols], axis=1)                       # k x 3
    A = np.vstack([rows, np.ones(k)]).T
    coef = np.linalg.lstsq(A, prof, rcond=None)[0]
    skyrow = np.vstack([np.arange(H), np.ones(H)]).T @ coef       # H x 3
    d = np.linalg.norm(lab - skyrow[:, None, :], axis=2)
    out = np.full(Wd, H - 1)
    big = d > thr
    for x in range(Wd):
        col = big[:, x]
        c = np.convolve(col.astype(int), np.ones(run, int), "valid")
        idx = np.nonzero(c >= run)[0]
        if len(idx): out[x] = idx[0]
    return out, d

def render_skyline(sky):
    H, W = sky.shape
    out = np.full(W, H - 1)
    for x in range(W):
        idx = np.nonzero(~sky[:, x])[0]
        if len(idx): out[x] = idx[0]
    return out
EOF
cat > t_sk.py <<'EOF'
import numpy as np, skyline
from PIL import Image, ImageDraw
p="../refs/Mount_Rainier_from_Seattle/commons_Mount_Rainier_from_Seattle_1_jpg_785aaa4b.jpg"
for thr in (3,5,8):
    s,d=skyline.photo_skyline(p,640,thr=thr)
    im=Image.open(p).convert("RGB").resize((640,427)); dr=ImageDraw.Draw(im)
    dr.line(list(zip(range(640),s)),fill=(255,0,0))
    im.save(f"../stages/t_sk_{thr}.png")
EOF
python3 t_sk.py

# output:
(Bash completed with no output)
Bash: Stack skyline tests
cd ~/work/pixelart-studies/elements/snowpeak/stages; python3 -c "
from PIL import Image
ims=[Image.open(f't_sk_{t}.png') for t in (3,5,8)]
o=Image.new('RGB',(640,427*3)); [o.paste(im,(0,i*427)) for i,im in enumerate(ims)]; o.save('t_sk_all.png')"

# output:
(Bash completed with no output)
Read: elements/snowpeak/stages/t_sk_all.png

elements/snowpeak/stages/t_sk_all.png 16. elements/snowpeak/stages/t_sk_all.png

Write: elements/snowpeak/src/fit.py
"""fit.py -- fit a photo's camera (heading, pitch, fov, roll) given its GPS position.

The camera position is fixed by EXIF GPS, so every candidate view is a re-sampling of ONE panorama
rendered from that point: fitting costs image warps, not ray casts.

Score: the photo's edge strength (Lab gradient, distance-smoothed) sampled along the render's
silhouette edges (sky boundary + depth jumps).  Robust to haze, where colour-based sky masks fail.

    P = pano(T, e, n, z, az0, az_span, el_lo, el_hi, dpp)          # dict: dist, az, el grids
    best = fit(photo_path, P, x0=(az, pitch, hfov, roll), W=480)
    view = warp(P, best, W, H)                                       # dist image for that camera
"""
import numpy as np
from scipy import ndimage as ndi
from scipy.optimize import minimize
from PIL import Image, ImageFilter
from skimage.color import rgb2lab
import cam


def pano(T, e, n, z, az0, az_span, el_lo, el_hi, dpp=0.01, far=250000.0):
    W = int(az_span / dpp); H = int((el_hi - el_lo) / dpp)
    az = az0 - az_span / 2 + (np.arange(W) + 0.5) * dpp
    el = el_hi - (np.arange(H) + 0.5) * dpp
    A, E = np.meshgrid(np.radians(az), np.radians(el))
    D = np.stack([np.sin(A) * np.cos(E), np.cos(A) * np.cos(E), np.sin(E)], -1)
    t = np.zeros((H, W))
    cam._cast(T["Z"].astype(np.float32), T["x0"], T["y0"], T["res"], e, n, z, D, far, cam.R_EFF, t)
    sky = t < 0
    tt = np.where(sky, 0, t)
    dist = np.where(sky, np.inf, tt * np.hypot(D[..., 0], D[..., 1]))
    return dict(dist=dist, sky=sky, t=t, az0=az0 - az_span / 2, el0=el_hi, dpp=dpp, e=e, n=n, z=z, D=D)


def view_dirs(p, W, H):
    az, pitch, hfov, roll = p
    C = cam.Camera(0, 0, 0, az, pitch, hfov, W, H, roll)
    D = C.rays()
    a = np.degrees(np.arctan2(D[..., 0], D[..., 1]))
    el = np.degrees(np.arcsin(np.clip(D[..., 2], -1, 1)))
    return a, el


def warp(P, p, W, H, key="dist", order=0):
    a, el = view_dirs(p, W, H)
    col = ((a - P["az0"] + 540) % 360 - 180) / P["dpp"] - 0.5
    row = (P["el0"] - el) / P["dpp"] - 0.5
    src = P[key]
    if key == "dist":
        src = np.where(np.isfinite(src), src, 1e9)
    return ndi.map_coordinates(src, [row, col], order=order, mode="nearest")


def silhouette(dist, jump=1.15):
    """edges: sky boundary and depth discontinuities (farther/nearer ratio > jump)"""
    d = dist
    up = np.roll(d, 1, 0); up[0] = d[0]
    e = (d < 1e8) & ((up >= 1e8) | (up > d * jump))
    return e


def photo_edges(path, W, blur=1.2):
    im = Image.open(path).convert("RGB")
    H = round(im.size[1] * W / im.size[0])
    im = im.resize((W, H), Image.LANCZOS).filter(ImageFilter.GaussianBlur(blur))
    lab = rgb2lab(np.asarray(im) / 255.0)
    g = np.zeros((H, W))
    for k, w in ((0, 1.0), (1, 1.5), (2, 1.5)):
        gy = ndi.sobel(lab[..., k], 0); gx = ndi.sobel(lab[..., k], 1)
        g += w * np.hypot(gx, gy)
    g /= np.percentile(g, 99) + 1e-6
    g = np.clip(g, 0, 1)
    return ndi.grey_dilation(g, size=3), H


def score(p, P, E, W, H, mask=None):
    d = warp(P, p, W, H)
    s = silhouette(d)
    if mask is not None:
        s &= mask
    n = s.sum()
    if n < 20:
        return 1.0
    return -E[s].mean()


def fit(path, P, x0, W=480, mask_fn=None, steps=(3.0, 1.0, 0.3)):
    E, H = photo_edges(path, W)
    Es = [ndi.gaussian_filter(E, s) for s in steps]
    mask = mask_fn(W, H) if mask_fn else None
    x = np.array(x0, float)
    for Ei, s in zip(Es, steps):
        r = minimize(score, x, args=(P, Ei / (Ei.max() + 1e-9), W, H, mask), method="Nelder-Mead",
                     options=dict(xatol=0.005, fatol=1e-4, maxiter=400,
                                  initial_simplex=[x] + [x + np.eye(4)[k] * np.array([0.6, 0.3, 0.6, 0.4])[k] * s / 3
                                                         for k in range(4)]))
        x = r.x
        print(f"  blur {s}: {np.round(x, 3)} score {r.fun:.4f}")
    return x, E, H


def overlay(path, P, p, W, out):
    E, H = photo_edges(path, W)
    im = Image.open(path).convert("RGB").resize((W, H), Image.LANCZOS)
    a = np.asarray(im).copy()
    s = silhouette(warp(P, p, W, H))
    s = ndi.binary_dilation(s)
    a[s] = (255, 40, 40)
    Image.fromarray(a).save(out)
Bash: Fit camera for Rainier-from-Seattle photo
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > t_fit_sea.py <<'EOF'
import numpy as np, dem, fit, time, pickle
ce,cn=dem.ll2utm(47.607985,-122.386236); pe,pn=dem.ll2utm(46.8529,-121.7604)
T=dem.load_box(min(ce,pe)-15000,max(ce,pe)+25000,min(cn,pn)-20000,max(cn,pn)+5000,30,tag="sea_rainier")
az=np.degrees(np.arctan2(pe-ce,pn-cn))
t=time.time(); P=fit.pano(T,ce,cn,12,az,24,-1.5,4.0,0.01); print("pano",time.time()-t, P["dist"].shape)
pickle.dump(P,open("../cache/pano_sea_rainier.pkl","wb"))
p="../refs/Mount_Rainier_from_Seattle/commons_Mount_Rainier_from_Seattle_1_jpg_785aaa4b.jpg"
x,E,H=fit.fit(p,P,(az,1.2,8.0,0.0),W=480,mask_fn=lambda W,H: (np.arange(H)[:,None]<0.6*H)&np.ones((1,W),bool))
print("best",x)
fit.overlay(p,P,x,960,"../stages/fit_sea_rainier.png")
EOF
NUMBA_NUM_THREADS=4 OPENBLAS_NUM_THREADS=1 nice -n 10 python3 t_fit_sea.py

# output:
pano 27.024214029312134 (550, 2400)
  blur 3.0: [ 1.50631e+02  1.15600e+00  7.95500e+00 -1.01000e-01] score -0.2156
  blur 1.0: [ 1.50631e+02  1.15500e+00  7.94300e+00 -5.70000e-02] score -0.2276
  blur 0.3: [ 1.50631e+02  1.15500e+00  7.94400e+00 -5.00000e-02] score -0.2310
best [ 1.50630756e+02  1.15484724e+00  7.94379071e+00 -5.03404401e-02]
Read: elements/snowpeak/stages/fit_sea_rainier.png

elements/snowpeak/stages/fit_sea_rainier.png 17. elements/snowpeak/stages/fit_sea_rainier.png

Bash: Add tree and bare landcover to DEM loader
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("dem.py").read()
s=s.replace('''        d = np.load(fn)
        return dict(Z=d["Z"], ice=d["ice"], x0=float(d["x0"]), y0=float(d["y0"]), res=float(d["res"]))''','''        d = np.load(fn)
        if "trees" in d.files:
            return dict(Z=d["Z"], ice=d["ice"], trees=d["trees"], bare=d["bare"], x0=float(d["x0"]),
                        y0=float(d["y0"]), res=float(d["res"]))''')
s=s.replace('''    I = np.zeros((H, W), np.float32)
    if ice:
        I[:] = np.nan
        I = _mosaic_class(sorted(glob.glob(DATA + "/landcover/ESA_WorldCover_*.tif")), I, transform)
    np.savez_compressed(fn, Z=Z, ice=I, x0=e0, y0=n1, res=res)
    return dict(Z=Z, ice=I, x0=float(e0), y0=float(n1), res=float(res))''','''    lc = sorted(glob.glob(DATA + "/landcover/ESA_WorldCover_*.tif"))
    I, TR, BA = [np.full((H, W), np.nan, np.float32) for _ in range(3)]
    I = _mosaic_class(lc, I, transform, 70)       # snow and ice
    TR = _mosaic_class(lc, TR, transform, 10)     # tree cover
    BA = _mosaic_class(lc, BA, transform, 60)     # bare / sparse vegetation
    np.savez_compressed(fn, Z=Z, ice=I, trees=TR, bare=BA, x0=e0, y0=n1, res=res)
    return dict(Z=Z, ice=I, trees=TR, bare=BA, x0=float(e0), y0=float(n1), res=float(res))''')
open("dem.py","w").write(s)
EOF
grep -n "def _mosaic_class" dem.py

# output:
80:def _mosaic_class(files, dst, transform, cls=70):
Write: elements/snowpeak/src/sun.py
"""sun.py -- solar azimuth/elevation (NOAA simplified algorithm, ~0.5 deg), and sun direction vectors."""
import numpy as np
from datetime import datetime, timezone, timedelta


def position(lat, lon, when_utc):
    """when_utc: datetime (UTC). returns (azimuth deg from N clockwise, elevation deg)"""
    doy = when_utc.timetuple().tm_yday
    hour = when_utc.hour + when_utc.minute / 60 + when_utc.second / 3600
    g = 2 * np.pi / 365 * (doy - 1 + (hour - 12) / 24)
    eqt = 229.18 * (0.000075 + 0.001868 * np.cos(g) - 0.032077 * np.sin(g) - 0.014615 * np.cos(2 * g)
                    - 0.040849 * np.sin(2 * g))
    decl = (0.006918 - 0.399912 * np.cos(g) + 0.070257 * np.sin(g) - 0.006758 * np.cos(2 * g)
            + 0.000907 * np.sin(2 * g) - 0.002697 * np.cos(3 * g) + 0.00148 * np.sin(3 * g))
    tst = hour * 60 + eqt + 4 * lon
    ha = np.radians(tst / 4 - 180)
    la = np.radians(lat)
    cz = np.sin(la) * np.sin(decl) + np.cos(la) * np.cos(decl) * np.cos(ha)
    zen = np.arccos(np.clip(cz, -1, 1))
    az = np.degrees(np.arctan2(np.sin(ha), np.cos(ha) * np.sin(la) - np.tan(decl) * np.cos(la))) + 180
    return az % 360, 90 - np.degrees(zen)


def local(lat, lon, y, mo, d, h, mi=0, utc_offset=-7):
    """local clock time (PDT = -7, PST = -8)"""
    t = datetime(y, mo, d, h, mi, tzinfo=timezone.utc) - timedelta(hours=utc_offset)
    return position(lat, lon, t)


def vec(az, el):
    a, e = np.radians(az), np.radians(el)
    return np.array([np.sin(a) * np.cos(e), np.cos(a) * np.cos(e), np.sin(e)])


if __name__ == "__main__":
    print(local(47.608, -122.386, 2009, 8, 1, 20, 52))
Bash: Test sun position
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 sun.py

# output:
(299.6558847349047, -1.8184742993878587)
Bash: Locate candidate Rainier photo files
cd ~/work/pixelart-studies/elements/snowpeak/refs; ls Mount_Rainier_from_Sunrise Mount_Rainier_September | grep -i "sunrise_16\|silver\|2020\|6909\|410"; ls */ | grep -i "6909"

# output:
commons_Mount_Rainier_from_the_Silver_Queen_Peak_jpg_926c3740.jpg
commons_Rainier20200906_jpg_b01edb80.jpg
openverse_Silver_Falls_Mount_Rainier_National_Park_Explore_bb7d1761.jpg
commons_Mount_Rainier_and_Sunrise_163a05fa_3224_4103_937_af636410.jpg
commons_Mount_Rainier_from_the_Silver_Queen_Peak_jpg_926c3740.jpg
commons_Peak_of_Mount_Rainier_from_Washington_SR_410_jus_0b710789.jpg
commons_Mount_Rainier_6909h_JPG_89cb3034.jpg
Read: elements/snowpeak/refs/Mount_Rainier_from_Sunrise/commons_Mount_Rainier_and_Sunrise_163a05fa_3224_4103_937_af636410.jpg

elements/snowpeak/refs/Mount_Rainier_from_Sunrise/commons_Mount_Rainier_and_Sunrise_163a05fa_3224_4103_937_af636410.jpg 18. elements/snowpeak/refs/Mount_Rainier_from_Sunrise/commons_Mount_Rainier_and_Sunrise_163a05fa_3224_4103_937_af636410.jpg

Read: elements/snowpeak/refs/Mount_Rainier_September/commons_Rainier20200906_jpg_b01edb80.jpg

elements/snowpeak/refs/Mount_Rainier_September/commons_Rainier20200906_jpg_b01edb80.jpg 19. elements/snowpeak/refs/Mount_Rainier_September/commons_Rainier20200906_jpg_b01edb80.jpg

Read: elements/snowpeak/refs/Mount_Rainier_September/commons_Mount_Rainier_from_the_Silver_Queen_Peak_jpg_926c3740.jpg

elements/snowpeak/refs/Mount_Rainier_September/commons_Mount_Rainier_from_the_Silver_Queen_Peak_jpg_926c3740.jpg 20. elements/snowpeak/refs/Mount_Rainier_September/commons_Mount_Rainier_from_the_Silver_Queen_Peak_jpg_926c3740.jpg

Write: elements/snowpeak/NOTEBOOK.md
# Snowpeak notebook

Working thread, newest at the bottom. Images referenced by path under this folder.

## 2026-09-26, day 1

**Reading.** observation TUTORIAL (three-panel sheet, ramp coordinate with a jump, clustered dither),
willow/ground/aerial tutorials, aerial how-to 4 (far view: bands, collapse, cavity tone) and 5
(mountain ground: snow drawn in its shadows; far snow was salt-and-pepper, the weakest cell), the
snow POST (Ferrari: "on mountains the rock defines the snow"; few big shapes; haze must not kill the
subject's rock/snow contrast), rock LESSONS (Dragontail camera fit, IoU 0.94; "value statement taken
from the photo"), naturalist geo 03 and 10.

What the studio already knows that I must not relearn:

- far snow as thresholded noise = salt-and-pepper (aerial). Blur before threshold; far snow needs its own simplification.
- snow is drawn by its shadows; lit snow flat, 1-2 values; shaded snow L* ~76 vs shaded rock ~51 (Sawtooth).
- haze: black point rises, range collapses, b* goes blue (Blue Ridge numbers).
- mountains: build from real profiles, not noise. Here I can do better: real DEMs.

**Data on disk** (~/work/voxsim-data): USGS 1/3" DEM tiles covering all four peaks and Seattle;
ESA WorldCover 2021 (class 70 snow/ice = glacier mask, 10 trees, 60 bare). `src/dem.py` reprojects
any box onto UTM 10N. Quicklook: `img/quicklook_dem_ice.png` (Rainier, Stuart, Olympus, Glacier Peak).

**References.** 20 queries via imgfetch (`refs/`). Commons metadata with camera GPS for the key ones
(`refs/commons_meta.json`). Answer keys chosen:

- Rainier 96 km: "Mount Rainier from Seattle (1)", 2009-08-01 20:52, from Elliott Bay (hazy August evening).
- Rainier 16 km, September minimum: "Rainier20200906", 2020-09-06 19:37, west side, alpenglow.
- Rainier 13 km, late September: "Mount Rainier and Sunrise", from Sunrise (Emmons Glacier, Little Tahoma).
- Rainier 20 km, early July: "from the Silver Queen Peak".
- Stuart 3-6 km: "Mount Stuart 7814p" (July 2008), "sun & fog from near Longs Pass" (Oct 2024).
- Dragontail/Colchuck 2 km, September: "Dragontail Peak, Colchuck Glacier, and Colchuck Peak" (2018-09-03).
- Olympus 26 km: Hurricane Ridge photos (camera 47.9696 -123.4991).
- Olympics 80-100 km: "from Fremont, Seattle" (Jan 2015), "14th Ave E" (Dec 2014).
- Glacier Peak 12-50 km: 7118/7137b (Aug 2003), 8782s (Oct 2013), from Sloan Peak.

**Camera fit.** `src/cam.py` casts rays over the DEM with earth curvature + refraction
(R_eff = R/(1-0.13); at 96 km the terrain drops 630 m). `src/fit.py`: because GPS fixes the camera
position, render one panorama from there and fit heading/pitch/fov/roll by warping it. Score =
photo Lab-edge strength along the render's silhouette edges (colour sky masks failed in the haze:
`stages/t_sk_all.png`, the sky gradient breaks a linear sky model). First fit, Rainier from Elliott
Bay: az 150.63, pitch 1.155, hfov 7.94, roll -0.05. The silhouette lands on the photo's summit,
left shoulder and long right ridge (`stages/fit_sea_rainier.png`). The near shore is too low in the
render: trees (~40 m) are not in a bare-earth DEM.
Bash: Fit camera for the September west-side Rainier photo
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > fitcam.py <<'EOF'
"""fitcam.py <name> : fit and store camera for a named answer-key photo (see SHOTS)."""
import numpy as np, dem, fit, sys, json, pickle, os, time
REF="../refs/"
SHOTS={
 "sea_rainier": dict(photo=REF+"Mount_Rainier_from_Seattle/commons_Mount_Rainier_from_Seattle_1_jpg_785aaa4b.jpg",
     cam=(47.607985,-122.386236), agl=None, z=12, peak="rainier", res=30, pad=(15000,25000,20000,5000),
     fov=8.0, span=24, el=(-1.5,4.0), time=(2009,8,1,20,52), maskfrac=0.6),
 "rainier_w_sept": dict(photo=REF+"Mount_Rainier_September/commons_Rainier20200906_jpg_b01edb80.jpg",
     cam=(46.909553,-121.966194), agl=2, peak="rainier", res=15, pad=(5000,12000,12000,5000),
     fov=30, span=70, el=(-8,14), time=(2020,9,6,19,37), maskfrac=0.8),
 "rainier_ne_july": dict(photo=REF+"Mount_Rainier_September/commons_Mount_Rainier_from_the_Silver_Queen_Peak_jpg_926c3740.jpg",
     cam=(46.9281559,-121.5043864), agl=2, peak="rainier", res=15, pad=(12000,5000,12000,5000),
     fov=30, span=70, el=(-10,14), time=(2015,7,1,12,22), maskfrac=0.7),
 "rainier_sunrise": dict(photo=REF+"Mount_Rainier_from_Sunrise/commons_Mount_Rainier_and_Sunrise_163a05fa_3224_4103_937_af636410.jpg",
     cam=(46.91842222,-121.62129167), agl=2, peak="rainier", res=15, pad=(8000,5000,10000,5000),
     fov=60, span=100, el=(-20,20), time=(2017,9,27,12,0), maskfrac=0.62),
}
def box(s):
    ce,cn=dem.ll2utm(*s["cam"]); lat,lon,_=dem.PEAKS[s["peak"]]; pe,pn=dem.ll2utm(lat,lon)
    a,b,c,d=s["pad"]
    return ce,cn,pe,pn,(min(ce,pe)-a,max(ce,pe)+b,min(cn,pn)-c,max(cn,pn)+d)
def setup(name):
    s=SHOTS[name]; ce,cn,pe,pn,bx=box(s)
    T=dem.load_box(*[round(v) for v in bx],s["res"],tag=name)
    z=s.get("z") or float(dem.sample(T,ce,cn)[0])+s["agl"]
    return s,T,ce,cn,pe,pn,z
if __name__=="__main__":
    name=sys.argv[1]; s,T,ce,cn,pe,pn,z=setup(name)
    az=np.degrees(np.arctan2(pe-ce,pn-cn)); d=np.hypot(pe-ce,pn-cn)
    pitch0=np.degrees(np.arctan2(dem.PEAKS[s["peak"]][2]-z-d*d/(2*6371000/0.87),d))*0.6
    fn=f"../cache/pano_{name}.pkl"
    if os.path.exists(fn): P=pickle.load(open(fn,"rb"))
    else:
        t=time.time(); P=fit.pano(T,ce,cn,z,az,s["span"],s["el"][0],s["el"][1],0.02 if s["fov"]>20 else 0.01)
        print("pano",time.time()-t); pickle.dump(P,open(fn,"wb"))
    x0=[float(v) for v in sys.argv[2:6]] if len(sys.argv)>5 else (az,pitch0,s["fov"],0.0)
    mf=s["maskfrac"]
    x,E,H=fit.fit(s["photo"],P,x0,W=480,mask_fn=lambda W,H:(np.arange(H)[:,None]<mf*H)&np.ones((1,W),bool))
    print(name,"dist km %.1f z %.0f"%(d/1000,z),"best",x)
    fit.overlay(s["photo"],P,x,960,f"../stages/fit_{name}.png")
    cams=json.load(open("../cache/cams.json")) if os.path.exists("../cache/cams.json") else {}
    cams[name]=dict(e=ce,n=cn,z=z,az=x[0],pitch=x[1],hfov=x[2],roll=x[3])
    json.dump(cams,open("../cache/cams.json","w"),indent=1)
EOF
NUMBA_NUM_THREADS=4 OPENBLAS_NUM_THREADS=1 nice -n 10 python3 fitcam.py rainier_w_sept

# output:
Exit code 1
object address  : 0x135b451c0
object refcount : 3
object type     : 0x1030d4d78
object type name: OSError
object repr     : OSError(28, 'No space left on device')
lost sys.stderr
Traceback (most recent call last):
pano 21.93078112602234
  blur 3.0: [112.467   9.6    27.137   0.333] score -0.5157
  blur 1.0: [112.665   9.604  27.158   0.321] score -0.5111
  blur 0.3: [112.671   9.603  27.145   0.316] score -0.5137
rainier_w_sept dist km 16.9 z 1069 best [112.67059483   9.60346675  27.14518261   0.31631268]
Bash: Check disk space and cache sizes
df -h ~ | tail -1; du -sh ~/work/pixelart-studies/elements/snowpeak/cache/*; ls -la ~/work/pixelart-studies/elements/snowpeak/stages | head

# output:
/dev/disk3s5   926Gi   890Gi    63Mi   100%     10M  647k   94%   /System/Volumes/Data
3.4M    ~/work/pixelart-studies/elements/snowpeak/cache/dem_glacierpeak_10_20.npz
3.4M    ~/work/pixelart-studies/elements/snowpeak/cache/dem_olympus_10_20.npz
3.5M    ~/work/pixelart-studies/elements/snowpeak/cache/dem_rainier_10_20.npz
 13M    ~/work/pixelart-studies/elements/snowpeak/cache/dem_rainier_w_sept_15.npz
 35M    ~/work/pixelart-studies/elements/snowpeak/cache/dem_sea_rainier_30.npz
3.3M    ~/work/pixelart-studies/elements/snowpeak/cache/dem_stuart_10_20.npz
151M    ~/work/pixelart-studies/elements/snowpeak/cache/pano_rainier_w_sept.pkl
 52M    ~/work/pixelart-studies/elements/snowpeak/cache/pano_sea_rainier.pkl
total 5336
drwxr-xr-x  8 user  staff     256 Sep 26 12:55 .
drwxr-xr-x  9 user  staff     288 Sep 26 12:57 ..
-rw-r--r--  1 user  staff  709690 Sep 26 12:55 fit_sea_rainier.png
-rw-r--r--  1 user  staff   21289 Sep 26 12:53 t_rainier_sea_depth.png
-rw-r--r--  1 user  staff  331636 Sep 26 12:53 t_sk_3.png
-rw-r--r--  1 user  staff  330774 Sep 26 12:53 t_sk_5.png
-rw-r--r--  1 user  staff  331146 Sep 26 12:53 t_sk_8.png
-rw-r--r--  1 user  staff  996374 Sep 26 12:53 t_sk_all.png
Bash: Free disk and store compact panoramas
cd ~/work/pixelart-studies/elements/snowpeak; rm cache/pano_*.pkl stages/t_sk_[358].png; python3 - <<'EOF'
s=open("src/fit.py").read()
s=s.replace('''    dist = np.where(sky, np.inf, tt * np.hypot(D[..., 0], D[..., 1]))
    return dict(dist=dist, sky=sky, t=t, az0=az0 - az_span / 2, el0=el_hi, dpp=dpp, e=e, n=n, z=z, D=D)''','''    dist = np.where(sky, np.inf, tt * np.hypot(D[..., 0], D[..., 1])).astype(np.float32)
    return dict(dist=dist, az0=az0 - az_span / 2, el0=el_hi, dpp=dpp, e=e, n=n, z=z)''')
open("src/fit.py","w").write(s)
s=open("src/fitcam.py").read()
s=s.replace('fn=f"../cache/pano_{name}.pkl"','fn=f"../cache/pano_{name}.npz"')
s=s.replace('if os.path.exists(fn): P=pickle.load(open(fn,"rb"))','if os.path.exists(fn): P={k:(v if v.ndim else v.item()) for k,v in np.load(fn).items()}')
s=s.replace('pickle.dump(P,open(fn,"wb"))','np.savez(fn,**P)')
open("src/fitcam.py","w").write(s)
EOF
df -h ~ | tail -1

# output:
/dev/disk3s5   926Gi   885Gi   5.4Gi   100%     10M   56M   15%   /System/Volumes/Data
Bash: Re-run west-side camera fit
cd ~/work/pixelart-studies/elements/snowpeak/src; NUMBA_NUM_THREADS=4 OPENBLAS_NUM_THREADS=1 nice -n 10 python3 fitcam.py rainier_w_sept 2>&1 | tail -5; ls -la ../cache | grep pano

# output:
pano 12.43351697921753
  blur 3.0: [112.467   9.6    27.137   0.333] score -0.5157
  blur 1.0: [112.665   9.604  27.158   0.321] score -0.5111
  blur 0.3: [112.671   9.603  27.145   0.316] score -0.5137
rainier_w_sept dist km 16.9 z 1069 best [112.67059483   9.60346675  27.14518261   0.31631268]
-rw-r--r--   1 user  staff  15401726 Sep 26 12:59 pano_rainier_w_sept.npz
Read: elements/snowpeak/stages/fit_rainier_w_sept.png

elements/snowpeak/stages/fit_rainier_w_sept.png 21. elements/snowpeak/stages/fit_rainier_w_sept.png

Bash: Enlarge DEM box and refit
cd ~/work/pixelart-studies/elements/snowpeak/src; sed -i '' 's/pad=(5000,12000,12000,5000)/pad=(8000,25000,20000,15000)/; s/pad=(12000,5000,12000,5000)/pad=(25000,8000,20000,15000)/; s/pad=(8000,5000,10000,5000)/pad=(20000,8000,20000,15000)/' fitcam.py; rm ../cache/pano_rainier_w_sept.npz ../cache/dem_rainier_w_sept_15.npz; NUMBA_NUM_THREADS=4 OPENBLAS_NUM_THREADS=1 nice -n 10 python3 fitcam.py rainier_w_sept 2>&1 | tail -4

# output:
blur 3.0: [112.931  10.959  30.125  -4.756] score -0.5254
  blur 1.0: [112.949  10.964  30.251  -4.651] score -0.5252
  blur 0.3: [112.983  10.965  30.253  -4.654] score -0.5315
rainier_w_sept dist km 16.9 z 1069 best [112.98341771  10.9651166   30.25298529  -4.65423385]
Read: elements/snowpeak/stages/fit_rainier_w_sept.png

elements/snowpeak/stages/fit_rainier_w_sept.png 22. elements/snowpeak/stages/fit_rainier_w_sept.png

Bash: Visualize west panorama depth
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 -c "
import numpy as np; from PIL import Image
P=np.load('../cache/pano_rainier_w_sept.npz'); d=P['dist']; print(d.shape, np.nanmin(d))
im=np.where(np.isfinite(d),np.clip(np.log(np.maximum(d,1))/np.log(40000)*255,0,255),255).astype(np.uint8)
Image.fromarray(im).save('../stages/t_pano_w.png')
"

# output:
(1100, 3500) 5.637467
Read: elements/snowpeak/stages/t_pano_w.png

elements/snowpeak/stages/t_pano_w.png 23. elements/snowpeak/stages/t_pano_w.png

Bash: Snap camera to local high point and refit
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("fitcam.py").read()
s=s.replace('''    z=s.get("z") or float(dem.sample(T,ce,cn)[0])+s["agl"]
    return s,T,ce,cn,pe,pn,z''','''    if s.get("snap"):     # photographers stand on high points: move to the highest cell within snap m
        R=s["snap"]; r0=int((T["y0"]-cn)/T["res"]); c0=int((ce-T["x0"])/T["res"]); k=int(R/T["res"])
        win=T["Z"][r0-k:r0+k+1,c0-k:c0+k+1]; yy,xx=np.mgrid[-k:k+1,-k:k+1]
        win=np.where(yy**2+xx**2<=k*k,win,-1e9); i,j=np.unravel_index(np.argmax(win),win.shape)
        ce+= (j-k)*T["res"]; cn-=(i-k)*T["res"]
    z=s.get("z") or float(dem.sample(T,ce,cn)[0])+s["agl"]
    return s,T,ce,cn,pe,pn,z''')
s=s.replace('cam=(46.909553,-121.966194), agl=2,','cam=(46.909553,-121.966194), agl=2, snap=300,')
open("fitcam.py","w").write(s)
EOF
rm ../cache/pano_rainier_w_sept.npz; NUMBA_NUM_THREADS=4 nice -n 10 python3 fitcam.py rainier_w_sept 2>&1 | tail -2

# output:
blur 0.3: [113.24   11.239  30.763  -3.653] score -0.6635
rainier_w_sept dist km 16.6 z 1109 best [113.23966274  11.23905981  30.76308583  -3.65290343]
Bash: Read the new model-systems library
cd ~/work/pixelart-studies/library; cat README.md | head -80; ls terrain snow rock 2>/dev/null | head -40

# output:
# The studio library: model systems

The elk and deer student succeeded largely because it didn't start from raw photos. It started from finished analysis:

- Muybridge's frame-by-frame plates;
- the naturalist's "four things that make a 6-pixel elk read".

Natural scientists used to be trained to draw: a geologist could draw a flake, an outcrop or a fold with economy and truth. Naturalist painting guides were built on the same scientific understanding. This library collects those traditions so studies can start where the experts finished, instead of doing a geology PhD from photographs.

**How to use it:**

- Before drawing, find your subject's model system here (or online, below) and read it closely.
- Then build on it, the way the elk student built on Muybridge.
- Open the PDFs and look at the plates; the drawings are the point.

Refetch anything missing with `./fetch.sh` (resumable). Files are also mirrored in S3 under `voxsim/shared/library/`.

## On disk (open access)

| Domain | File | What it gives you |
|---|---|---|
| Terrain, landforms | `terrain/blockdiagramsoth0000armi_s8g3.pdf` | **Lobeck, *Block Diagrams* (1924).** How geologists draw landforms in 3-D: every landform type as a block diagram, with the drawing method. |
| Terrain, landforms | `terrain/2015.205632.Geomorphology.pdf` | **Lobeck, *Geomorphology* (1939).** Landforms explained through his own drawings: glacial troughs, cirques, rivers, coasts, volcanoes. |
| Terrain, landforms | `terrain/physicalgeograp00davigoog.pdf` | **W. M. Davis, *Physical Geography* (1898).** Davis's block diagrams: how landscapes form and age. |
| Terrain, rock at map scale | `terrain/2015.119840.Cartographic-Relief-Presentation.pdf` | **Imhof, *Cartographic Relief Presentation*.** The rigorous method for showing terrain by light, and Swiss rock drawing: cliffs rendered by structure with a few strokes. Probably the single most useful text for rock at landscape and region scale. |
| Rock, mountains | `rock/modernpainters04rusk.pdf` | **Ruskin, *Modern Painters IV: Of Mountain Beauty* (1856).** A painter's analysis of rock types, cleavage, crests, banks and stones, written for artists and illustrated with his own drawings. |
| Rock, strata at distance | `rock/holmes/*.jpg` | **William Henry Holmes, plates for the Grand Cañon Atlas (1882).** The masterpieces of American scientific landscape drawing: stratified cliffs and plateaus at great distance in a few values and stepped strata lines. Directly relevant to the Yakima basalt. |
| Rock, structure | `rock/reportongeology05duttgoog.pdf` | **G. K. Gilbert, *Geology of the Henry Mountains* (1877).** A classic illustrated geological report. |
| Rock, a flake | `rock/cifa_lithic_illustration_guide.pdf` | **CIfA, *The Illustration of Lithic Artefacts*.** How archaeologists draw a knapped flake: conventions for ridges, ripples and light. The drawing-a-flake skill itself. |
| Trees | `trees/artisticanatomyo1915cole.pdf` | **Rex Vicat Cole, *The Artistic Anatomy
Bash: Extract Imhof text and find snow/glacier sections
cd ~/work/pixelart-studies/library/terrain; which pdftotext; ls -la 2015.119840.Cartographic-Relief-Presentation.pdf; pdftotext 2015.119840.Cartographic-Relief-Presentation.pdf (tmp) 2>&1 | tail -2; wc -c (tmp) grep -n -i "glacier\|firn\|snow" (tmp) | head -60

# output:
/opt/homebrew/bin/pdftotext
-rw-r--r--  1 user  staff  28704969 Sep 26 13:00 2015.119840.Cartographic-Relief-Presentation.pdf
     405 (tmp)
Bash: Get page counts and extract text layers
cd ~/work/pixelart-studies/library; pdfinfo terrain/2015.119840.Cartographic-Relief-Presentation.pdf | egrep "Pages|Title"; pdfinfo snow/glaciersalpsbei01tyndgoog.pdf | grep Pages; pdfinfo rock/modernpainters04rusk.pdf | grep Pages; pdftotext rock/modernpainters04rusk.pdf (tmp) wc -c (tmp) pdftotext snow/glaciersalpsbei01tyndgoog.pdf (tmp) wc -c (tmp)

# output:
Pages:           405
Pages:           495
Pages:           560
  866021 (tmp)
    3468 (tmp)
Read: library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 24. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 25. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 26. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 27. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 28. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 29. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 30. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 31. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

Read: library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 32. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 33. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

Read: library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 34. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 35. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 36. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

Read: library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 37. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 38. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 39. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 40. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

Read: library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 41. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 42. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 43. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 44. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 45. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf 46. library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf

Bash: Search Ruskin text for snow passages
grep -n -i "snow" (tmp) | head -80

# output:
3230:authority for using snow-whiteness as a type of
3232:' white as snow An incidental image has no
3242:snow forinher
5238:and the right hand of Christ first strewed the snow
5629:above the snow-fields; then piercing it with strange
6316:snow, and the Venus de Medicis would have looked
6868:incapable streams ; the snow water oozing through
6879:wander continually, and the snow lies in wasted and
7186:their blue precipices and cloud-like snows : why
7484:snows,
8667:and pyramids of barren rock sprinkled with snow.
8672:pyramid 1, of the height of Snowdon or Ben Lomond,
8676:summits, loaded with deep snow, terminates the
8791:of the snows, not at any eminent point, being caught
8874:was struck from their pinnacles, and every snowwreath which slipped from their ledges, would descend at once upon the inhabitable ground, over
8877:course of their fall, both the stones and the snow
8881:Besides this, the masses of snow, cast down at once
8884:river for a month or six weeks. The snow being
8889:from the high snows. The Rhone under such circumstances would hardly be larger at Lyons than
8894:rocks and the sliding snow of the high peaks, instead
8908:the masses of snow heaped upon the ledge above
9046:form at b. But at this point the snow would begin
9631:this glacier, or by the snow which covers it, till
10730:by falls of winter snow, and stones from the glacier
10811:which are of such hard rock that the fall of snow
11672:snowy side, or upper surface, in the right-hand figure
11676:as it is not only perpendicular, but assuredly overhangs, both at this snow-lighted angle and at the
11800:arrow-heads through the snow, where their ridges
11847:hills in their snowy, cold, grey-haired old age, at
11869:the snowy hills should lie bowed like flocks of sheep,
11876:rose, new cressets upon the points of snow that
11892:only in the midst of the higher snow fields, are not
11895:even beyond Turner's. The waves of snow, when it
11905:compared with their snow, they are so dark that a
11907:seconds to draw the snow shadows rightly, will
11909:therefore, to paint a snowy mountain properly, we
11914:interesting resemblance of snow effect, and a true
11932:works, was always to treat the snowy mountains
13842:but reached the snow on the rock opposite. It then became
16018:bells ringing sweetly, and the snowy mountains
16799:the under side, snow-white on the other with limedust, gather around them a melancholy hum of flies.
16807:depths of burning air, and the crescents of snow
17099:purple rocks and eternal snows above; this excellence not being in any wise a matter referable to
17288:level-laid between dome and dome of snow; of these
17312:of snow, and vaults of purple traversed by the
17591:sincerely religious ones continually used Alpine distances, bright with snow. In like manner Giotto,
18525:1 Taurus snow \ and the 4 frosty Caucasus ', are used
18529:Bush on his host, as doth the melted snow
19240:thoughts, as we watch the chains of the snowy mountains rise on th
Bash: Read Ruskin passages on snow painting
sed -n 11880,11940p (tmp) sed -n 8895,8915p (tmp) sed -n 11790,11805p (tmp)

# output:
III. PRECIPICES

237

§ 18. I have thought it worth while, for the sake
of these lessons, and the other interests connected
with them, to lead the reader thus far into the
examination of the principal precipices among the
Alps, although, so far as our immediate purposes are
concerned, the inquiry cannot be very fruitful or
helpful to us. For rocks of this kind, being found
only in the midst of the higher snow fields, are not
only out of the general track of the landscape painter,
but are for the most part quite beyond his power —
even beyond Turner's. The waves of snow, when it
becomes a principal element in mountain form, are at
once so subtle in tone, and so complicated in curve
and fold, that no skill will express them, so as to
keep the whole luminous mass in anything like a
true relation to the rock darkness. For the distant
rocks of the upper peaks are themselves, when in
light, paler than white paper, and their true size
and relation to near objects cannot be exhibited
unless they are painted in the palest tones. Yet, as
compared with their snow, they are so dark that a
daguerreotype taken for the proper number of
seconds to draw the snow shadows rightly, will
always represent the rocks as coal-black. In order,
therefore, to paint a snowy mountain properly, we
should need a light as much brighter than white
paper as white paper is brighter than charcoal. So
that although it is possible, with deep blue sky, and
purple rocks, and blue shadows, to obtain a very
interesting resemblance of snow effect, and a true
one up to a certain point (as in the best examples
of the body-colour drawings sold so extensively in
Switzerland), it is not possible to obtain any of those
refinements of form and gradation which a great
artist's eye
Turner
felt that,work
among
highest
hills,requires.
no serious
or perfect
couldthese
be
done; and although in one or two of his vignettes
(already referred to in the first volume) he showed
his knowledge of them, his practice, in larger
works, was always to treat the snowy mountains
merely as a far-away white cloud, concentrating the

238

RESULTING

FORMS


176

SCULPTURE

OF MOUNTAINS

[part v

of being dashed at once to the vales, are caught upon
the desolate shelves or shoulders which everywhere
surround the central crests. The soft banks which
terminate these shelves, traversed by no falling fragments, clothe themselves with richest wood; while
the masses of snow heaped upon the ledge above
them, in a climate neither so warm as to thaw them
quickly in the spring, nor so cold as to protect them
from all the power of the summer sun, either form
themselves into glaciers, or remain in slowly wasting
fields even to the close of the year — in either case
supplying constant, abundant, and regular streams
to the villages and pastures beneath, and to the rest
below the seeming summit, is the rock marked g
in fig. 33. But the structure of the mass, and the

chap, xvi]

III. PRECIPICES

235
long ranges of horizontal, or n
Read: elements/snowpeak/stages/fit_rainier_w_sept.png

elements/snowpeak/stages/fit_rainier_w_sept.png 47. elements/snowpeak/stages/fit_rainier_w_sept.png

Bash: Add summit control-point init and refit
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("fitcam.py").read()
s=s.replace("fov=30, span=70, el=(-8,14), time=(2020,9,6,19,37), maskfrac=0.8),",
 "fov=30, span=70, el=(-8,14), time=(2020,9,6,19,37), maskfrac=0.8, summit=(455/960,252/640), fovs=(15,40)),")
s=s.replace("pad=(8000,25000,20000,15000)","pad=(10000,40000,30000,25000)")
s=s.replace('''    x0=[float(v) for v in sys.argv[2:6]] if len(sys.argv)>5 else (az,pitch0,s["fov"],0.0)''','''    x0=[float(v) for v in sys.argv[2:6]] if len(sys.argv)>5 else (az,pitch0,s["fov"],0.0)
    if "summit" in s and len(sys.argv)<=5:   # control point: put the summit on its photo pixel, scan fov
        lat,lon,zs=dem.PEAKS[s["peak"]]; ez,nz=dem.ll2utm(lat,lon); dd=np.hypot(ez-ce,nz-cn)
        az_s=np.degrees(np.arctan2(ez-ce,nz-cn)); el_s=np.degrees(np.arctan2(zs-z-dd*dd/(2*6371000/0.87),dd))
        E,H=fit.photo_edges(s["photo"],480); Eb=fit.ndi.gaussian_filter(E,2)
        mf=s["maskfrac"]; M=(np.arange(H)[:,None]<mf*H)&np.ones((1,480),bool); best=None
        for fv in np.linspace(*s["fovs"],26):
            f=0.5/np.tan(np.radians(fv)/2); ar=H/480
            dx=np.degrees(np.arctan((s["summit"][0]-0.5)/f)); dy=np.degrees(np.arctan((0.5-s["summit"][1])*ar/f))
            p=(az_s-dx,el_s-dy,fv,0.0); sc=fit.score(p,P,Eb,480,H,M)
            if best is None or sc<best[0]: best=(sc,p)
        x0=best[1]; print("control-point init",np.round(x0,3),best[0])''')
open("fitcam.py","w").write(s)
EOF
rm -f ../cache/pano_rainier_w_sept.npz ../cache/dem_rainier_w_sept_15.npz; NUMBA_NUM_THREADS=4 nice -n 10 python3 fitcam.py rainier_w_sept 2>&1 | tail -5

# output:
control-point init [111.656   9.764  19.      0.   ] -0.40145714686404066
  blur 3.0: [110.941  10.695  24.667  -1.881] score -0.6537
  blur 1.0: [111.045  10.711  24.132  -2.861] score -0.6828
  blur 0.3: [111.099  10.736  24.088  -2.926] score -0.6958
rainier_w_sept dist km 16.6 z 1109 best [111.09947467  10.73613011  24.08809448  -2.92579866]
Read: elements/snowpeak/stages/fit_rainier_w_sept.png

elements/snowpeak/stages/fit_rainier_w_sept.png 48. elements/snowpeak/stages/fit_rainier_w_sept.png

Bash: Inspect west panorama and DEM box
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 -c "
import numpy as np; from PIL import Image
P=np.load('../cache/pano_rainier_w_sept.npz'); d=P['dist']; print(d.shape, float(P['z']))
im=np.where(np.isfinite(d),np.clip(np.log(np.maximum(d,1))/np.log(60000)*255,0,255),255).astype(np.uint8)
Image.fromarray(im).save('../stages/t_pano_w.png')
T=np.load('../cache/dem_rainier_w_sept_15.npz'); print(T['Z'].shape, T['Z'].max(), float(T['x0']),float(T['y0']))
"

# output:
(1100, 3500) 1108.611328125
(4071, 4385) 4391.619 568728.0 5220632.0
Read: elements/snowpeak/stages/t_pano_w.png

elements/snowpeak/stages/t_pano_w.png 49. elements/snowpeak/stages/t_pano_w.png

Bash: Snap to highest point within 1.5 km and refit
cd ~/work/pixelart-studies/elements/snowpeak/src; sed -i '' 's/cam=(46.909553,-121.966194), agl=2, snap=300,/cam=(46.909553,-121.966194), agl=2, snap=1500,/' fitcam.py; rm -f ../cache/pano_rainier_w_sept.npz; NUMBA_NUM_THREADS=4 nice -n 10 python3 fitcam.py rainier_w_sept 2>&1 | tail -3

# output:
blur 1.0: [112.524   8.972  43.616   1.553] score -0.4935
  blur 0.3: [112.519   8.973  43.629   1.537] score -0.4971
rainier_w_sept dist km 15.4 z 1265 best [112.51923757   8.97320225  43.6291133    1.53745151]
Bash: Search camera position around GPS fix
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > possearch.py <<'EOF'
"""possearch.py <shot> [radius_km] : when the GPS tag is unreliable, search camera position.
Candidates: local high points (max within 250 m) on a grid around the GPS fix; each gets a coarse
panorama and the summit-control-point fov scan; best few are refined by fitcam."""
import numpy as np, dem, fit, fitcam, sys, time
name=sys.argv[1]; R=float(sys.argv[2]) if len(sys.argv)>2 else 4
s=fitcam.SHOTS[name]; ce0,cn0,pe,pn,bx=fitcam.box(s)
T=dem.load_box(*[round(v) for v in bx],30,tag=name+"_30")
lat,lon,zs=dem.PEAKS[s["peak"]]; ez,nz=dem.ll2utm(lat,lon)
E,H=fit.photo_edges(s["photo"],320); Eb=fit.ndi.gaussian_filter(E,1.5); Eb/=Eb.max()
M=(np.arange(H)[:,None]<s["maskfrac"]*H)&np.ones((1,320),bool)
Z=T["Z"]; res=T["res"]; out=[]
for dx in np.arange(-R,R+0.01,1.0):
  for dy in np.arange(-R,R+0.01,1.0):
    e=ce0+dx*1000; n=cn0+dy*1000
    r0=int((T["y0"]-n)/res); c0=int((e-T["x0"])/res); k=8
    w=Z[r0-k:r0+k+1,c0-k:c0+k+1]; i,j=np.unravel_index(np.argmax(w),w.shape)
    e+= (j-k)*res; n-=(i-k)*res; z=float(w[i,j])+3
    dd=np.hypot(ez-e,nz-n); az=np.degrees(np.arctan2(ez-e,nz-n))
    el=np.degrees(np.arctan2(zs-z-dd*dd/(2*6371000/0.87),dd))
    P=fit.pano(T,e,n,z,az,80,el-18,el+8,0.06)
    best=None
    for fv in np.linspace(*s["fovs"],16):
        f=0.5/np.tan(np.radians(fv)/2); ar=H/320
        ax=np.degrees(np.arctan((s["summit"][0]-0.5)/f)); ay=np.degrees(np.arctan((0.5-s["summit"][1])*ar/f))
        p=(az-ax,el-ay,fv,0.0); sc=fit.score(p,P,Eb,320,H,M)
        if best is None or sc<best[0]: best=(sc,p)
    out.append((best[0],e,n,z,best[1])); print(f"{dx:+.0f},{dy:+.0f} z{z:.0f} score {best[0]:.3f} fov {best[1][2]:.1f}",flush=True)
out.sort(key=lambda o:o[0])
for o in out[:5]:
    la,lo=dem.utm2ll(o[1],o[2]); print("TOP",round(o[0],3),round(la,5),round(lo,5),round(o[3]),np.round(o[4],2))
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 possearch.py rainier_w_sept 4 2>&1 | tail -8

# output:
Command did not complete within its 600s timeout and was moved to the background (ID: bdwgx6zhj). Output is being written to: (tmp) You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains ~/work/pixelart-studies/manager/briefs; directory changes made by the backgrounded command do not apply to subsequent commands.
Write: elements/snowpeak/src/snowmodel.py
"""snowmodel.py -- where the snow lies on a real mountain, for a season.

The observation half of the study, as a program a simulation can port.  Every step is a published
process with its numbers (sources in LESSONS.md, part 1):

 1. accumulation   HS_acc(z) = g_acc * (z - z_rs), clipped to [0, hs_max]      (orographic gradient)
 2. wind           HS *= clip(1 + k_wind * Sx, lo, hi)  (Winstral 2002 Sx: upwind max slope angle;
                   Sx < 0 exposed crest -> scour, Sx > 0 sheltered lee -> loading)
 3. gravity        each cell holds at most SHD(slope) = c * exp(-a * slope) m  (Bernhardt & Schulz 2010:
                   3.08, 1.11, 0.45, 0.15 m at 30, 45, 60, 75 deg); the excess slides to lower
                   neighbours (multiple flow direction), processed from the top down, so steep faces
                   shed and gullies, ledges, bowls and slope-foot cones collect.
 4. melt           melt(z, I) = HS_acc(z_sl) * (1 + k_melt * (z_sl - z) / 1000) * (I / I_flat) ** p_ins
                   z_sl is the season's snowline for undisturbed, flat, average snow; I is the
                   clear-sky insolation over the melt season, including terrain shadows.
 5. glaciers       WorldCover snow/ice cells: where seasonal snow survives, firn (white); where it has
                   gone, bare glacier ice (grey-blue) with crevasse fields on steep ice.

    S = snow(T, SEASONS["sept"], wind_from=225)
    S["hs"]      remaining snow depth, m
    S["cover"]   0..1 cover fraction (sub-grid: ledges on steep rock hold a streaky fraction)
    S["glacier"] 0 none, 1 firn-covered glacier, 2 bare ice
    S["cornice"] 0..1 cornice lip strength on lee crest cells
"""
import numpy as np
from numba import njit
from scipy import ndimage as ndi
import sun as sunmod

SEASONS = {
    # z_sl: snowline elevation (m) of undisturbed flat snow;  depth parameters for the winter
    "spring": dict(z_sl=1250, label="late May"),
    "summer": dict(z_sl=1700, label="mid July"),
    "sept": dict(z_sl=2150, label="September minimum"),
    "fresh": dict(z_sl=2150, fresh=0.15, fresh_z=1600, label="September, after first autumn snow"),
    "winter": dict(z_sl=300, label="midwinter"),
}
DEFAULT = dict(z_rs=300.0, g_acc=0.45 / 100, hs_max=9.0,          # 0.45 m depth per 100 m; cap 9 m
               k_wind=0.035, wind_lo=0.15, wind_hi=2.5, sx_dmax=300.0,
               shd_c=23.0, shd_a=0.067, shd_min=0.03,              # fit to Bernhardt & Schulz defaults
               k_melt=0.9, p_ins=0.8, doy_melt=(135, 250))


# ---------------------------------------------------------------- terrain fields
def fields(Z, res):
    gy, gx = np.gradient(Z.astype(np.float64), res)
    gy = -gy                                                      # row 0 = north: dZ/dN = -dZ/drow
    slope = np.degrees(np.arctan(np.hypot(gx, gy)))
    aspect = (np.degrees(np.arctan2(-gx, -gy)) + 360) % 360       # downslope direction, deg from N
    n = np.dstack([-gx, -gy, np.ones_like(gx)]); n /= np.linalg.norm(n, axis=2, keepdims=True)
    return slope, aspect, n


def curvature(Z, res, r_m):
    """height minus local mean over radius r_m: <0 hollow/gully, >0 crest/rib"""
    s = max(r_m / res / 2, 0.5)
    return Z - ndi.gaussian_filter(Z.astype(np.float64), s)


@njit(cache=True)
def _sx(Z, res, dx, dy, dmax):
    ny, nx = Z.shape
    out = np.zeros((ny, nx))
    nstep = int(dmax / res)
    for i in range(ny):
        for j in range(nx):
            z0 = Z[i, j]; best = -1e9
            for k in range(1, nstep + 1):
                ii = int(round(i - dy * k)); jj = int(round(j + dx * k))    # step upwind (dy north = -row)
                if ii < 0 or ii >= ny or jj < 0 or jj >= nx:
                    break
                a = np.arctan((Z[ii, jj] - z0) / (k * res))
                if a > best:
                    best = a
            out[i, j] = np.degrees(best) if best > -1e8 else 0.0
    return out


def winstral_sx(Z, res, wind_from=225.0, dmax=300.0, spread=30.0):
    """Sx averaged over wind_from +- spread (Winstral et al. 2002)"""
    acc = np.zeros(Z.shape)
    angs = [wind_from - spread, wind_from, wind_from + spread]
    for a in angs:
        r = np.radians(a)
        acc += _sx(Z.astype(np.float64), res, np.sin(r), np.cos(r), dmax)
    return acc / len(angs)


@njit(cache=True)
def _shadow(Z, res, sx, sy, sz, maxd):
    """1 = sunlit, 0 = in terrain shadow, for sun direction (sx east, sy north, sz up)"""
    ny, nx = Z.shape
    out = np.ones((ny, nx), np.float32)
    h = np.sqrt(sx * sx + sy * sy) + 1e-9
    tan_e = sz / h
    ux = sx / h; uy = sy / h
    nstep = int(maxd / res)
    for i in range(ny):
        for j in range(nx):
            z0 = Z[i, j] + 1.0
            for k in range(1, nstep):
                d = k * res * (1.0 + k * 0.02)          # stretch steps with distance
                ii = int(i - uy * d / res); jj = int(j + ux * d / res)
                if ii < 0 or ii >= ny or jj < 0 or jj >= nx:
                    break
                if Z[ii, jj] > z0 + d * tan_e:
                    out[i, j] = 0.0
                    break
                if z0 + d * tan_e > 5000:
                    break
    return out


def shadow(Z, res, az, el, maxd=15000.0):
    v = sunmod.vec(az, el)
    return _shadow(Z.astype(np.float64), res, v[0], v[1], v[2], maxd)


def insolation(Z, res, n, lat, lon, doys=(172, 200, 230), hours=range(6, 20), coarse=2):
    """relative clear-sky direct + diffuse energy over the melt season, with cast shadows.
    Returned as ratio to a flat unshadowed cell (1.0 = flat)."""
    Zc = Z[::coarse, ::coarse]; nc = n[::coarse, ::coarse]
    tot = np.zeros(Zc.shape); flat = 0.0
    from datetime import datetime, timezone, timedelta
    for d in doys:
        for h in hours:
            t = datetime(2021, 1, 1, tzinfo=timezone.utc) + timedelta(days=d - 1, hours=h + 7)
            az, el = sunmod.position(lat, lon, t)
            if el < 2:
                continue
            v = sunmod.vec(az, el)
            m = 1.0 / max(np.sin(np.radians(el)), 0.05)              # air mass
            beam = 0.75 ** (m ** 0.678)                                # clear-sky transmittance
            cosi = np.clip(nc @ v, 0, None)
            sh = shadow(Zc, res * coarse, az, el, maxd=8000.0)
            tot += beam * cosi * sh + 0.12 * (1 + nc[..., 2]) / 2
            flat += beam * v[2] + 0.12
    I = tot / flat
    return ndi.zoom(I, coarse, order=1)[:Z.shape[0], :Z.shape[1]]


# ---------------------------------------------------------------- gravity
@njit(cache=True)
def _route(Z, hs, shd, res):
    ny, nx = Z.shape
    order = np.argsort(-Z.ravel())
    out = hs.copy()
    di = np.array([-1, -1, -1, 0, 0, 1, 1, 1]); dj = np.array([-1, 0, 1, -1, 1, -1, 0, 1])
    dist = np.array([1.414, 1.0, 1.414, 1.0, 1.0, 1.414, 1.0, 1.414]) * res
    w = np.zeros(8)
    for idx in order:
        i = idx // nx; j = idx % nx
        ex = out[i, j] - shd[i, j]
        if ex <= 0:
            continue
        tw = 0.0
        for k in range(8):
            ii = i + di[k]; jj = j + dj[k]
            w[k] = 0.0
            if 0 <= ii < ny and 0 <= jj < nx:
                dz = Z[i, j] - Z[ii, jj]
                if dz > 0:
                    w[k] = (dz / dist[k]) ** 1.1
                    tw += w[k]
        if tw <= 0:
            continue                                   # pit: keep it (snow fills the hollow)
        out[i, j] = shd[i, j]
        for k in range(8):
            if w[k] > 0:
                out[i + di[k], j + dj[k]] += ex * w[k] / tw
    return out


def holding_depth(slope, P=DEFAULT):
    return np.maximum(P["shd_c"] * np.exp(-P["shd_a"] * slope), P["shd_min"])


# ---------------------------------------------------------------- the model
def snow(T, season, wind_from=225.0, P=None, lat=47.0, lon=-121.5, cache=None, want_I=True):
    P = dict(DEFAULT, **(P or {}))
    Z = T["Z"].astype(np.float64); res = T["res"]
    slope, aspect, n = fields(Z, res)
    hs = np.clip(P["g_acc"] * (Z - P["z_rs"]), 0, P["hs_max"])
    sx = winstral_sx(Z, res, wind_from, P["sx_dmax"])
    hs_w = hs * np.clip(1 + P["k_wind"] * sx, P["wind_lo"], P["wind_hi"])
    shd = holding_depth(slope, P)
    hs_g = _route(Z, hs_w, shd, res)
    if want_I:
        I = insolation(Z, res, n, lat, lon)
    else:
        I = np.clip(0.9 + 0.5 * (n[..., 1] * -1) * 0 + 0.6 * n @ sunmod.vec(180, 55), 0.1, 2)
    z_sl = season["z_sl"]
    acc_sl = np.clip(P["g_acc"] * (z_sl - P["z_rs"]), 0, P["hs_max"])
    melt = acc_sl * np.clip(1 + P["k_melt"] * (z_sl - Z) / 1000, 0.05, None) * np.clip(I, 0.05, None) ** P["p_ins"]
    rem = hs_g - melt
    # a thin fresh autumn snowfall above fresh_z (lies everywhere it can hold, sheds from steep rock)
    if season.get("fresh"):
        fr = season["fresh"] * np.clip((Z - season["fresh_z"]) / 300, 0, 1)
        rem = np.maximum(rem, 0) + np.minimum(fr, shd)
    hs_rem = np.maximum(rem, 0)
    ice = T.get("ice", np.zeros_like(Z))
    glacier = np.where(ice > 0.5, np.where(hs_rem > 0.05, 1, 2), 0).astype(np.int8)
    # sub-grid cover: deep snow covers fully; thin snow on steep rock survives on ledges only
    cover = np.clip(hs_rem / 0.4, 0, 1)
    steep = np.clip((slope - 40) / 25, 0, 1)
    cover = cover * (1 - 0.55 * steep)                 # a 65-deg face shows ~45% ledge snow at best
    cover = np.where(glacier > 0, 1.0, cover)
    # cornices: lee crest cells (sheltering drop downwind) under an exposed windward approach
    crest = curvature(Z, res, 60) > 2.0 * res / 10
    r = np.radians(wind_from)
    down = ndi.shift(Z, (np.cos(r) * 2, np.sin(r) * 2), order=1, mode="nearest")   # 2 cells downwind
    drop = (Z - down) / (2 * res)
    cornice = crest & (sx < -3) & (drop > np.tan(np.radians(35))) & (hs_w > 1.0)
    return dict(hs=hs_rem, hs_acc=hs, hs_wind=hs_w, hs_grav=hs_g, cover=cover, glacier=glacier,
                cornice=cornice.astype(np.float32), sx=sx, I=I, slope=slope, aspect=aspect, n=n,
                shd=shd, melt=melt)
Bash: Show position search results and folder size
tail -8 (tmp) du -sh ~/work/pixelart-studies/elements/snowpeak

# output:
+4,+4 z1424 score -0.073 fov 40.0
TOP -0.543 46.90725 -121.94996 1249 [112.75   8.87  33.33   0.  ]
TOP -0.534 46.92943 -121.99484 920 [115.44   7.71  30.     0.  ]
TOP -0.53 46.94749 -121.94234 1428 [127.19   7.31  31.67   0.  ]
TOP -0.525 46.91928 -122.01775 727 [110.56   7.8   28.33   0.  ]
TOP -0.518 46.93914 -122.01503 849 [116.1    7.54  23.33   0.  ]

[exited with code 0]
229M    ~/work/pixelart-studies/elements/snowpeak
Bash: Fit Silver Queen July camera
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("fitcam.py").read()
s=s.replace("fov=30, span=70, el=(-10,14), time=(2015,7,1,12,22), maskfrac=0.7),","fov=30, span=70, el=(-10,14), time=(2015,7,1,12,22), maskfrac=0.7, summit=(548/1280,268/855), fovs=(15,45), snap=200),")
s=s.replace("pad=(25000,8000,20000,15000)","pad=(40000,10000,30000,25000)")
open("fitcam.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 fitcam.py rainier_ne_july 2>&1 | tail -3

# output:
blur 1.0: [-111.931    1.205   36.711    0.309] score -0.7099
  blur 0.3: [-111.929    1.21    36.711    0.326] score -0.7265
rainier_ne_july dist km 21.2 z 2129 best [-111.92884104    1.2098204    36.71113656    0.32577725]
Read: elements/snowpeak/stages/fit_rainier_ne_july.png

elements/snowpeak/stages/fit_rainier_ne_july.png 50. elements/snowpeak/stages/fit_rainier_ne_july.png

Write: elements/snowpeak/src/scene.py
"""scene.py -- one view of a real peak: DEM, snow model, camera, per-pixel G-buffer.

    S = Scene("rainier_ne_july", W=320)        # uses cache/cams.json + fitcam.SHOTS
    S.snow("summer")                            # runs snowmodel on the peak sub-box
    G = S.gbuffer()                             # per-pixel fields for the painter
"""
import numpy as np, json, os
from scipy.ndimage import map_coordinates
import dem, cam, fitcam, snowmodel, sun as sunmod

HERE = os.path.dirname(os.path.abspath(__file__))


class Scene:
    def __init__(self, shot, W=320, H=None, cams=None, snow_half_km=11, snow_res=15, cam_override=None):
        self.shot = shot
        s = fitcam.SHOTS[shot]; self.s = s
        c = (cams or json.load(open(os.path.join(HERE, "../cache/cams.json"))))[shot]
        if cam_override:
            c = dict(c, **cam_override)
        ce, cn, pe, pn, bx = fitcam.box(s)
        self.T = dem.load_box(*[round(v) for v in bx], s["res"], tag=shot)
        from PIL import Image
        pw, ph = Image.open(s["photo"]).size
        self.W = W; self.H = H or round(W * ph / pw)
        self.C = cam.Camera(c["e"], c["n"], c["z"], c["az"], c["pitch"], c["hfov"], self.W, self.H, c["roll"])
        lat, lon, _ = dem.PEAKS[s["peak"]]
        self.lat, self.lon = lat, lon
        self.Tp = dem.load(s["peak"], snow_half_km, snow_res)      # snow sub-box around the peak
        y, mo, d, h, mi = s["time"]
        self.sun = sunmod.local(lat, lon, y, mo, d, h, mi)

    def snow(self, season, wind_from=225.0, P=None):
        key = f"{self.s['peak']}_{season}_{int(wind_from)}_{hash(str(sorted((P or {}).items()))) % 10000}"
        fn = os.path.join(HERE, f"../cache/snow_{key}.npz")
        if os.path.exists(fn):
            self.S = dict(np.load(fn))
        else:
            S = snowmodel.snow(self.Tp, snowmodel.SEASONS[season], wind_from, P, self.lat, self.lon)
            keep = {k: S[k].astype(np.float16 if S[k].dtype.kind == "f" else S[k].dtype)
                    for k in ("hs", "cover", "glacier", "cornice", "sx", "I", "hs_grav", "hs_acc")}
            np.savez_compressed(fn, **keep)
            self.S = keep
        self.season = season
        return self.S

    def gbuffer(self, sun=None):
        G = cam.cast(self.T, self.C)
        T = self.T; Tp = self.Tp
        sl, asp, n = snowmodel.fields(T["Z"], T["res"])
        def samp(A, r, c, order=1):
            return map_coordinates(np.asarray(A, np.float32), [r.ravel(), c.ravel()], order=order,
                                   mode="nearest").reshape(r.shape)
        r, c = G["r"], G["c"]
        G["normal"] = np.stack([samp(n[..., k], r, c) for k in range(3)], -1)
        G["normal"] /= np.linalg.norm(G["normal"], axis=-1, keepdims=True) + 1e-9
        G["slope"] = samp(sl, r, c)
        for k in ("ice", "trees", "bare"):
            G[k] = samp(T[k], r, c)
        # snow fields from the peak sub-box (outside it: none, the sub-box is chosen to hold all snow)
        rp = (Tp["y0"] - G["n"]) / Tp["res"] - 0.5; cp = (G["e"] - Tp["x0"]) / Tp["res"] - 0.5
        inside = (rp > 0) & (rp < Tp["Z"].shape[0] - 1) & (cp > 0) & (cp < Tp["Z"].shape[1] - 1) & ~G["sky"]
        for k in ("hs", "cover", "cornice", "I"):
            G[k] = np.where(inside, samp(self.S[k], rp, cp), 0.0)
        G["glacier"] = np.where(inside, samp(self.S["glacier"], rp, cp, order=0), 0)
        # sun shadow at each hit point, by marching the peak box (fine) then the view box
        az, el = sun or self.sun
        self.sun_used = (az, el)
        shp = snowmodel.shadow(Tp["Z"], Tp["res"], az, el, maxd=20000.0) if el > -3 else np.zeros_like(Tp["Z"])
        shv = snowmodel.shadow(T["Z"][::2, ::2], T["res"] * 2, az, el, maxd=30000.0) if el > -3 else None
        sh_view = samp(shv, r / 2, c / 2) if shv is not None else np.zeros(r.shape)
        G["sunlit"] = np.where(inside, samp(shp, rp, cp), sh_view)
        G["sunvec"] = sunmod.vec(az, max(el, 0.5))
        G["ndl"] = G["normal"] @ G["sunvec"]
        # sub-pixel curvature for gully/rib reading
        cv = snowmodel.curvature(T["Z"], T["res"], 90)
        G["curv"] = samp(cv, r, c)
        self.G = G
        return G
Bash: Run snow model and physical preview for Silver Queen view
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > preview.py <<'EOF'
"""quick physical preview of a scene: where snow falls, before any painting."""
import numpy as np, sys, time
from PIL import Image
import scene
def preview(S, G):
    cov=np.clip(G["cover"],0,1); gl=G["glacier"]
    alb_rock=np.where(G["trees"]>0.5,0.05,np.where(G["bare"]>0.3,0.2,0.12))
    alb=alb_rock*(1-cov)+0.85*cov
    alb=np.where(gl==2,0.45,alb)
    light=np.clip(G["ndl"],0,1)*G["sunlit"]*1.0+0.28*(0.5+0.5*G["normal"][...,2])
    v=alb*light
    col=np.stack([v*1.0,v*1.02,v*1.08],-1)
    d=np.where(G["sky"],1e9,G["dist"]); f=1-np.exp(-d/60000)
    sky=np.array([0.55,0.68,0.9])
    col=col*(1-f[...,None])+sky*0.9*f[...,None]
    col[G["sky"]]=sky
    return (np.clip(col,0,1)**(1/2.2)*255).astype(np.uint8)
if __name__=="__main__":
    shot=sys.argv[1]; season=sys.argv[2]; W=int(sys.argv[3]) if len(sys.argv)>3 else 480
    t=time.time(); S=scene.Scene(shot,W=W); S.snow(season); print("snow",time.time()-t)
    t=time.time(); G=S.gbuffer(); print("gbuf",time.time()-t, "sun",S.sun)
    im=preview(S,G)
    ph=Image.open(S.s["photo"]).convert("RGB").resize((S.W,S.H),Image.LANCZOS)
    out=Image.new("RGB",(S.W*2,S.H)); out.paste(ph,(0,0)); out.paste(Image.fromarray(im),(S.W,0))
    out.save(f"../stages/preview_{shot}_{season}.png")
EOF
NUMBA_NUM_THREADS=4 OPENBLAS_NUM_THREADS=1 nice -n 10 python3 preview.py rainier_ne_july summer 480

# output:
~/work/pixelart-studies/elements/snowpeak/src/scene.py:40: RuntimeWarning: overflow encountered in cast
  keep = {k: S[k].astype(np.float16 if S[k].dtype.kind == "f" else S[k].dtype)
snow 61.25150513648987
gbuf 17.368157148361206 sun (153.44125758476886, 64.37357565555618)
Read: elements/snowpeak/stages/preview_rainier_ne_july_summer.png

elements/snowpeak/stages/preview_rainier_ne_july_summer.png 51. elements/snowpeak/stages/preview_rainier_ne_july_summer.png

Bash: Compare modeled vs photo snow cover
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > snowcmp.py <<'EOF'
"""compare the model's snow cover with the photo's snow (L* threshold) inside the terrain area."""
import numpy as np, sys
from PIL import Image
from skimage.color import rgb2lab
import scene
def photo_snow(path,W,H,thr=60):
    lab=rgb2lab(np.asarray(Image.open(path).convert("RGB").resize((W,H),Image.LANCZOS))/255.)
    return lab[...,0]>thr, lab
def compare(S,G,thr=60,out=None):
    ps,lab=photo_snow(S.s["photo"],S.W,S.H,thr)
    ms=(G["cover"]>0.5)|(G["glacier"]>0)
    roi=~G["sky"]&(G["z"]>1500)
    inter=(ps&ms&roi).sum(); uni=((ps|ms)&roi).sum()
    iou=inter/max(uni,1)
    img=np.zeros((S.H,S.W,3),np.uint8)
    img[roi]=(60,60,60); img[ps&ms&roi]=(240,240,240); img[ps&~ms&roi]=(40,120,255); img[~ps&ms&roi]=(255,90,40)
    if out: Image.fromarray(img).resize((S.W*2,S.H*2),Image.NEAREST).save(out)
    return iou,(ps&roi).mean(),(ms&roi).mean()
if __name__=="__main__":
    shot,season=sys.argv[1],sys.argv[2]
    S=scene.Scene(shot,W=480); S.snow(season); G=S.gbuffer()
    print(compare(S,G,out=f"../stages/snowcmp_{shot}_{season}.png"))
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 snowcmp.py rainier_ne_july summer

# output:
~/work/pixelart-studies/elements/snowpeak/src/scene.py:40: RuntimeWarning: overflow encountered in cast
  keep = {k: S[k].astype(np.float16 if S[k].dtype.kind == "f" else S[k].dtype)
(0.6810830743296634, 0.091452492211838, 0.11566069574247144)
Bash: Fix cache key and dtype
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("scene.py").read()
s=s.replace("hash(str(sorted((P or {}).items()))) % 10000","__import__('hashlib').md5(str(sorted((P or {}).items())).encode()).hexdigest()[:6]")
s=s.replace("np.float16 if S[k].dtype.kind == \"f\"","np.float32 if S[k].dtype.kind == \"f\"")
open("scene.py","w").write(s)
EOF
rm -f ../cache/snow_*; echo ok

# output:
ok
Read: elements/snowpeak/stages/snowcmp_rainier_ne_july_summer.png

elements/snowpeak/stages/snowcmp_rainier_ne_july_summer.png 52. elements/snowpeak/stages/snowcmp_rainier_ne_july_summer.png

Bash: Split snow model into prepare/apply with caching
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("snowmodel.py").read()
a=s.index("def snow(T, season")
s=s[:a]+'''def prepare(T, wind_from=225.0, P=None, lat=47.0, lon=-121.5):
    """the season-independent, expensive part: fields, wind, gravity, insolation"""
    P = dict(DEFAULT, **(P or {}))
    Z = T["Z"].astype(np.float64); res = T["res"]
    slope, aspect, n = fields(Z, res)
    hs = np.clip(P["g_acc"] * (Z - P["z_rs"]), 0, P["hs_max"])
    sx = winstral_sx(Z, res, wind_from, P["sx_dmax"])
    hs_w = hs * np.clip(1 + P["k_wind"] * sx, P["wind_lo"], P["wind_hi"])
    shd = holding_depth(slope, P)
    hs_g = _route(Z, hs_w, shd, res)
    I = insolation(Z, res, n, lat, lon)
    crest = curvature(Z, res, 60) > 2.0 * res / 10
    r = np.radians(wind_from)
    down = ndi.shift(Z, (np.cos(r) * 2, -np.sin(r) * 2), order=1, mode="nearest")   # 2 cells downwind
    drop = (Z - down) / (2 * res)
    cornice = crest & (sx < -3) & (drop > np.tan(np.radians(35))) & (hs_w > 1.0)
    return dict(Z=Z, slope=slope, aspect=aspect, hs_acc=hs, hs_wind=hs_w, hs_grav=hs_g, sx=sx, I=I,
                shd=shd, cornice=cornice.astype(np.float32), ice=np.asarray(T.get("ice", np.zeros_like(Z))),
                P=P)


def apply(prep, season, P=None):
    """the cheap, seasonal part: melt, fresh snow, glacier surface state, sub-grid cover"""
    Pd = dict(prep["P"], **(P or {}))
    Z, slope, I, shd = prep["Z"], prep["slope"], prep["I"], prep["shd"]
    z_sl = season["z_sl"]
    acc_sl = np.clip(Pd["g_acc"] * (z_sl - Pd["z_rs"]), 0, Pd["hs_max"])
    melt = acc_sl * np.clip(1 + Pd["k_melt"] * (z_sl - Z) / 1000, 0.05, None) * np.clip(I, 0.05, None) ** Pd["p_ins"]
    rem = prep["hs_grav"] - melt
    if season.get("fresh"):
        fr = season["fresh"] * np.clip((Z - season["fresh_z"]) / 300, 0, 1)
        rem = np.maximum(rem, 0) + np.minimum(fr, shd)
    hs_rem = np.maximum(rem, 0)
    ice = prep["ice"]
    # glacier ice lies under the mask, but faces steeper than ~50 deg inside it are rock walls (Willis Wall)
    on_ice = (ice > 0.5) & (slope < Pd.get("ice_max_slope", 50))
    glacier = np.where(on_ice, np.where(hs_rem > 0.05, 1, 2), 0).astype(np.int8)
    cover = np.clip(hs_rem / 0.4, 0, 1)
    steep = np.clip((slope - 40) / 25, 0, 1)
    cover = cover * (1 - 0.55 * steep)
    cover = np.where(glacier > 0, 1.0, cover)
    return dict(hs=hs_rem, cover=cover, glacier=glacier, cornice=prep["cornice"], sx=prep["sx"], I=I,
                hs_grav=prep["hs_grav"], hs_acc=prep["hs_acc"], melt=melt)


def snow(T, season, wind_from=225.0, P=None, lat=47.0, lon=-121.5):
    return apply(prepare(T, wind_from, P, lat, lon), season, P)
'''
open("snowmodel.py","w").write(s)
s=open("scene.py").read()
a=s.index("    def snow(self"); b=s.index("    def gbuffer")
s=s[:a]+'''    def snow(self, season, wind_from=225.0, P=None, season_over=None):
        """season: key of snowmodel.SEASONS; season_over: dict overriding its entries (e.g. z_sl)"""
        fn = os.path.join(HERE, f"../cache/snowprep_{self.s['peak']}_{int(wind_from)}.npz")
        if os.path.exists(fn):
            d = np.load(fn); prep = {k: d[k] for k in d.files}; prep["P"] = dict(snowmodel.DEFAULT)
        else:
            prep = snowmodel.prepare(self.Tp, wind_from, None, self.lat, self.lon)
            np.savez_compressed(fn, **{k: np.asarray(v, np.float32) for k, v in prep.items() if k != "P"})
        self.prep = prep
        se = dict(snowmodel.SEASONS[season], **(season_over or {}))
        self.S = snowmodel.apply(prep, se, P)
        self.season = season
        return self.S

'''+s[b:]
open("scene.py","w").write(s)
EOF
echo done

# output:
done
Bash: Sweep snowline for the July photo
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("snowmodel.py").read()
s=s.replace("down = ndi.shift(Z, (np.cos(r) * 2, -np.sin(r) * 2)","down = ndi.shift(Z, (-np.cos(r) * 2, np.sin(r) * 2)")
open("snowmodel.py","w").write(s)
s=open("scene.py").read()
s=s.replace('''        inside = (rp > 0) & (rp < Tp["Z"].shape[0] - 1) & (cp > 0) & (cp < Tp["Z"].shape[1] - 1) & ~G["sky"]
        for k in ("hs", "cover", "cornice", "I"):
            G[k] = np.where(inside, samp(self.S[k], rp, cp), 0.0)
        G["glacier"] = np.where(inside, samp(self.S["glacier"], rp, cp, order=0), 0)''','''        inside = (rp > 0) & (rp < Tp["Z"].shape[0] - 1) & (cp > 0) & (cp < Tp["Z"].shape[1] - 1) & ~G["sky"]
        G["rp"], G["cp"], G["inside"] = rp, cp, inside
        self.G = G
        self.resample()''')
s=s.replace('''        self.G = G
        return G
''','''        self.G = G
        return G

    def resample(self):
        """(re)sample the current snow state into the G-buffer (cheap; call after snow())"""
        G = self.G; rp, cp, inside = G["rp"], G["cp"], G["inside"]
        def samp(A, order=1):
            return map_coordinates(np.asarray(A, np.float32), [rp.ravel(), cp.ravel()], order=order,
                                   mode="nearest").reshape(rp.shape)
        for k in ("hs", "cover", "cornice", "I", "sx"):
            G[k] = np.where(inside, samp(self.S[k]), 0.0)
        G["glacier"] = np.where(inside, samp(self.S["glacier"], 0), 0)
        return G
''')
open("scene.py","w").write(s)
EOF
cat > calib.py <<'EOF'
"""calib.py shot season z1 z2 step : sweep snowline z_sl against the photo's snow mask."""
import numpy as np, sys, snowcmp, scene
shot,season=sys.argv[1],sys.argv[2]; zs=np.arange(float(sys.argv[3]),float(sys.argv[4])+1,float(sys.argv[5]))
S=scene.Scene(shot,W=480); S.snow(season); G=S.gbuffer()
for thr in (55,65):
  for z in zs:
    S.snow(season,season_over=dict(z_sl=z)); S.resample()
    print(thr,z,np.round(snowcmp.compare(S,G,thr=thr),3))
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 calib.py rainier_ne_july summer 1500 2500 100

# output:
55 1500.0 [0.689 0.105 0.132]
55 1600.0 [0.7   0.105 0.121]
55 1700.0 [0.704 0.105 0.113]
55 1800.0 [0.706 0.105 0.105]
55 1900.0 [0.702 0.105 0.099]
55 2000.0 [0.696 0.105 0.094]
55 2100.0 [0.689 0.105 0.09 ]
55 2200.0 [0.684 0.105 0.086]
55 2300.0 [0.679 0.105 0.083]
55 2400.0 [0.673 0.105 0.082]
55 2500.0 [0.669 0.105 0.081]
65 1500.0 [0.586 0.082 0.132]
65 1600.0 [0.624 0.082 0.121]
65 1700.0 [0.651 0.082 0.113]
65 1800.0 [0.676 0.082 0.105]
65 1900.0 [0.693 0.082 0.099]
65 2000.0 [0.705 0.082 0.094]
65 2100.0 [0.712 0.082 0.09 ]
65 2200.0 [0.718 0.082 0.086]
65 2300.0 [0.722 0.082 0.083]
65 2400.0 [0.724 0.082 0.082]
65 2500.0 [0.725 0.082 0.081]
Bash: Measure photo Lab values by class at 21 and 96 km
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > measure.py <<'EOF'
"""measure.py shot [season] : photo Lab by class (snow/rock x lit/shade, forest, sky), using the
fitted camera's G-buffer to know what each photo pixel looks at and how far away it is."""
import numpy as np, sys, json
from PIL import Image
from skimage.color import rgb2lab
import scene
def measure(S,G,snow_thr=None):
    lab=rgb2lab(np.asarray(Image.open(S.s["photo"]).convert("RGB").resize((S.W,S.H),Image.LANCZOS))/255.)
    L=lab[...,0]; terr=~G["sky"]
    # photo snow: Otsu-like split of L* over the terrain above 1600 m
    hi=terr&(G["z"]>1600)
    from skimage.filters import threshold_otsu
    thr=snow_thr or threshold_otsu(L[hi])
    psnow=hi&(L>thr)
    lit=(G["ndl"]>0.15)&(G["sunlit"]>0.5); shade=(G["ndl"]<0.0)|(G["sunlit"]<0.5)
    cls=dict(snow_lit=psnow&lit, snow_shade=psnow&shade,
             rock_lit=hi&~psnow&lit&(G["trees"]<0.3), rock_shade=hi&~psnow&shade&(G["trees"]<0.3),
             forest=terr&(G["trees"]>0.6)&(G["z"]<1500))
    # sky: 20 rows just above the terrain silhouette
    sk=G["sky"]&np.roll(terr,-15,0); cls["sky_low"]=sk
    cls["sky_top"]=np.zeros_like(sk); cls["sky_top"][:max(3,S.H//12)]=True
    out={"thr":float(thr)}
    for k,m in cls.items():
        if m.sum()<8: continue
        d=G["dist"][m]; d=d[np.isfinite(d)]
        out[k]=dict(n=int(m.sum()),L=round(float(np.median(L[m])),1),a=round(float(np.median(lab[...,1][m])),1),
                    b=round(float(np.median(lab[...,2][m])),1),dist_km=round(float(np.median(d))/1000,1) if len(d) else None)
    return out
if __name__=="__main__":
    shot=sys.argv[1]; season=sys.argv[2] if len(sys.argv)>2 else "summer"
    S=scene.Scene(shot,W=480); S.snow(season); G=S.gbuffer()
    m=measure(S,G); print(json.dumps(m,indent=0))
    db=json.load(open("../cache/measure.json")) if __import__("os").path.exists("../cache/measure.json") else {}
    db[shot]=m; json.dump(db,open("../cache/measure.json","w"),indent=1)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 measure.py rainier_ne_july summer 2>&1 | tr '\n' ' '; echo; NUMBA_NUM_THREADS=4 nice -n 10 python3 measure.py sea_rainier summer 2>&1 | tr '\n' ' '

# output:
{ "thr": 47.93983196977611, "snow_lit": { "n": 18986, "L": 78.4, "a": 2.0, "b": 2.3, "dist_km": 17.4 }, "snow_shade": { "n": 18, "L": 58.8, "a": -0.8, "b": -13.2, "dist_km": 18.1 }, "rock_lit": { "n": 5823, "L": 33.7, "a": 1.3, "b": -10.9, "dist_km": 11.8 }, "rock_shade": { "n": 10, "L": 24.4, "a": 3.1, "b": -22.2, "dist_km": 13.5 }, "forest": { "n": 35115, "L": 14.3, "a": 0.4, "b": -8.4, "dist_km": 5.9 }, "sky_low": { "n": 7200, "L": 66.4, "a": -3.7, "b": -14.2, "dist_km": null }, "sky_top": { "n": 12480, "L": 43.0, "a": 0.0, "b": -31.3, "dist_km": null } } 
Traceback (most recent call last):   File "~/work/pixelart-studies/elements/snowpeak/src/measure.py", line 31, in <module>     S=scene.Scene(shot,W=480); S.snow(season); G=S.gbuffer()       ^^^^^^^^^^^^^^^^^^^^^^^   File "~/work/pixelart-studies/elements/snowpeak/src/scene.py", line 18, in __init__     c = (cams or json.load(open(os.path.join(HERE, "../cache/cams.json"))))[shot]         ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^ KeyError: 'sea_rainier'
Bash: Refit sea camera and measure at 96 km
cd ~/work/pixelart-studies/elements/snowpeak/src; NUMBA_NUM_THREADS=4 nice -n 10 python3 fitcam.py sea_rainier 150.63 1.155 7.94 -0.05 2>&1 | tail -1; NUMBA_NUM_THREADS=4 nice -n 10 python3 measure.py sea_rainier summer 2>&1 | tr '\n' ' '

# output:
sea_rainier dist km 96.4 z 12 best [150.67814552   1.13281748   8.14991469   0.22543144]
{ "thr": 71.19679233570818, "snow_lit": { "n": 5803, "L": 73.0, "a": 3.6, "b": 2.5, "dist_km": 93.7 }, "snow_shade": { "n": 1846, "L": 71.9, "a": 1.6, "b": 1.2, "dist_km": 94.5 }, "rock_lit": { "n": 3335, "L": 69.3, "a": 2.5, "b": 0.8, "dist_km": 92.6 }, "rock_shade": { "n": 4382, "L": 69.2, "a": 1.6, "b": -0.0, "dist_km": 92.9 }, "forest": { "n": 20214, "L": 59.1, "a": 0.8, "b": -2.8, "dist_km": 68.6 }, "sky_low": { "n": 7200, "L": 73.6, "a": 2.3, "b": 2.5, "dist_km": null }, "sky_top": { "n": 12480, "L": 76.2, "a": 2.1, "b": 1.8, "dist_km": null } }
Read: elements/snowpeak/refs/Mount_Rainier_from_Seattle/openverse_Mount_Rainier_from_Seattle_Municipal_Tower_24d1e92a.jpg

elements/snowpeak/refs/Mount_Rainier_from_Seattle/openverse_Mount_Rainier_from_Seattle_Municipal_Tower_24d1e92a.jpg 53. elements/snowpeak/refs/Mount_Rainier_from_Seattle/openverse_Mount_Rainier_from_Seattle_Municipal_Tower_24d1e92a.jpg

Bash: Fit clear-day Seattle tower camera
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("fitcam.py").read()
s=s.replace(''' "rainier_w_sept":''',''' "sea_rainier_clear": dict(photo=REF+"Mount_Rainier_from_Seattle/openverse_Mount_Rainier_from_Seattle_Municipal_Tower_24d1e92a.jpg",
     cam=(47.6053,-122.3294), agl=None, z=240, peak="rainier", res=30, pad=(15000,25000,20000,5000),
     fov=8.0, span=24, el=(-2.0,4.0), time=(2017,5,10,19,0), maskfrac=0.5, summit=(475/1024,152/675), fovs=(4,12)),
 "rainier_w_sept":''')
open("fitcam.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 fitcam.py sea_rainier_clear 2>&1 | tail -2

# output:
blur 0.3: [ 1.52464e+02 -9.80000e-02  1.27500e+01 -2.58000e-01] score -0.6054
sea_rainier_clear dist km 94.1 z 240 best [ 1.52464035e+02 -9.80194972e-02  1.27502173e+01 -2.58119170e-01]
Read: elements/snowpeak/stages/fit_sea_rainier_clear.png

elements/snowpeak/stages/fit_sea_rainier_clear.png 54. elements/snowpeak/stages/fit_sea_rainier_clear.png

Bash: Measure clear-day 94 km photo
cd ~/work/pixelart-studies/elements/snowpeak/src; NUMBA_NUM_THREADS=4 nice -n 10 python3 measure.py sea_rainier_clear spring 2>&1 | tr '\n' ' '

# output:
{ "thr": 59.02191456044966, "snow_lit": { "n": 3628, "L": 65.1, "a": 1.7, "b": 14.7, "dist_km": 90.8 }, "snow_shade": { "n": 683, "L": 64.3, "a": 1.1, "b": 10.7, "dist_km": 92.3 }, "rock_lit": { "n": 1906, "L": 52.8, "a": -1.5, "b": 1.8, "dist_km": 91.0 }, "rock_shade": { "n": 943, "L": 52.0, "a": -1.7, "b": 0.1, "dist_km": 91.8 }, "forest": { "n": 37021, "L": 27.5, "a": -1.9, "b": -2.6, "dist_km": 14.7 }, "sky_low": { "n": 7200, "L": 55.9, "a": -3.0, "b": 2.0, "dist_km": null }, "sky_top": { "n": 12480, "L": 56.1, "a": -3.9, "b": -7.4, "dist_km": null } }
Bash: Crop Rainier from clear Seattle photo
cd ~/work/pixelart-studies/elements/snowpeak; python3 -c "
from PIL import Image
im=Image.open('refs/Mount_Rainier_from_Seattle/openverse_Mount_Rainier_from_Seattle_Municipal_Tower_24d1e92a.jpg')
im.crop((150,130,900,350)).resize((1500,440)).save('img/crop_sea_clear.png')"

# output:
(Bash completed with no output)
Read: elements/snowpeak/img/crop_sea_clear.png

elements/snowpeak/img/crop_sea_clear.png 55. elements/snowpeak/img/crop_sea_clear.png

Bash: Write box sampler and sample clear 94 km photo
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > boxes.py <<'EOF'
"""boxes.py: named-box Lab samples from a photo (the observation student's point samples).
Coordinates in original photo pixels.  Prints median L*a*b* and the 10/90 percentiles of L*."""
import numpy as np, json, sys
from PIL import Image
from skimage.color import rgb2lab
def sample(path, boxes):
    lab = rgb2lab(np.asarray(Image.open(path).convert("RGB")) / 255.0)
    out = {}
    for k, (x0, y0, x1, y1) in boxes.items():
        a = lab[y0:y1, x0:x1].reshape(-1, 3)
        out[k] = dict(L=round(float(np.median(a[:, 0])), 1), a=round(float(np.median(a[:, 1])), 1),
                      b=round(float(np.median(a[:, 2])), 1), L10=round(float(np.percentile(a[:, 0], 10)), 1),
                      L90=round(float(np.percentile(a[:, 0], 90)), 1))
    return out
if __name__ == "__main__":
    spec = json.load(open(sys.argv[1]))
    db = {}
    for name, d in spec.items():
        db[name] = dict(dist_km=d["dist_km"], cond=d["cond"], s=sample(d["photo"], d["boxes"]))
        print(name, d["dist_km"], d["cond"])
        for k, v in db[name]["s"].items(): print("   ", k, v)
    json.dump(db, open("../cache/boxes_out.json", "w"), indent=1)
EOF
cat > ../cache/boxes.json <<'EOF'
{
 "sea_clear_94": {"photo":"../refs/Mount_Rainier_from_Seattle/openverse_Mount_Rainier_from_Seattle_Municipal_Tower_24d1e92a.jpg",
   "dist_km":94, "cond":"clear May evening",
   "boxes":{"snow_lit":[470,205,500,230],"snow_shade":[390,195,410,210],"sky_at_peak":[300,160,350,185],
            "sky_high":[300,20,700,60],"foothills":[450,295,550,320],"haze_band":[300,322,700,340]}}
}
EOF
python3 boxes.py ../cache/boxes.json

# output:
sea_clear_94 94 clear May evening
    snow_lit {'L': 65.2, 'a': 1.6, 'b': 11.0, 'L10': 55.3, 'L90': 70.7}
    snow_shade {'L': 59.9, 'a': 0.2, 'b': 6.1, 'L10': 51.2, 'L90': 69.0}
    sky_at_peak {'L': 57.7, 'a': -3.4, 'b': -1.8, 'L10': 57.4, 'L90': 58.1}
    sky_high {'L': 56.8, 'a': -3.9, 'b': -6.3, 'L10': 56.3, 'L90': 57.3}
    foothills {'L': 45.3, 'a': -3.3, 'b': -3.4, 'L10': 44.5, 'L90': 45.7}
    haze_band {'L': 44.7, 'a': -2.3, 'b': -4.0, 'L10': 43.6, 'L90': 45.4}
Bash: Crop Silver Queen photo with grid
cd ~/work/pixelart-studies/elements/snowpeak; python3 -c "
from PIL import Image, ImageDraw
im=Image.open('refs/Mount_Rainier_September/commons_Mount_Rainier_from_the_Silver_Queen_Peak_jpg_926c3740.jpg')
print(im.size)
c=im.crop((200,250,1200,650)); d=ImageDraw.Draw(c)
for x in range(0,1000,100): d.line([(x,0),(x,400)],fill=(255,0,0))
for y in range(0,400,100): d.line([(0,y),(1000,y)],fill=(255,0,0))
c.save('img/crop_sq_grid.png')"

# output:
(1280, 855)
Read: elements/snowpeak/img/crop_sq_grid.png

elements/snowpeak/img/crop_sq_grid.png 56. elements/snowpeak/img/crop_sq_grid.png

Bash: Sample Silver Queen boxes
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
import json
d=json.load(open("../cache/boxes.json"))
d["sq_july_21"]={"photo":"../refs/Mount_Rainier_September/commons_Mount_Rainier_from_the_Silver_Queen_Peak_jpg_926c3740.jpg","dist_km":21,"cond":"clear July noon",
 "boxes":{"snow_lit":[500,310,540,340],"rock_wall":[670,310,710,340],"rock_lit_ridge":[670,410,700,430],"scree_lit":[760,460,800,490],
  "forest_5km":[300,580,500,640],"forest_15km":[220,500,280,520],"sky_at_peak":[300,270,450,310],"sky_high":[300,20,900,80]}}
json.dump(d,open("../cache/boxes.json","w"),indent=1)
EOF
python3 boxes.py ../cache/boxes.json | sed -n '8,40p'

# output:
sq_july_21 21 clear July noon
    snow_lit {'L': 91.4, 'a': 1.0, 'b': 4.7, 'L10': 79.5, 'L90': 96.3}
    rock_wall {'L': 53.8, 'a': 3.0, 'b': -11.5, 'L10': 36.4, 'L90': 83.0}
    rock_lit_ridge {'L': 82.7, 'a': 2.4, 'b': 2.3, 'L10': 47.5, 'L90': 94.5}
    scree_lit {'L': 58.3, 'a': 2.9, 'b': -3.3, 'L10': 51.5, 'L90': 79.9}
    forest_5km {'L': 21.0, 'a': 1.7, 'b': -18.0, 'L10': 16.5, 'L90': 25.0}
    forest_15km {'L': 43.4, 'a': 1.3, 'b': -13.4, 'L10': 20.3, 'L90': 51.8}
    sky_at_peak {'L': 64.0, 'a': -2.9, 'b': -18.7, 'L10': 62.7, 'L90': 65.0}
    sky_high {'L': 46.6, 'a': -2.5, 'b': -28.6, 'L10': 44.3, 'L90': 48.9}
Bash: Look at observation student's pixel primitives
ls ~/work/pixelart-studies/pedagogy/observation/ 2>/dev/null | head; grep -n "def majority_clean\|def checker\|def clean_profile" -A20 ~/work/pixelart-studies/pedagogy/observation/pix.py | head -80

# output:
LESSONS.md
__pycache__
blockin.py
compare_stats.py
contact.py
ferrari
ferrari_contours.py
fetch_refs.py
out
palette_card.py
11:def clean_profile(yf, max_run=14):
12-    """float y per column -> int y per column made of consistent runs.
13-    |slope| < 1 : horizontal runs of n = round(1/|slope|) px then a 1 px step
14-    |slope| >= 1: 1 px runs with vertical steps of k = round(|slope|)
15-    the pattern is held until the contour drifts more than 1 px from the true curve."""
16-    yf = np.asarray(yf, float); n = len(yf)
17-    if n == 0: return yf.astype(int)
18-    out = np.zeros(n, int); y = int(round(yf[0])); out[0] = y
19-    run_len, run_pos, step = 1, 0, 0
20-    def choose(x):
21-        x2 = min(n - 1, x + 3); x1 = max(0, x - 1)
22-        s = (yf[x2] - yf[x1]) / max(x2 - x1, 1)
23-        if abs(s) < 1.0 / max_run: return max_run, 0
24-        if abs(s) < 1: return int(np.clip(round(1 / abs(s)), 1, max_run)), int(np.sign(s))
25-        return 1, int(np.sign(s) * round(abs(s)))
26-    run_len, step = choose(0)
27-    for x in range(1, n):
28-        run_pos += 1
29-        if run_pos >= run_len:
30-            y += step; run_pos = 0
31-            run_len, step = choose(x)
--
38:def majority_clean(z, iters=1, protect=None):
39-    """replace pixels that disagree with both horizontal or both vertical neighbours, when those
40-    neighbours agree with each other (kills single-pixel noise, keeps 1-px lines that run)."""
41-    z = z.copy()
42-    for _ in range(iters):
43-        l = np.roll(z, 1, 1); r = np.roll(z, -1, 1); u = np.roll(z, 1, 0); d = np.roll(z, -1, 0)
44-        iso_h = (l == r) & (z != l); iso_v = (u == d) & (z != u)
45-        iso = iso_h & iso_v
46-        if protect is not None: iso &= ~protect
47-        z = np.where(iso, l, z)
48-    return z
49-
50-def stipple(t, rng, bias=0.0):
51-    """True where colour B should be used, given fraction t in [0,1] (white-noise threshold)."""
52-    return t + bias > rng.uniform(0, 1, np.shape(t))
53-
54:def checker(t, yy, xx):
55-    """3-level: t<1/3 -> A (False), t>2/3 -> B (True), otherwise 50% checkerboard"""
56-    c = ((xx + yy) & 1) == 1
57-    return np.where(t < 1 / 3, False, np.where(t > 2 / 3, True, c))
58-
59-def hex2rgb(h):
60-    h = h.lstrip("#"); return np.array([int(h[i:i + 2], 16) for i in (0, 2, 4)], np.uint8)
Write: elements/snowpeak/src/paint.py
"""paint.py -- translate a snow-peak G-buffer into a painted pixel image.

Pipeline (each stage is a function you can call or replace):

  1. coverage   supersampled G-buffer -> per-pixel snow fraction; snow decided by fraction, then
                cleaned so snow reads as shapes, never salt-and-pepper (aerial student's failure)
  2. material   sky / snow / ice (bare glacier) / rock / scree / meadow / forest
  3. family     lit or shade (sun n.l and cast shadow); never dithered across (observation student)
  4. step       a designed form coordinate inside each family: plateau-lit snow, core + sky-lit shade,
                gullies a step down, ribs and sun-facing crests a step up (aerial cavity tone)
  5. band       distance band with dithered seams; far bands collapse to fewer steps
  6. palette    colour = physical light on the material at that step's representative n.l, then
                the band's haze (Koschmieder: tau = exp(-d / D_h)).  The palette IS the light: one
                index canvas re-lights for any hour or haze by swapping the palette.

    canvas = index(G, P)             # dict of per-pixel int arrays
    rgb    = colour(canvas, G, P, L) # L = light state (sun, sky, haze)
"""
import numpy as np
from scipy import ndimage as ndi

MATS = ["sky", "snow", "ice", "rock", "scree", "meadow", "forest", "water"]
M = {k: i for i, k in enumerate(MATS)}
# steps per family (shade, lit)
NSTEP = {"snow": (3, 3), "ice": (2, 2), "rock": (2, 3), "scree": (2, 2), "meadow": (2, 2),
         "forest": (2, 2), "water": (1, 1), "sky": (1, 1)}

DEFAULT = dict(
    snow_thr=0.5,          # pixel is snow when this fraction of its supersamples is snow
    clean_iters=2,
    min_island=2,          # snow/rock islands smaller than this (px) are absorbed, unless ribs
    bands_km=(4, 10, 25, 55),
    seam=0.06,             # band-edge jitter, fraction of distance (aerial: 6% = 1-2 px dither)
    cav_k=0.9,             # curvature threshold in units of metres-per-pixel for gully/rib steps
    lit_min=0.06,          # n.l above which a sunlit pixel is in the lit family
)

BAYER4 = np.array([[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]) / 16 + 1 / 32


def bayer(h, w):
    return np.tile(BAYER4, (h // 4 + 1, w // 4 + 1))[:h, :w]


def clustered_noise(h, w, rng):
    c = rng.uniform(size=((h + 1) // 2 + 1, (w + 1) // 2 + 1))
    c = np.repeat(np.repeat(c, 2, 0), 2, 1)[:h, :w]
    off = rng.integers(0, 2, size=h)
    for y in range(h):
        c[y] = np.roll(c[y], off[y])
    return 0.55 * c + 0.45 * rng.uniform(size=(h, w))


def majority_clean(z, iters=1, protect=None):
    z = z.copy()
    for _ in range(iters):
        l = np.roll(z, 1, 1); r = np.roll(z, -1, 1); u = np.roll(z, 1, 0); d = np.roll(z, -1, 0)
        iso = (l == r) & (z != l) & (u == d) & (z != u)
        if protect is not None:
            iso &= ~protect
        z = np.where(iso, l, z)
    return z


def down(a, k, how="mean"):
    H, W = a.shape[0] // k, a.shape[1] // k
    a = a[:H * k, :W * k].reshape(H, k, W, k, *a.shape[2:])
    if how == "mean":
        return a.mean(axis=(1, 3))
    if how == "min":
        return a.min(axis=(1, 3))
    if how == "max":
        return a.max(axis=(1, 3))
    return a[:, k // 2, :, k // 2]            # centre sample


# ------------------------------------------------------------------ 1-2: coverage and material
def material(Gs, k, P):
    """Gs: supersampled G-buffer (k x native). returns native material map + per-pixel fields"""
    sky_s = Gs["sky"]
    snow_s = ((Gs["cover"] > 0.5) | (Gs["glacier"] > 0)) & ~sky_s
    ice_s = (Gs["glacier"] == 2) & ~sky_s
    f_sky = down(sky_s.astype(float), k)
    f_snow = down(snow_s.astype(float), k)
    f_ice = down(ice_s.astype(float), k)
    f_tree = down(np.where(sky_s, 0, Gs["trees"]), k)
    f_bare = down(np.where(sky_s, 0, Gs["bare"]), k)
    H, W = f_sky.shape
    mat = np.full((H, W), M["rock"], np.int16)
    terr = f_sky < 0.5
    mat[~terr] = M["sky"]
    veg = terr & (f_tree > 0.5)
    mat[veg] = M["forest"]
    z = down(np.where(sky_s, 0, Gs["z"]), k)
    meadow = terr & ~veg & (f_bare < 0.3) & (z < 2000) & (f_tree < 0.5)
    mat[meadow] = M["meadow"]
    snowfrac = f_snow / np.maximum(1 - f_sky, 1e-3)
    sn = terr & (snowfrac >= P["snow_thr"])
    mat[sn] = M["snow"]
    mat[sn & (f_ice / np.maximum(f_snow, 1e-3) > 0.5)] = M["ice"]
    # clean: single pixels that disagree with both pairs of neighbours
    mat = majority_clean(mat, P["clean_iters"], protect=~terr)
    return mat, dict(snowfrac=snowfrac, terr=terr)


# ------------------------------------------------------------------ 3-4: family and step
def family_step(mat, G, P):
    ndl = G["ndl"]; lit = (ndl > P["lit_min"]) & (G["sunlit"] > 0.5)
    up = G["normal"][..., 2]
    H, W = mat.shape
    step = np.zeros((H, W), np.int16)
    mpp = np.maximum(G["dist"], 1) * G["pix_rad"]                 # metres per pixel on the ground
    cav = G["curv"] < -P["cav_k"] * np.clip(mpp * 0.25, 1.5, 30)
    rib = G["curv"] > P["cav_k"] * np.clip(mpp * 0.25, 1.5, 30)
    for name in MATS:
        m = mat == M[name]
        if not m.any():
            continue
        ns, nl = NSTEP[name]
        # lit: plateau that saturates early (observation student: granite scatters broadly)
        xl = np.clip((ndl - P["lit_min"]) / 0.55, 0, 1) ** 0.7 * (nl - 1 + 0.49)
        # shade: core next to the terminator, sky-lit (upward-facing) deeper in
        xs = np.clip((up - 0.35) / 0.55, 0, 1) * (ns - 1 + 0.49)
        core = (ndl > -0.25) & (ndl <= P["lit_min"])
        xs = np.where(core & (G["sunlit"] > 0.5), np.minimum(xs, 0.4), xs)
        x = np.where(lit, xl, xs)
        s = np.floor(x).astype(np.int16)
        s = np.where(cav & m, np.maximum(s - 1, 0), s)
        s = np.where(rib & lit & m, np.minimum(s + 1, np.where(lit, nl, ns) - 1), s)
        step[m] = s[m]
    fam = lit.astype(np.int16)
    return fam, step, dict(frac=None)


def bands(G, P, rng=None):
    d = np.where(G["sky"], 1e12, G["dist"]) / 1000
    thr = bayer(*d.shape)
    dj = d * (1 + (thr - 0.5) * 2 * P["seam"])
    return np.digitize(dj, P["bands_km"]).astype(np.int16)


def collapse(fam, step, band, mat):
    """aerial perspective removes steps, not only contrast: far bands keep 2 per family, the
    farthest 1 lit + 1 shade (aerial student's band collapse)"""
    s = step.copy()
    s = np.where(band >= 3, np.minimum(s, 1), s)
    s = np.where(band >= 4, np.where(fam == 1, np.maximum(s, 1), 0), s)
    return s


def index(G, Gs, k, P=None):
    P = dict(DEFAULT, **(P or {}))
    mat, info = material(Gs, k, P)
    fam, step, _ = family_step(mat, G, P)
    band = bands(G, P)
    step = collapse(fam, step, band, mat)
    fam[mat == M["sky"]] = 1; step[mat == M["sky"]] = 0
    return dict(mat=mat, fam=fam, step=step, band=band, **info)


# ------------------------------------------------------------------ 6: palette = light
ALBEDO = {  # linear RGB, measured/estimated (LESSONS part 1 has sources)
    "snow": np.array([0.86, 0.88, 0.90]), "firn": np.array([0.72, 0.73, 0.74]),
    "ice": np.array([0.36, 0.44, 0.50]), "rock": np.array([0.13, 0.115, 0.11]),
    "granite": np.array([0.30, 0.29, 0.27]), "scree": np.array([0.20, 0.19, 0.18]),
    "meadow": np.array([0.16, 0.14, 0.08]), "forest": np.array([0.035, 0.05, 0.04]),
    "water": np.array([0.04, 0.06, 0.08]),
}


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


def light_state(sun_el, sun_az, haze_km=60.0, exposure=1.0, sky_zenith=None, sky_horizon=None,
                airlight=None, warm=None):
    """a light: sun colour by elevation (warm and weak near the horizon), sky ambient, haze."""
    e = max(sun_el, -1.5)
    t = np.clip(e / 25, 0, 1)
    sun = np.array([1.0, 0.72 + 0.26 * t, 0.45 + 0.52 * t]) * (0.35 + 0.95 * np.clip(e / 20, 0, 1) ** 0.5)
    if warm is not None:
        sun = sun * np.array(warm)
    skyamb = np.array([0.30, 0.40, 0.60]) * (0.35 + 0.65 * t)
    zen = np.array(sky_zenith if sky_zenith is not None else [0.18, 0.30, 0.62]) * (0.4 + 0.6 * t)
    hor = np.array(sky_horizon if sky_horizon is not None else [0.55, 0.62, 0.75]) * (0.5 + 0.5 * t)
    air = np.array(airlight if airlight is not None else hor)
    return dict(sun=sun, skyamb=skyamb, zenith=zen, horizon=hor, air=air, haze_km=haze_km,
                exposure=exposure, el=sun_el, az=sun_az)


# representative n.l / sky view for each step (the palette is computed at these, not per pixel)
def step_light(name, fam, s):
    ns, nl = NSTEP[name]
    if fam == 1:
        ndl = 0.18 + 0.72 * (s + 0.5) / nl
        sky = 0.75
    else:
        ndl = 0.0
        sky = 0.35 + 0.6 * (s + 0.5) / ns
    return ndl, sky


def base_colour(name, fam, s, L, rock_albedo="rock"):
    alb = ALBEDO[rock_albedo if name == "rock" else name]
    ndl, sky = step_light(name, fam, s)
    direct = L["sun"] * ndl if fam == 1 else 0.0
    bounce = 0.10 * L["sun"] * (name in ("snow", "ice")) * (fam == 0)     # snow lights its own shadows
    return alb * (direct + L["skyamb"] * sky + bounce) * L["exposure"]


def colour(C, G, L, rock_albedo="rock", band_km=(2, 7, 17, 40, 80), subject_clear=0.0):
    """C: index canvas. band_km: representative distance for each band (for haze).
    subject_clear: 0..1 removes that fraction of haze from the snow/rock of the peak (Ferrari's
    'haze compresses what is behind, not the subject')."""
    H, W = C["mat"].shape
    out = np.zeros((H, W, 3))
    # sky: zenith to horizon by elevation angle of the ray
    elev = np.degrees(np.arcsin(np.clip(G["D"][..., 2], -1, 1)))
    tt = np.clip(elev / 25, 0, 1) ** 0.6
    skyc = L["horizon"][None, None] * (1 - tt[..., None]) + L["zenith"][None, None] * tt[..., None]
    out[:] = skyc
    pal = {}
    for name in MATS:
        if name == "sky":
            continue
        m0 = C["mat"] == M[name]
        if not m0.any():
            continue
        ns, nl = NSTEP[name]
        for fam in (0, 1):
            for s in range(nl if fam else ns):
                for b in range(len(band_km)):
                    m = m0 & (C["fam"] == fam) & (C["step"] == s) & (C["band"] == b)
                    if not m.any():
                        continue
                    c = base_colour(name, fam, s, L, rock_albedo)
                    tau = np.exp(-band_km[b] / L["haze_km"])
                    if name in ("snow", "ice", "rock") and subject_clear > 0:
                        tau = tau + (1 - tau) * subject_clear
                    c = c * tau + L["air"] * (1 - tau)
                    pal[(name, fam, s, b)] = c
                    out[m] = c
    return (srgb(out) * 255).astype(np.uint8), pal
Write: elements/snowpeak/src/render.py
"""render.py -- paint a fitted scene and set it beside its photo.

    python3 render.py rainier_ne_july summer 320 [z_sl]
writes stages/r_<shot>_<season>.png : photo (box-downsampled to the same pixels) | painting, at 3x
"""
import numpy as np, sys, os, json
from PIL import Image
import scene, paint

HERE = os.path.dirname(os.path.abspath(__file__))


def native_from_super(Gs, k, W, H, hfov):
    G = {}
    D = paint.down(Gs["D"], k, "centre"); G["D"] = D / np.linalg.norm(D, axis=-1, keepdims=True)
    sky = paint.down(Gs["sky"].astype(float), k) > 0.5
    G["sky"] = sky
    for key in ("ndl", "sunlit", "curv", "z", "cover", "hs", "cornice", "trees", "bare", "slope", "sx", "I"):
        a = np.where(Gs["sky"], np.nan, Gs[key].astype(float))
        with np.errstate(all="ignore"):
            v = np.nanmean(a[:H * k, :W * k].reshape(H, k, W, k), axis=(1, 3))
        G[key] = np.nan_to_num(v)
    n = np.where(Gs["sky"][..., None], np.nan, Gs["normal"])
    with np.errstate(all="ignore"):
        nn = np.nanmean(n[:H * k, :W * k].reshape(H, k, W, k, 3), axis=(1, 3))
    nn = np.nan_to_num(nn); nn[..., 2] += 1e-6
    G["normal"] = nn / np.linalg.norm(nn, axis=-1, keepdims=True)
    d = np.where(Gs["sky"], np.inf, Gs["dist"])
    G["dist"] = np.where(sky, np.inf, np.min(d[:H * k, :W * k].reshape(H, k, W, k), axis=(1, 3)))
    G["glacier"] = paint.down(Gs["glacier"].astype(float), k, "centre")
    G["pix_rad"] = np.radians(hfov) / W
    return G


def run(shot, season, W=320, k=3, season_over=None, light=None, P=None, sun=None, colour_kw=None):
    S = scene.Scene(shot, W=W * k)
    S.snow(season, season_over=season_over)
    Gs = S.gbuffer(sun=sun)
    H = S.H // k
    G = native_from_super(Gs, k, W, H, S.C.hfov)
    C = paint.index(G, Gs, k, P)
    az, el = S.sun_used
    L = light or paint.light_state(el, az)
    rgb, pal = paint.colour(C, G, L, **(colour_kw or {}))
    return S, G, C, rgb, pal


def photo_native(S, W, H):
    return np.asarray(Image.open(S.s["photo"]).convert("RGB").resize((W, H), Image.BOX))


def pair(S, rgb, out, scale=3):
    H, W = rgb.shape[:2]
    ph = photo_native(S, W, H)
    im = np.concatenate([ph, np.full((H, 2, 3), 255, np.uint8), rgb], 1)
    Image.fromarray(im).resize((im.shape[1] * scale, H * scale), Image.NEAREST).save(out)


if __name__ == "__main__":
    shot, season = sys.argv[1], sys.argv[2]
    W = int(sys.argv[3]) if len(sys.argv) > 3 else 320
    so = dict(z_sl=float(sys.argv[4])) if len(sys.argv) > 4 else None
    S, G, C, rgb, pal = run(shot, season, W, season_over=so)
    tag = sys.argv[5] if len(sys.argv) > 5 else ""
    pair(S, rgb, os.path.join(HERE, f"../stages/r_{shot}_{season}{tag}.png"))
    print("colours", len(pal))
Bash: First painted render of the July view
cd ~/work/pixelart-studies/elements/snowpeak/src; NUMBA_NUM_THREADS=4 OPENBLAS_NUM_THREADS=1 nice -n 10 python3 render.py rainier_ne_july summer 320 1900 _v1 2>&1 | tail -3

# output:
~/work/pixelart-studies/elements/snowpeak/src/render.py:25: RuntimeWarning: Mean of empty slice
  nn = np.nanmean(n[:H * k, :W * k].reshape(H, k, W, k, 3), axis=(1, 3))
colours 24
Read: elements/snowpeak/stages/r_rainier_ne_july_summer_v1.png

elements/snowpeak/stages/r_rainier_ne_july_summer_v1.png 57. elements/snowpeak/stages/r_rainier_ne_july_summer_v1.png

Bash: Print palette L*/b* for noon light
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > palcheck.py <<'EOF'
import numpy as np, paint
from skimage.color import rgb2lab
def L(c): return np.round(rgb2lab(paint.srgb(np.array(c))[None,None])[0,0],1)
def show(Ls, km=(2,7,17,40,80)):
    for name in ("snow","ice","rock","scree","forest","meadow"):
        ns,nl=paint.NSTEP[name]
        for fam in (0,1):
            row=[]
            for s in range(nl if fam else ns):
                c=paint.base_colour(name,fam,s,Ls)
                row.append(str(L(c)[0])+"/"+str(L(c)[2]))
            print(f"{name:7s} {'lit ' if fam else 'shade'}", " ".join(row))
    print("sky zen",L(Ls["zenith"]),"hor",L(Ls["horizon"]))
if __name__=="__main__":
    show(paint.light_state(64,153))
EOF
python3 palcheck.py

# output:
snow    shade 58.8/-11.3 64.7/-13.8 69.9/-15.8
snow    lit  81.6/-11.5 94.5/-8.3 100.0/0.0
ice     shade 44.2/-13.8 50.8/-17.0
ice     lit  63.7/-14.2 77.3/-13.1
rock    shade 17.0/-6.4 22.6/-7.5
rock    lit  33.9/-3.4 40.6/-2.0 45.9/-1.1
scree   shade 22.8/-7.6 29.4/-8.9
scree   lit  44.8/-3.7 55.3/-1.8
forest  shade 8.2/-3.5 12.4/-4.1
forest  lit  21.9/-0.8 28.4/0.6
meadow  shade 18.7/1.9 24.6/2.2
meadow  lit  38.7/9.3 48.2/12.9
sky zen [ 61.4   4.1 -33. ] hor [ 82.6  -0.  -10.2]
Bash: Retune sky and scree, recheck palette
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace('"scree": np.array([0.20, 0.19, 0.18])','"scree": np.array([0.32, 0.31, 0.30])')
s=s.replace("zen = np.array(sky_zenith if sky_zenith is not None else [0.18, 0.30, 0.62])","zen = np.array(sky_zenith if sky_zenith is not None else [0.055, 0.12, 0.34])")
s=s.replace("hor = np.array(sky_horizon if sky_horizon is not None else [0.55, 0.62, 0.75])","hor = np.array(sky_horizon if sky_horizon is not None else [0.28, 0.36, 0.50])")
s=s.replace("haze_km=60.0, exposure=1.0","haze_km=80.0, exposure=0.85")
open("paint.py","w").write(s)
EOF
python3 palcheck.py

# output:
snow    shade 54.8/-10.8 60.4/-13.1 65.4/-15.0
snow    lit  76.5/-10.8 88.7/-8.8 98.3/-2.6
ice     shade 41.0/-13.1 47.3/-16.1
ice     lit  59.5/-13.4 72.4/-12.4
rock    shade 15.2/-6.1 20.5/-7.1
rock    lit  31.3/-3.2 37.6/-1.9 42.6/-1.0
scree   shade 27.3/-9.1 34.6/-10.6
scree   lit  51.8/-5.0 63.5/-3.0
forest  shade 7.0/-3.3 10.9/-3.9
forest  lit  19.9/-0.7 26.1/0.6
meadow  shade 16.9/1.8 22.5/2.1
meadow  lit  35.8/8.8 44.8/12.2
sky zen [ 41.5   7.6 -36.3] hor [ 66.   -0.4 -15.3]
Bash: Add scree material and re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace("bounce = 0.10 * L[\"sun\"]","bounce = 0.20 * L[\"sun\"]")
s=s.replace("[0.055, 0.12, 0.34]","[0.05, 0.125, 0.33]")
s=s.replace('''    mat[meadow] = M["meadow"]''','''    mat[meadow] = M["meadow"]
    slope = down(np.where(sky_s, 0, Gs["slope"]), k)
    scree = terr & ~veg & ~meadow & (f_bare > 0.4) & (slope < 38)
    mat[scree] = M["scree"]''')
open("paint.py","w").write(s)
EOF
python3 palcheck.py | head -2; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 1900 _v2 2>&1 | tail -1

# output:
snow    shade 63.6/-8.8 68.1/-11.2 72.3/-13.1
snow    lit  76.5/-10.8 88.7/-8.8 98.3/-2.6
colours 28
Read: elements/snowpeak/stages/r_rainier_ne_july_summer_v2.png

elements/snowpeak/stages/r_rainier_ne_july_summer_v2.png 58. elements/snowpeak/stages/r_rainier_ne_july_summer_v2.png

Bash: Debug material, family and band maps
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > dbg_mat.py <<'EOF'
import numpy as np, sys, render, paint
from PIL import Image
S,G,C,rgb,pal=render.run(sys.argv[1],sys.argv[2],320,season_over=dict(z_sl=float(sys.argv[3])))
cols=np.array([[150,190,255],[255,255,255],[80,170,220],[90,60,60],[180,170,150],[200,200,60],[20,90,30],[0,0,160]],np.uint8)
im=cols[C["mat"]]
fb=np.stack([C["fam"]*200]*3,-1).astype(np.uint8)
bd=np.stack([C["band"]*50]*3,-1).astype(np.uint8)
out=np.concatenate([im,fb,bd],1)
Image.fromarray(out).resize((out.shape[1]*2,out.shape[0]*2),Image.NEAREST).save("../stages/dbg_mat.png")
print("trees mean",G["trees"][~G["sky"]].mean(), "dist med",np.median(G["dist"][~G["sky"]]))
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 dbg_mat.py rainier_ne_july summer 1900 2>&1 | tail -1

# output:
trees mean 0.6959617660213319 dist med 7877.445511803346
Read: elements/snowpeak/stages/dbg_mat.png

elements/snowpeak/stages/dbg_mat.png 59. elements/snowpeak/stages/dbg_mat.png

Bash: Darken forest, add debris and island cleanup
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace('"forest": np.array([0.035, 0.05, 0.04])','"forest": np.array([0.018, 0.026, 0.022])')
s=s.replace('''MATS = ["sky", "snow", "ice", "rock", "scree", "meadow", "forest", "water"]''','''MATS = ["sky", "snow", "ice", "rock", "scree", "meadow", "forest", "water", "debris"]''')
s=s.replace('''"forest": (2, 2), "water": (1, 1), "sky": (1, 1)}''','''"forest": (2, 2), "water": (1, 1), "sky": (1, 1), "debris": (2, 2)}''')
s=s.replace('''    "water": np.array([0.04, 0.06, 0.08]),''','''    "water": np.array([0.04, 0.06, 0.08]), "debris": np.array([0.14, 0.13, 0.125]),''')
s=s.replace('''    min_island=2,''','''    min_island=3,          # snow patches / rock islands smaller than this many px are absorbed
    debris_z=1850,         # bare glacier ice below this is debris-covered (Rainier's Emmons, Carbon)''')
s=s.replace('''    # clean: single pixels that disagree with both pairs of neighbours
    mat = majority_clean(mat, P["clean_iters"], protect=~terr)''','''    mat[(mat == M["ice"]) & (z < P["debris_z"])] = M["debris"]
    # clean: single pixels that disagree with both pairs of neighbours
    mat = majority_clean(mat, P["clean_iters"], protect=~terr)
    # small snow patches and small rock islands in snow: absorbed unless the curvature says they
    # are real forms (gully fill for snow, projecting ribs for rock -- Ruskin's arrowheads)
    curv = down(np.where(sky_s, 0, Gs["curv"]), k)
    for is_snow in (True, False):
        m = np.isin(mat, [M["snow"], M["ice"]]) if is_snow else (terr & ~np.isin(mat, [M["snow"], M["ice"]]))
        lab, n = ndi.label(m)
        if n == 0:
            continue
        area = ndi.sum(np.ones_like(lab), lab, np.arange(1, n + 1))
        cmean = ndi.mean(curv, lab, np.arange(1, n + 1))
        keep_form = (cmean < -1.0) if is_snow else (cmean > 1.0)
        small = (area < P["min_island"]) & ~(keep_form & (area >= 2))
        kill = small[lab - 1] & (lab > 0)
        if is_snow:
            mat[kill] = M["rock"]
        else:
            mat[kill] = M["snow"]''')
open("paint.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 1900 _v3 2>&1 | tail -1

# output:
colours 30
Read: elements/snowpeak/stages/r_rainier_ne_july_summer_v3.png

elements/snowpeak/stages/r_rainier_ne_july_summer_v3.png 60. elements/snowpeak/stages/r_rainier_ne_july_summer_v3.png

Bash: Print palette entries used with pixel counts
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > dbg_pal.py <<'EOF'
import numpy as np, sys, render, paint
from skimage.color import rgb2lab
S,G,C,rgb,pal=render.run(sys.argv[1],sys.argv[2],320,season_over=dict(z_sl=float(sys.argv[3])))
for k,c in sorted(pal.items()):
    n=((C["mat"]==paint.M[k[0]])&(C["fam"]==k[1])&(C["step"]==k[2])&(C["band"]==k[3])).sum()
    print(k, np.round(rgb2lab(paint.srgb(c)[None,None])[0,0],1), n)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 dbg_pal.py rainier_ne_july summer 1900 2>&1 | grep -v Warn

# output:
v = np.nanmean(a[:H * k, :W * k].reshape(H, k, W, k), axis=(1, 3))
  nn = np.nanmean(n[:H * k, :W * k].reshape(H, k, W, k, 3), axis=(1, 3))
('debris', 1, 0, 2) [43.6  0.4 -7.6] 3
('debris', 1, 1, 2) [49.2  0.7 -6. ] 20
('forest', 0, 0, 2) [32.  -0.5 -8.9] 22
('forest', 1, 0, 0) [17.4 -2.8 -3.4] 18
('forest', 1, 0, 1) [24.9 -1.8 -5.9] 2620
('forest', 1, 0, 2) [34.  -1.2 -8.3] 423
('forest', 1, 1, 0) [21.2 -3.6 -2.3] 152
('forest', 1, 1, 1) [27.4 -2.6 -5. ] 19792
('forest', 1, 1, 2) [35.5 -1.7 -7.7] 3292
('ice', 1, 0, 2) [ 60.8  -1.3 -13.8] 74
('ice', 1, 1, 2) [ 71.3  -1.9 -12.9] 769
('meadow', 1, 0, 1) [39.8 -1.7  3.3] 291
('meadow', 1, 0, 2) [44.2 -1.4 -1.3] 101
('meadow', 1, 1, 1) [47.2 -1.8  7.3] 754
('meadow', 1, 1, 2) [50.  -1.5  2.4] 380
('rock', 0, 0, 2) [34.7  0.  -9.5] 10
('rock', 1, 0, 2) [41.6  0.7 -7.8] 122
('rock', 1, 1, 1) [41.2  1.4 -4.3] 17
('rock', 1, 1, 2) [45.3  1.  -6.6] 382
('rock', 1, 2, 1) [45.4  1.7 -3.3] 230
('rock', 1, 2, 2) [48.5  1.3 -5.7] 1146
('scree', 1, 0, 1) [53.2  0.4 -6.3] 7
('scree', 1, 0, 2) [55.   0.3 -7.7] 145
('scree', 1, 1, 1) [63.7  0.6 -4.2] 102
('scree', 1, 1, 2) [64.   0.5 -5.6] 1335
('snow', 1, 0, 2) [ 74.6   0.2 -11.6] 37
('snow', 1, 1, 1) [87.1  0.2 -9.1] 8
('snow', 1, 1, 2) [85.1  0.2 -9.6] 362
('snow', 1, 2, 1) [96.4 -0.8 -5.5] 71
('snow', 1, 2, 2) [93.7  0.2 -8.4] 5469
Bash: Sliver filter, wider lit ramp, crop view; re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace('"forest": np.array([0.018, 0.026, 0.022])','"forest": np.array([0.012, 0.018, 0.015])')
s=s.replace('''        small = (area < P["min_island"]) & ~(keep_form & (area >= 2))''','''        # rows spanned by each component: 1-row slivers read as dashes unless long (a ledge band)
        sl = ndi.find_objects(lab)
        rows = np.array([s_[0].stop - s_[0].start for s_ in sl])
        sliver = (rows < 2) & (area < P["ledge_min_len"])
        small = ((area < P["min_island"]) | sliver) & ~(keep_form & (area >= 2) & ~sliver)''')
s=s.replace('''    debris_z=1850,''','''    debris_z=1850,
    ledge_min_len=6,       # a 1-px-tall snow run shorter than this is a dash, not a ledge band
    lit_span=0.75, lit_gamma=1.0,   # n.l range and curve of the lit snow/rock ramp (plateau = small span)''')
s=s.replace("xl = np.clip((ndl - P[\"lit_min\"]) / 0.55, 0, 1) ** 0.7 * (nl - 1 + 0.49)","xl = np.clip((ndl - P[\"lit_min\"]) / P[\"lit_span\"], 0, 1) ** P[\"lit_gamma\"] * (nl - 1 + 0.49)")
open("paint.py","w").write(s)
s=open("render.py").read()
s=s.replace('''def pair(S, rgb, out, scale=3):
    H, W = rgb.shape[:2]
    ph = photo_native(S, W, H)''','''def pair(S, rgb, out, scale=3, crop=None):
    H, W = rgb.shape[:2]
    ph = photo_native(S, W, H)
    if crop:
        x0, y0, x1, y1 = crop; ph = ph[y0:y1, x0:x1]; rgb = rgb[y0:y1, x0:x1]; H = y1 - y0''')
s=s.replace('''    pair(S, rgb, os.path.join(HERE, f"../stages/r_{shot}_{season}{tag}.png"))''','''    pair(S, rgb, os.path.join(HERE, f"../stages/r_{shot}_{season}{tag}.png"))
    cr = fitcam.SHOTS[shot].get("crop")
    if cr:
        pair(S, rgb, os.path.join(HERE, f"../stages/r_{shot}_{season}{tag}_crop.png"), scale=5, crop=cr)''')
s=s.replace("import scene, paint","import scene, paint, fitcam")
open("render.py","w").write(s)
s=open("fitcam.py").read()
s=s.replace("summit=(548/1280,268/855), fovs=(15,45), snap=200),","summit=(548/1280,268/855), fovs=(15,45), snap=200, crop=(0,50,320,150)),")
open("fitcam.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 1900 _v4 2>&1 | tail -1

# output:
colours 31
Read: elements/snowpeak/stages/r_rainier_ne_july_summer_v4_crop.png

elements/snowpeak/stages/r_rainier_ne_july_summer_v4_crop.png 61. elements/snowpeak/stages/r_rainier_ne_july_summer_v4_crop.png

Bash: Photo L* distribution under each painted material
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > dbg_matL.py <<'EOF'
import numpy as np, sys, render, paint
from skimage.color import rgb2lab
S,G,C,rgb,pal=render.run(sys.argv[1],sys.argv[2],320,season_over=dict(z_sl=float(sys.argv[3])))
ph=render.photo_native(S,rgb.shape[1],rgb.shape[0]); lab=rgb2lab(ph/255.); mine=rgb2lab(rgb/255.)
for name in paint.MATS:
    m=C["mat"]==paint.M[name]
    if m.sum()<5: continue
    print(f"{name:7s} n={m.sum():5d} photo L {np.percentile(lab[...,0][m],[10,50,90]).round(1)} b {np.median(lab[...,2][m]):.1f} | mine L {np.median(mine[...,0][m]):.1f}")
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 dbg_matL.py rainier_ne_july summer 1900 2>&1 | grep -v -i warn | grep -v nanmean

# output:
sky     n=30006 photo L [43.2 55.9 65.5] b -23.1 | mine L 55.6
snow    n= 5953 photo L [52.3 82.9 91.1] b 4.0 | mine L 93.6
ice     n=  839 photo L [52.4 72.  87.4] b 0.1 | mine L 71.0
rock    n= 1922 photo L [25.2 47.6 65. ] b -9.6 | mine L 45.1
scree   n= 1574 photo L [39.2 54.5 66.1] b -5.3 | mine L 63.8
meadow  n= 1526 photo L [16.4 28.1 45.4] b -8.4 | mine L 47.0
forest  n=26319 photo L [10.2 14.5 21.9] b -9.7 | mine L 25.1
debris  n=   21 photo L [49.7 51.8 55. ] b -10.1 | mine L 48.9
Bash: Recalibrate albedos, snowline 2100, re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace('"forest": np.array([0.012, 0.018, 0.015])','"forest": np.array([0.0055, 0.0085, 0.0085])')
s=s.replace('"meadow": np.array([0.16, 0.14, 0.08])','"meadow": np.array([0.055, 0.06, 0.035])')
s=s.replace('"scree": np.array([0.32, 0.31, 0.30])','"scree": np.array([0.25, 0.245, 0.24])')
open("paint.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 dbg_matL.py rainier_ne_july summer 2100 2>&1 | grep -v -i warn | grep -v nanmean; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 2100 _v5 2>&1 | tail -1

# output:
sky     n=30006 photo L [43.2 55.9 65.5] b -23.1 | mine L 55.6
snow    n= 4290 photo L [58.8 85.5 91.6] b 4.7 | mine L 93.6
ice     n= 1727 photo L [54.3 77.2 88.9] b 2.5 | mine L 71.0
rock    n= 2163 photo L [26.  49.  68.8] b -9.3 | mine L 45.1
scree   n= 1862 photo L [40.3 55.6 72. ] b -4.9 | mine L 58.9
meadow  n= 1590 photo L [16.6 28.2 45.6] b -8.5 | mine L 34.6
forest  n=26489 photo L [10.2 14.6 22. ] b -9.8 | mine L 22.6
debris  n=   33 photo L [49.8 51.8 54.4] b -10.1 | mine L 48.9
colours 32
Bash: Locate band and haze code
cd ~/work/pixelart-studies/elements/snowpeak/src; grep -n "def bands" -A8 paint.py; grep -n "band_km\|tau" paint.py

# output:
162:def bands(G, P, rng=None):
163-    d = np.where(G["sky"], 1e12, G["dist"]) / 1000
164-    thr = bayer(*d.shape)
165-    dj = d * (1 + (thr - 0.5) * 2 * P["seam"])
166-    return np.digitize(dj, P["bands_km"]).astype(np.int16)
167-
168-
169-def collapse(fam, step, band, mat):
170-    """aerial perspective removes steps, not only contrast: far bands keep 2 per family, the
12:                the band's haze (Koschmieder: tau = exp(-d / D_h)).  The palette IS the light: one
239:def colour(C, G, L, rock_albedo="rock", band_km=(2, 7, 17, 40, 80), subject_clear=0.0):
240:    """C: index canvas. band_km: representative distance for each band (for haze).
260:                for b in range(len(band_km)):
265:                    tau = np.exp(-band_km[b] / L["haze_km"])
267:                        tau = tau + (1 - tau) * subject_clear
268:                    c = c * tau + L["air"] * (1 - tau)
Bash: Altitude-dependent haze path; re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace('''def bands(G, P, rng=None):
    d = np.where(G["sky"], 1e12, G["dist"]) / 1000''','''def eff_dist(dist, hc, ht, Ha=1500.0):
    """sea-level-equivalent haze path (m): aerosol density falls as exp(-h / Ha) (Ha ~ 1-2 km, the
    boundary layer), so a ray from hc to ht crosses dist * mean(exp(-h/Ha)) of sea-level haze.
    This is why summits stand clear above hazy foothills."""
    dh = ht - hc
    small = np.abs(dh) < 1.0
    mean = np.where(small, np.exp(-hc / Ha),
                    Ha * (np.exp(-hc / Ha) - np.exp(-ht / Ha)) / np.where(small, 1.0, dh))
    return dist * mean


def bands(G, P, rng=None):
    d = np.where(G["sky"], 1e12, G["deff"]) / 1000''')
s=s.replace('''    bands_km=(4, 10, 25, 55),''','''    bands_km=(1.5, 4, 10, 22),   # band edges in sea-level-equivalent haze km (see eff_dist)''')
s=s.replace('band_km=(2, 7, 17, 40, 80)','band_km=(0.7, 2.5, 6.5, 15, 35)')
open("paint.py","w").write(s)
s=open("render.py").read()
s=s.replace('''    G["pix_rad"] = np.radians(hfov) / W
    return G''','''    G["pix_rad"] = np.radians(hfov) / W
    return G


def add_haze_path(G, cam_z, Ha=1500.0):
    d = np.where(G["sky"], 0, G["dist"])
    G["deff"] = np.where(G["sky"], np.inf, paint.eff_dist(d, cam_z, G["z"], Ha))
    return G''')
s=s.replace('''    G = native_from_super(Gs, k, W, H, S.C.hfov)''','''    G = native_from_super(Gs, k, W, H, S.C.hfov)
    add_haze_path(G, S.C.z, (P or {}).get("haze_scale", 1500.0))''')
open("render.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 dbg_matL.py rainier_ne_july summer 2100 2>&1 | grep -v -i warn | grep -v nanmean; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 2100 _v6 2>&1 | tail -1

# output:
sky     n=30006 photo L [43.2 55.9 65.5] b -23.1 | mine L 55.6
snow    n= 4290 photo L [58.8 85.5 91.6] b 4.7 | mine L 97.5
ice     n= 1727 photo L [54.3 77.2 88.9] b 2.5 | mine L 72.1
rock    n= 2163 photo L [26.  49.  68.8] b -9.3 | mine L 43.6
scree   n= 1862 photo L [40.3 55.6 72. ] b -4.9 | mine L 57.5
meadow  n= 1590 photo L [16.6 28.2 45.6] b -8.5 | mine L 31.1
forest  n=26489 photo L [10.2 14.6 22. ] b -9.8 | mine L 14.7
debris  n=   33 photo L [49.8 51.8 54.4] b -10.1 | mine L 44.5
colours 35
Bash: Lower snow albedo and re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; sed -i '' 's/"snow": np.array(\[0.86, 0.88, 0.90\])/"snow": np.array([0.79, 0.81, 0.84])/' paint.py; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 2100 _v6 2>&1 | tail -1

# output:
colours 35
Read: elements/snowpeak/stages/r_rainier_ne_july_summer_v6.png

elements/snowpeak/stages/r_rainier_ne_july_summer_v6.png 62. elements/snowpeak/stages/r_rainier_ne_july_summer_v6.png

Bash: Locate step quantization
cd ~/work/pixelart-studies/elements/snowpeak/src; grep -n "s = np.floor(x)" -B3 -A3 paint.py

# output:
151-        core = (ndl > -0.25) & (ndl <= P["lit_min"])
152-        xs = np.where(core & (G["sunlit"] > 0.5), np.minimum(xs, 0.4), xs)
153-        x = np.where(lit, xl, xs)
154:        s = np.floor(x).astype(np.int16)
155-        s = np.where(cav & m, np.maximum(s - 1, 0), s)
156-        s = np.where(rib & lit & m, np.minimum(s + 1, np.where(lit, nl, ns) - 1), s)
157-        step[m] = s[m]
Bash: Seam dithering, sky colour, haze tuning; re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace('''        s = np.floor(x).astype(np.int16)
        s = np.where(cav & m''','''        # flat planes with a dithered seam at each half step (aerial student's flat_bands);
        # clustered thresholds so seams read as strings and clumps, not salt-and-pepper
        f = np.floor(x); frac = x - f
        seam = P["seam_w"]
        t = np.clip((frac - (0.5 - seam / 2)) / seam, 0, 1)
        s = (f + (t > thr)).astype(np.int16)
        s = np.minimum(s, np.where(lit, nl - 1, ns - 1))
        s = np.where(cav & m''')
s=s.replace('''    ndl = G["ndl"]; lit = (ndl > P["lit_min"]) & (G["sunlit"] > 0.5)''','''    ndl = G["ndl"]; lit = (ndl > P["lit_min"]) & (G["sunlit"] > 0.5)
    thr = clustered_noise(*ndl.shape, np.random.default_rng(P.get("seed", 1)))''')
s=s.replace('''    lit_span=0.75, lit_gamma=1.0,''','''    lit_span=0.75, lit_gamma=1.0,
    seam_w=0.35,           # width of the dithered seam between steps, in step units''')
s=s.replace("[0.05, 0.125, 0.33]","[0.035, 0.13, 0.34]").replace("[0.28, 0.36, 0.50]","[0.29, 0.40, 0.53]")
s=s.replace("haze_km=80.0, exposure=0.85","haze_km=45.0, exposure=0.85")
open("paint.py","w").write(s)
s=open("render.py").read()
s=s.replace('add_haze_path(G, S.C.z, (P or {}).get("haze_scale", 1500.0))','add_haze_path(G, S.C.z, (P or {}).get("haze_scale", 2000.0))')
open("render.py","w").write(s)
EOF
python3 palcheck.py | tail -1; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 2100 _v7 2>&1 | tail -1

# output:
sky zen [ 42.    2.4 -35.6] hor [ 68.5  -2.9 -14.3]
colours 30
Read: elements/snowpeak/stages/r_rainier_ne_july_summer_v7.png

elements/snowpeak/stages/r_rainier_ne_july_summer_v7.png 63. elements/snowpeak/stages/r_rainier_ne_july_summer_v7.png

Bash: Band haze by ridge layer; re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace('''def bands(G, P, rng=None):
    d = np.where(G["sky"], 1e12, G["deff"]) / 1000
    thr = bayer(*d.shape)
    dj = d * (1 + (thr - 0.5) * 2 * P["seam"])
    return np.digitize(dj, P["bands_km"]).astype(np.int16)''','''def bands(G, P, rng=None):
    """haze band per RIDGE LAYER, not per pixel: walk each column top-down; a depth jump
    (nearer by > layer_jump) starts a new layer, and the whole layer segment takes the band of the
    haze path at its crest.  Seams then sit on ridge crests, where a painter puts them, instead of
    wandering across a valley floor as a dithered smear (the aerial student's 'smoke' seams)."""
    d = np.where(G["sky"], 1e12, G["deff"]) / 1000
    dist = np.where(G["sky"], 1e12, G["dist"])
    H, W = d.shape
    raw = np.digitize(d, P["bands_km"]).astype(np.int16)
    out = raw.copy()
    jump = P["layer_jump"]
    for x in range(W):
        cur = None; prev = None
        for y in range(H):
            if G["sky"][y, x]:
                cur = None; prev = None; continue
            if cur is None or dist[y, x] * jump < prev:
                cur = raw[y, x]
            else:
                cur = max(min(cur, raw[y, x] + 1), raw[y, x] - 1) if P["layer_slack"] else cur
            out[y, x] = cur
            prev = dist[y, x]
    return out''')
s=s.replace('''    seam=0.06,''','''    seam=0.06,
    layer_jump=1.25,       # nearer by this ratio = a new ridge layer (for haze bands)
    layer_slack=1,         # a layer may drift one band from its crest before being re-banded''')
open("paint.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 2100 _v8 2>&1 | tail -1

# output:
colours 30
Read: elements/snowpeak/stages/r_rainier_ne_july_summer_v8.png

elements/snowpeak/stages/r_rainier_ne_july_summer_v8.png 64. elements/snowpeak/stages/r_rainier_ne_july_summer_v8.png

Bash: Mode-filtered haze bands; re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
a=s.index("def bands(G, P, rng=None):"); b=s.index("def collapse(")
s=s[:a]+'''def mode_filter(z, size=5, iters=2, mask=None):
    """majority vote in a window (integers >= 0): turns ragged seams into clean shapes"""
    z = z.copy(); K = int(z.max()) + 1
    for _ in range(iters):
        votes = np.stack([ndi.uniform_filter((z == k).astype(float), size) for k in range(K)])
        nz = votes.argmax(0).astype(z.dtype)
        z = np.where(mask, nz, z) if mask is not None else nz
    return z


def bands(G, P, rng=None):
    """haze bands from the sea-level-equivalent haze path, as clean flat shapes: quantize, then a
    majority filter so each seam is a crisp run (no dither: dithered seams read as smoke)."""
    d = np.where(G["sky"], 1e12, G["deff"]) / 1000
    raw = np.digitize(d, P["bands_km"]).astype(np.int16)
    terr = ~G["sky"]
    out = mode_filter(np.where(terr, raw, 0), P["band_mode"], 2, mask=terr)
    return np.where(terr, out, raw)


'''+s[b:]
s=s.replace('''    layer_jump=1.25,''','''    band_mode=5,           # majority window for haze-band seams
    layer_jump=1.25,''')
open("paint.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 2100 _v9 2>&1 | tail -1

# output:
colours 36
Read: elements/snowpeak/stages/r_rainier_ne_july_summer_v9.png

elements/snowpeak/stages/r_rainier_ne_july_summer_v9.png 65. elements/snowpeak/stages/r_rainier_ne_july_summer_v9.png

Read: elements/snowpeak/stages/r_rainier_ne_july_summer_v9_crop.png

elements/snowpeak/stages/r_rainier_ne_july_summer_v9_crop.png 66. elements/snowpeak/stages/r_rainier_ne_july_summer_v9_crop.png

Bash: Warmer lit snow; render at two snowlines
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace('"ice": np.array([0.36, 0.44, 0.50])','"ice": np.array([0.40, 0.45, 0.50])')
s=s.replace('''    if fam == 1:
        ndl = 0.18 + 0.72 * (s + 0.5) / nl
        sky = 0.75''','''    if fam == 1:
        ndl = 0.18 + 0.72 * (s + 0.5) / nl
        sky = 0.45''')
open("paint.py","w").write(s)
EOF
python3 palcheck.py | head -2
for z in 1900 2000; do NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 $z _z$z 2>&1 | tail -1; done

# output:
snow    shade 61.4/-9.2 65.9/-11.5 69.9/-13.4
snow    lit  68.4/-8.0 81.6/-6.5 92.0/-5.7
colours 36
colours 36
Bash: Neutral snow albedo, re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; sed -i '' 's/"snow": np.array(\[0.79, 0.81, 0.84\])/"snow": np.array([0.82, 0.815, 0.80])/' paint.py; python3 palcheck.py | head -2; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 1900 _z1900 2>&1 | tail -1

# output:
snow    shade 61.6/-6.8 66.1/-9.0 70.1/-10.8
snow    lit  68.6/-5.5 81.9/-3.6 92.3/-2.6
colours 36
Read: elements/snowpeak/stages/r_rainier_ne_july_summer_z1900_crop.png

elements/snowpeak/stages/r_rainier_ne_july_summer_z1900_crop.png 67. elements/snowpeak/stages/r_rainier_ne_july_summer_z1900_crop.png

Agent: Critic: Rainier pixel pair
Bash: Update notebook with progress
cd ~/work/pixelart-studies/elements/snowpeak; cat >> NOTEBOOK.md <<'EOF'

**Library (coordinator's note, mid-morning).** Read Imhof, *Cartographic Relief Presentation*
(scanned PDF, no text layer; pdf page = book page + 18): ch. 9 §8 aerial perspective (p172-173:
"dark tones grow lighter and light tones are subdued... contrast progressively reduced";
cartographers *sharpen* contrast toward high ground, subdue lowland), §18-19 (p190-193: shadow
colour grey-blue / blue-violet; **shading on snow must keep the same strength as on the rest of the
terrain** or snow ridges read as plateaus; shadows on snow pure blue), ch. 11 §3-5 (p243-248:
Figure 170 "normal group of forms": gullies and intervening ribs, parallel, acute-angled
"poplar-tree" branching, steepest near the top, triangular frontal facets; every gully ends in a
debris cone at 30-40 deg, fine at the top, blocks at the toe). Ruskin, *Modern Painters IV*
(text layer): the value problem ("the distant rocks of the upper peaks are themselves, when in
light, paler than white paper... yet as compared with their snow, so dark"), and rock showing "in
black points like arrow-heads through the snow, where their ridges are left projecting by the
avalanche channels." Both go straight into the method: gullies hold snow, ribs are arrowheads.

**Camera fits, round 2.** Control-point init (put the summit on its photo pixel, scan fov) fixed
local optima. rainier_ne_july (Silver Queen, 21 km): excellent, foreground ridges line up
(`stages/fit_rainier_ne_july.png`). sea_rainier_clear (Seattle tower, 94 km): good. rainier_w_sept
(Rainier20200906): FAILED. The GPS point sits in a hollow; snapping to the highest point within
300 m and 1.5 km, and a 9x9 km position search (`possearch.py`) all gave weak, ambiguous scores
(-0.52..-0.54). The tag is probably not where the photo was taken. Parked; used as a September
reference by eye only.

**Measurements** (`src/boxes.py`, `src/measure.py`, `cache/boxes_out.json`, `cache/measure.json`).

- Rainier 21 km, clear noon July: lit snow L*91, sky at the peak 64, sky high 47 (b* -29),
  rock wall 36-54 (b* -11: haze is already blue), forest at 5 km 21 (b* -18).
- Rainier 94 km, clear May evening: lit snow 65 vs sky 58 (+7.5); foothill forest band 45 (-12 vs sky).
- Rainier 96 km, August evening (sun just set at the camera, still on the summit): snow 73,
  rock 69, sky 73.6. Snow = sky. This is not haze, it is weak alpenglow: at dusk the lit snow sinks
  to the sky's value.
- Koschmieder fits in luminance: forest at ~60-70 km gives tau ~0.45, i.e. D_h ~ 80 km on the
  clear day. But the forest at 5-8 km under the Silver Queen camera (at 2129 m) is nearly
  un-hazed (L*14.6 vs intrinsic ~12). Uniform haze cannot fit both. **Aerosol density falls
  with height (scale ~1.5-2 km)**: the painter now uses a sea-level-equivalent haze path
  `eff_dist(dist, cam_z, target_z, Ha)`. This one change put the forest at the photo's value
  (22.6 -> 14.7) and is the physical reason summits stand clear above hazy foothills.

**Painter v1-v9** (`src/paint.py`, `src/render.py`, `stages/r_rainier_ne_july_summer_v*.png`).

- v1: silhouette right from the DEM; everything else grey and hazy, snow dashes, ice blue patches.
- `dbg_matL.py`: photo L* under each painted material vs mine. The single best instrument so far:
  forest 25 vs 14.5, meadow 47 vs 28, scree 64 vs 55. Albedos recalibrated.
- v7: seam dither between steps (clustered thresholds). Haze bands dithered at 6% of distance
  made a smoky checker field across the valley (the aerial student's trap, again).
- v8: per-column "ridge layer" bands: vertical streaks. v9: quantize the haze path, then a 5x5
  majority filter: clean flat haze shapes; the near ridge now separates dark against the bluer
  valley, as in the photo.
- Snow colour: blue-white against the photo's warm white; cause was a blue-tinted snow albedo and
  too much sky in the lit ramp. Fixed.
EOF
echo ok

# output:
ok
[This command modified 1 file you've previously read: ../../elements/snowpeak/NOTEBOOK.md. Call Read before editing.]
Bash: Occlusion-layer bands, wider lit ramp, sky; re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
a=s.index("def bands(G, P, rng=None):"); b=s.index("def collapse(")
s=s[:a]+'''def bands(G, P, rng=None):
    """haze bands per OCCLUSION LAYER: crest lines (depth jumps > layer_jump) cut the terrain into
    regions; each region is one flat haze band, the band of its median haze path.  Ferrari's ridge
    layers: each flat, lighter with distance, seams on crests.  Dithered seams read as smoke (aerial
    student); per-pixel bands wander across valley floors."""
    d = np.where(G["sky"], 1e12, G["deff"]) / 1000
    dist = np.where(G["sky"], 1e12, G["dist"])
    terr = ~G["sky"]
    raw = np.digitize(d, P["bands_km"]).astype(np.int16)
    up = np.roll(dist, 1, 0); up[0] = dist[0]
    lf = np.roll(dist, 1, 1); lf[:, 0] = dist[:, 0]
    rt = np.roll(dist, -1, 1); rt[:, -1] = dist[:, -1]
    edge = terr & ((up > dist * P["layer_jump"]) | (lf > dist * P["layer_jump"]) | (rt > dist * P["layer_jump"]))
    edge = ndi.binary_dilation(edge, structure=np.array([[0, 0, 0], [1, 1, 1], [0, 0, 0]]))
    lab, n = ndi.label(terr & ~edge)
    out = raw.copy()
    if n:
        med = ndi.median(d, lab, np.arange(1, n + 1))
        bnd = np.digitize(med, P["bands_km"]).astype(np.int16)
        area = ndi.sum(np.ones_like(lab), lab, np.arange(1, n + 1))
        big = area >= P["layer_min_area"]
        m = lab > 0
        out[m] = np.where(big[lab[m] - 1], bnd[lab[m] - 1], raw[m])
    # edge pixels take the band of the region just below them (they are the crest's front)
    dn = np.roll(out, -1, 0)
    out = np.where(edge, dn, out)
    return np.where(terr, out, raw)


'''+s[b:]
s=s.replace('''    layer_jump=1.25,''','''    layer_jump=1.2,
    layer_min_area=30,     # smaller regions keep per-pixel bands
    lit_lo=0.35,           # the lit ramp starts here (Imhof: shade form on snow at full strength)''')
s=s.replace('''xl = np.clip((ndl - P["lit_min"]) / P["lit_span"], 0, 1)''','''xl = np.clip((ndl - P["lit_lo"]) / P["lit_span"], 0, 1)''')
s=s.replace('''    ledge_min_len=6,''','''    ledge_min_len=12,''')
s=s.replace("lit_span=0.75, lit_gamma=1.0","lit_span=0.6, lit_gamma=1.0")
s=s.replace("tt = np.clip(elev / 25, 0, 1) ** 0.6","tt = np.clip(elev / L.get('sky_span', 14), 0, 1) ** 0.7")
s=s.replace("[0.29, 0.40, 0.53]","[0.26, 0.40, 0.56]")
open("paint.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 1900 _v10 2>&1 | tail -1

# output:
colours 30
Read: elements/snowpeak/stages/r_rainier_ne_july_summer_v10.png

elements/snowpeak/stages/r_rainier_ne_july_summer_v10.png 68. elements/snowpeak/stages/r_rainier_ne_july_summer_v10.png

Bash: Locate collapse code
cd ~/work/pixelart-studies/elements/snowpeak/src; grep -n "def collapse" -A8 paint.py; grep -n "collapse(fam" paint.py; grep -n "def colour" paint.py

# output:
225:def collapse(fam, step, band, mat):
226-    """aerial perspective removes steps, not only contrast: far bands keep 2 per family, the
227-    farthest 1 lit + 1 shade (aerial student's band collapse)"""
228-    s = step.copy()
229-    s = np.where(band >= 3, np.minimum(s, 1), s)
230-    s = np.where(band >= 4, np.where(fam == 1, np.maximum(s, 1), 0), s)
231-    return s
232-
233-
225:def collapse(fam, step, band, mat):
239:    step = collapse(fam, step, band, mat)
295:def colour(C, G, L, rock_albedo="rock", band_km=(0.7, 2.5, 6.5, 15, 35), subject_clear=0.0):
Bash: Fine haze bands with centres; re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
a=s.index("def bands(G, P, rng=None):"); b=s.index("def collapse(")
s=s[:a]+'''BAND_EDGES = (0.6, 1.2, 2.2, 3.6, 5.5, 8.0, 11.5, 16.5, 23.0, 32.0, 45.0)   # haze-path km


def band_centres(edges=BAND_EDGES):
    e = (0.0,) + tuple(edges) + (edges[-1] * 1.5,)
    return tuple((e[i] + e[i + 1]) / 2 for i in range(len(e) - 1))


def bands(G, P, rng=None):
    """haze bands: the sea-level-equivalent haze path quantized finely (each band a small value
    step, so a band seam is a gentle contour, like the photo's gradient, not a hard shape), then a
    3x3 majority so seams are clean runs.  Coarse bands with big jumps made blobs (v9)."""
    d = np.where(G["sky"], 1e12, G["deff"]) / 1000
    raw = np.digitize(d, P["bands_km"]).astype(np.int16)
    terr = ~G["sky"]
    out = mode_filter(np.where(terr, raw, 0), 3, 2, mask=terr)
    return np.where(terr, out, raw)


'''+s[b:]
s=s.replace('''def collapse(fam, step, band, mat):
    """aerial perspective removes steps, not only contrast: far bands keep 2 per family, the
    farthest 1 lit + 1 shade (aerial student's band collapse)"""
    s = step.copy()
    s = np.where(band >= 3, np.minimum(s, 1), s)
    s = np.where(band >= 4, np.where(fam == 1, np.maximum(s, 1), 0), s)
    return s''','''def collapse(fam, step, band, mat, P):
    """aerial perspective removes steps, not only contrast: beyond collapse_km[0] of haze path each
    family keeps 2 steps, beyond collapse_km[1] one lit + one shade (aerial student's band collapse)"""
    km = np.array(band_centres(P["bands_km"]))[band]
    s = step.copy()
    s = np.where(km >= P["collapse_km"][0], np.minimum(s, 1), s)
    s = np.where(km >= P["collapse_km"][1], np.where(fam == 1, np.maximum(s, 1), 0), s)
    return s''')
s=s.replace("step = collapse(fam, step, band, mat)","step = collapse(fam, step, band, mat, P)")
s=s.replace("    bands_km=(1.5, 4, 10, 22),   # band edges in sea-level-equivalent haze km (see eff_dist)","    bands_km=BAND_EDGES,   # band edges in sea-level-equivalent haze km (see eff_dist)\n    collapse_km=(14, 35),")
s=s.replace('def colour(C, G, L, rock_albedo="rock", band_km=(0.7, 2.5, 6.5, 15, 35), subject_clear=0.0):','def colour(C, G, L, rock_albedo="rock", band_km=None, subject_clear=0.0):')
s=s.replace('''    H, W = C["mat"].shape
    out = np.zeros((H, W, 3))''','''    band_km = band_km or band_centres()
    H, W = C["mat"].shape
    out = np.zeros((H, W, 3))''')
# forest: more steps and stronger shading
s=s.replace('"forest": (2, 2)','"forest": (2, 3)')
open("paint.py","w").write(s)
EOF
grep -n "BAND_EDGES\|bands_km" paint.py | head; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 1900 _v11 2>&1 | tail -1

# output:
35:    bands_km=BAND_EDGES,   # band edges in sea-level-equivalent haze km (see eff_dist)
197:BAND_EDGES = (0.6, 1.2, 2.2, 3.6, 5.5, 8.0, 11.5, 16.5, 23.0, 32.0, 45.0)   # haze-path km
200:def band_centres(edges=BAND_EDGES):
210:    raw = np.digitize(d, P["bands_km"]).astype(np.int16)
219:    km = np.array(band_centres(P["bands_km"]))[band]
NameError: name 'BAND_EDGES' is not defined
Bash: Fix constant order and re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
line="BAND_EDGES = (0.6, 1.2, 2.2, 3.6, 5.5, 8.0, 11.5, 16.5, 23.0, 32.0, 45.0)   # haze-path km\n"
s=s.replace(line,"")
s=s.replace("DEFAULT = dict(",line+"\nDEFAULT = dict(",1)
open("paint.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 1900 _v11 2>&1 | tail -1

# output:
colours 51
Read: elements/snowpeak/stages/r_rainier_ne_july_summer_v11.png

elements/snowpeak/stages/r_rainier_ne_july_summer_v11.png 69. elements/snowpeak/stages/r_rainier_ne_july_summer_v11.png

Bash: Inspect pixels at a snow dash
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > dbg_px.py <<'EOF'
import numpy as np, sys, render, paint
S,G,C,rgb,pal=render.run("rainier_ne_july","summer",320,season_over=dict(z_sl=1900))
y0,y1,x0,x1=[int(v) for v in sys.argv[1:5]]
print(C["mat"][y0:y1,x0:x1]); print(C["fam"][y0:y1,x0:x1]); print(C["step"][y0:y1,x0:x1]); print(rgb[y0:y1,x0:x1,0])
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 dbg_px.py 134 142 140 160 2>&1 | grep -v -i "warn\|nanmean"

# output:
[[6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6]
 [6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6]
 [6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6]
 [6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6]
 [6 6 6 6 6 6 6 6 6 6 1 1 1 1 1 1 1 1 1 1]
 [6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 1 1]
 [6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 3 3 6 6]
 [6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6]]
[[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 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]
 [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 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]
 [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 1 1 1 1 1 1 1 1 1 1]]
[[2 2 2 2 2 2 0 1 1 2 2 2 2 2 1 2 1 2 2 2]
 [2 2 2 2 2 2 1 2 2 2 2 2 2 2 2 2 1 2 1 1]
 [2 2 2 2 2 1 1 1 1 1 2 2 2 2 1 0 1 1 1 1]
 [1 2 2 2 2 2 2 1 2 2 2 2 2 2 2 1 0 1 2 1]
 [1 2 2 2 2 2 2 2 1 2 2 2 2 2 2 2 2 2 2 2]
 [1 1 2 2 2 2 2 1 1 2 2 2 2 2 2 1 2 2 2 2]
 [2 1 2 2 2 2 2 2 1 2 2 2 2 2 2 2 2 2 2 2]
 [1 1 2 2 2 1 1 1 1 1 2 2 2 2 1 2 2 2 2 2]]
[[ 48  48  48  48  48  48  45  47  47  48  48  48  48  48  47  48  47  48
   48  48]
 [ 48  48  48  48  48  48  47  48  48  48  48  48  48  48  48  48  47  48
   47  47]
 [ 48  48  48  48  48  47  47  47  47  47  48  48  48  48  47  45  47  47
   47  47]
 [ 47  48  48  48  48  48  48  47  48  48  48  48  48  48  48  47  45  47
   48  47]
 [ 47  48  48  48  48  48  48  48  47  48 224 224 224 224 224 224 224 224
  224 224]
 [ 47  47  48  48  48  48  48  47  47  48  48  48  48  48  48  47  48  48
  224 224]
 [ 48  47  48  48  48  48  48  48  47  48  48  48  48  48  48  48 104 104
   48  48]
 [ 47  47  48  48  48  47  47  47  47  47  48  48  48  48  47  48  48  48
   48  48]]
Bash: Thickness-based sliver rule, bluer forest
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace('''        rows = np.array([s_[0].stop - s_[0].start for s_ in sl])
        sliver = (rows < 2) & (area < P["ledge_min_len"])''','''        cols = np.array([s_[1].stop - s_[1].start for s_ in sl])
        thick = area / np.maximum(cols, 1)                      # mean rows per column
        sliver = (thick < 1.5) & (area < P["ledge_min_len"] * 1.5)''')
s=s.replace('"forest": np.array([0.0055, 0.0085, 0.0085])','"forest": np.array([0.0045, 0.0075, 0.0105])')
open("paint.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 1900 _v12 2>&1 | tail -1

# output:
colours 51
Read: elements/snowpeak/stages/r_rainier_ne_july_summer_v12_crop.png

elements/snowpeak/stages/r_rainier_ne_july_summer_v12_crop.png 70. elements/snowpeak/stages/r_rainier_ne_july_summer_v12_crop.png

Bash: Fall-line smoothing of snow cover; re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("render.py").read()
s=s.replace('''    H = S.H // k
    G = native_from_super(Gs, k, W, H, S.C.hfov)''','''    H = S.H // k
    add_fall_line(Gs, S.C)
    G = native_from_super(Gs, k, W, H, S.C.hfov)''')
s=s.replace('''def add_haze_path(''','''def add_fall_line(Gs, C, step_m=None):
    """screen-space direction of steepest descent at each pixel (unit vector, pixels)"""
    n = Gs["normal"]
    d = np.stack([n[..., 2] * n[..., 0], n[..., 2] * n[..., 1], n[..., 2] ** 2 - 1], -1)
    d /= np.linalg.norm(d, axis=-1, keepdims=True) + 1e-9
    dist = np.where(Gs["sky"], 1.0, Gs["dist"])
    s = np.maximum(dist * np.radians(C.hfov) / C.W * 2, 5.0)          # ~2 px of ground
    x0, y0 = C.project(Gs["e"], Gs["n"], Gs["z"])
    x1, y1 = C.project(Gs["e"] + d[..., 0] * s, Gs["n"] + d[..., 1] * s, Gs["z"] + d[..., 2] * s)
    v = np.stack([x1 - x0, y1 - y0], -1)
    v /= np.linalg.norm(v, axis=-1, keepdims=True) + 1e-9
    Gs["fall"] = np.where(Gs["sky"][..., None], 0.0, v)
    return Gs


def add_haze_path(''')
open("render.py","w").write(s)

s=open("paint.py").read()
s=s.replace('''    snow_s = ((Gs["cover"] > 0.5) | (Gs["glacier"] > 0)) & ~sky_s''','''    cov_s = np.where(sky_s, 0.0, np.maximum(Gs["cover"], (Gs["glacier"] > 0).astype(float)))
    if P["fall_len"] > 0 and "fall" in Gs:
        cov_s = fall_smooth(cov_s, Gs["fall"], P["fall_len"] * k, ~sky_s)
    snow_s = (cov_s > 0.5) & ~sky_s''')
s=s.replace('''# ------------------------------------------------------------------ 1-2: coverage and material''','''# ------------------------------------------------------------------ 1-2: coverage and material
def fall_smooth(a, fall, L, valid, n=None):
    """average a along the screen-space fall line over +-L px (line integral convolution): snow
    tongues and rock ribs stretch down the slope, as gullies and ribs do (Imhof fig. 170)"""
    H, W = a.shape
    n = n or max(2, int(L))
    yy, xx = np.mgrid[0:H, 0:W].astype(float)
    acc = a.copy(); wsum = np.ones_like(a)
    for sgn in (1, -1):
        x = xx.copy(); y = yy.copy()
        for i in range(n):
            ix = np.clip(np.round(x).astype(int), 0, W - 1); iy = np.clip(np.round(y).astype(int), 0, H - 1)
            fx = fall[iy, ix, 0]; fy = fall[iy, ix, 1]
            x = x + sgn * fx * L / n; y = y + sgn * fy * L / n
            ix = np.clip(np.round(x).astype(int), 0, W - 1); iy = np.clip(np.round(y).astype(int), 0, H - 1)
            ok = valid[iy, ix]
            w = ok * (1 - i / n)
            acc += a[iy, ix] * w; wsum += w
    return acc / wsum

''')
s=s.replace('''    ledge_min_len=12,''','''    ledge_min_len=12,
    fall_len=1.5,          # native px of fall-line smoothing of snow cover before the threshold''')
s=s.replace("lit_lo=0.35,","lit_lo=0.2,").replace("lit_span=0.6, lit_gamma=1.0","lit_span=0.7, lit_gamma=1.0")
open("paint.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 1900 _v13 2>&1 | tail -1

# output:
colours 50
Read: elements/snowpeak/stages/r_rainier_ne_july_summer_v13_crop.png

elements/snowpeak/stages/r_rainier_ne_july_summer_v13_crop.png 71. elements/snowpeak/stages/r_rainier_ne_july_summer_v13_crop.png

Bash: Upscale photo native crop for close reading
cd ~/work/pixelart-studies/elements/snowpeak; python3 -c "
from PIL import Image
im=Image.open('refs/Mount_Rainier_September/commons_Mount_Rainier_from_the_Silver_Queen_Peak_jpg_926c3740.jpg').convert('RGB')
n=im.resize((320,214),Image.BOX).crop((40,60,280,130)); n.resize((240*6,70*6),Image.NEAREST).save('img/sq_native_crop.png')"

# output:
(Bash completed with no output)
Read: elements/snowpeak/img/sq_native_crop.png

elements/snowpeak/img/sq_native_crop.png 72. elements/snowpeak/img/sq_native_crop.png

Bash: Visualize model cover vs photo at high res
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > dbg_cover.py <<'EOF'
import numpy as np, sys, scene
from PIL import Image
S=scene.Scene("rainier_ne_july",W=960); S.snow("summer",season_over=dict(z_sl=float(sys.argv[1]))); G=S.gbuffer()
c=np.where(G["sky"],0.3,np.clip(G["cover"],0,1)); c=np.where(G["glacier"]>0,1.0,c)
hs=np.where(G["sky"],0,np.clip(G["hs"]/3,0,1))
ph=np.asarray(Image.open(S.s["photo"]).convert("L").resize((960,S.H),Image.LANCZOS))/255.
out=np.concatenate([ph,c,hs],0)[:, :]
Image.fromarray((out*255).astype(np.uint8)).crop((0,0,960,out.shape[0])).save("../stages/dbg_cover.png")
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 dbg_cover.py 1900; python3 -c "
from PIL import Image; im=Image.open('~/work/pixelart-studies/elements/snowpeak/stages/dbg_cover.png'); H=im.size[1]//3
Image.fromarray(__import__('numpy').concatenate([__import__('numpy').asarray(im.crop((0,i*H+150,960,i*H+400))) for i in range(3)],0)).save('~/work/pixelart-studies/elements/snowpeak/stages/dbg_cover_crop.png')"

# output:
(Bash completed with no output)
Read: elements/snowpeak/stages/dbg_cover_crop.png

elements/snowpeak/stages/dbg_cover_crop.png 73. elements/snowpeak/stages/dbg_cover_crop.png

Bash: Switch to OGGM holding depth, recompute cover
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("snowmodel.py").read()
s=s.replace('''               shd_c=23.0, shd_a=0.067, shd_min=0.03,              # fit to Bernhardt & Schulz defaults''','''               # holding depth: Bernhardt & Schulz defaults (3.08/1.11/0.45 m at 30/45/60 deg) fit
               # c=23, a=0.067; OGGM Snowslide's defaults are c=145, a=0.14 (2.2/0.27/0.03 m).
               # Summer photos of Rainier's steep walls (bare above ~45 deg) match the OGGM set.
               shd_c=145.0, shd_a=0.14, shd_min=0.05,''')
s=s.replace('Pd.get("ice_max_slope", 50)','Pd.get("ice_max_slope", 42)')
open("snowmodel.py","w").write(s)
EOF
rm ../cache/snowprep_*; NUMBA_NUM_THREADS=4 nice -n 10 python3 dbg_cover.py 1900; python3 -c "
from PIL import Image; import numpy as np; im=Image.open('~/work/pixelart-studies/elements/snowpeak/stages/dbg_cover.png'); H=im.size[1]//3
Image.fromarray(np.concatenate([np.asarray(im.crop((0,i*H+150,960,i*H+400))) for i in range(2)],0)).save('~/work/pixelart-studies/elements/snowpeak/stages/dbg_cover_crop.png')"

# output:
(Bash completed with no output)
Read: elements/snowpeak/stages/dbg_cover_crop.png

elements/snowpeak/stages/dbg_cover_crop.png 74. elements/snowpeak/stages/dbg_cover_crop.png

Bash: Re-render with new snow physics
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace('"rock": np.array([0.13, 0.115, 0.11])','"rock": np.array([0.125, 0.12, 0.125])')
open("paint.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 1900 _v14 2>&1 | tail -1

# output:
colours 51
Read: elements/snowpeak/stages/r_rainier_ne_july_summer_v14_crop.png

elements/snowpeak/stages/r_rainier_ne_july_summer_v14_crop.png 75. elements/snowpeak/stages/r_rainier_ne_july_summer_v14_crop.png

Bash: Compress lit ramp, higher snowline
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace('''        ndl = 0.18 + 0.72 * (s + 0.5) / nl''','''        ndl = 0.40 + 0.55 * (s + 0.5) / nl''')
open("paint.py","w").write(s)
EOF
python3 palcheck.py | head -8; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 2100 _v15 2>&1 | tail -1

# output:
snow    shade 61.6/-6.8 66.1/-9.0 70.1/-10.8
snow    lit  79.5/-3.9 88.0/-3.0 95.3/-2.3
ice     shade 48.4/-10.9 53.6/-13.7
ice     lit  63.8/-8.8 73.4/-8.5
rock    shade 15.6/-7.8 21.0/-9.2
rock    lit  34.6/-3.3 39.1/-2.9 43.0/-2.6
scree   shade 24.0/-8.7 30.8/-10.2
scree   lit  49.5/-2.3 57.5/-1.4
colours 51
Read: elements/snowpeak/stages/r_rainier_ne_july_summer_v15.png

elements/snowpeak/stages/r_rainier_ne_july_summer_v15.png 76. elements/snowpeak/stages/r_rainier_ne_july_summer_v15.png

Bash: Find candidate photo files
cd ~/work/pixelart-studies/elements/snowpeak/refs; ls */ | grep -i "7814p\|7118\|7137\|Colchuck_Glacier\|Longs_Pass_21\|JP2\|Mt_Olympus_jpg\|Fremont\|14th\|hurricane_ridge_c_bubar"; python3 -c "
from PIL import Image; import glob
for f in glob.glob('*/*7814p*')+glob.glob('*/*7118*')+glob.glob('*/*Colchuck_Glacier*'): print(f, Image.open(f).size)"

# output:
commons_Dragontail_Peak_Colchuck_Glacier_and_Colchuck_Pe_fb51b281.jpg
commons_Glacier_Peak_7118_JPG_3c1ed509.jpg
commons_Glacier_Peak_7137b_jpg_708f4f92.jpg
commons_Mount_Olympus_JP2_jpg_bc56d633.jpg
commons_Mt_Olympus_jpg_c6a5d0b6.jpg
commons_Mount_Stuart_7814p_JPG_5b777f82.jpg
commons_Mount_Stuart_sun_fog_from_near_Longs_Pass_21_Oct_353c40ec.jpg
commons_Mount_Stuart_sun_fog_from_near_Longs_Pass_21_Oct_b228bbd5.jpg
commons_Snow_capped_mountains_hurricane_ridge_c_bubar_ju_b0ae24a4.jpg
commons_Olympic_Mountains_from_Fremont_Seattle_JPG_764536e9.jpg
commons_Olympic_Mountains_from_Fremont_Seattle_in_reside_19d03f47.jpg
commons_View_of_Olympic_Mountains_from_14th_Ave_E_and_Re_131cc23c.jpg
Mount_Stuart_Washington/commons_Mount_Stuart_7814p_JPG_5b777f82.jpg (1280, 528)
Glacier_Peak_Washington/commons_Glacier_Peak_7118_JPG_3c1ed509.jpg (1280, 960)
Dragontail_Peak_Enchantments/commons_Dragontail_Peak_Colchuck_Glacier_and_Colchuck_Pe_fb51b281.jpg (1280, 960)
Read: elements/snowpeak/refs/Mount_Stuart_Washington/commons_Mount_Stuart_7814p_JPG_5b777f82.jpg
Read: elements/snowpeak/refs/Glacier_Peak_Washington/commons_Glacier_Peak_7118_JPG_3c1ed509.jpg
Read: elements/snowpeak/refs/Dragontail_Peak_Enchantments/commons_Dragontail_Peak_Colchuck_Glacier_and_Colchuck_Pe_fb51b281.jpg

elements/snowpeak/refs/Glacier_Peak_Washington/commons_Glacier_Peak_7118_JPG_3c1ed509.jpg 77. elements/snowpeak/refs/Glacier_Peak_Washington/commons_Glacier_Peak_7118_JPG_3c1ed509.jpg

elements/snowpeak/refs/Mount_Stuart_Washington/commons_Mount_Stuart_7814p_JPG_5b777f82.jpg 78. elements/snowpeak/refs/Mount_Stuart_Washington/commons_Mount_Stuart_7814p_JPG_5b777f82.jpg

elements/snowpeak/refs/Dragontail_Peak_Enchantments/commons_Dragontail_Peak_Colchuck_Glacier_and_Colchuck_Pe_fb51b281.jpg 79. elements/snowpeak/refs/Dragontail_Peak_Enchantments/commons_Dragontail_Peak_Colchuck_Glacier_and_Colchuck_Pe_fb51b281.jpg

Bash: Fit Stuart and Glacier Peak cameras
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("fitcam.py").read()
s=s.replace(''' "rainier_w_sept":''',''' "stuart_s_july": dict(photo=REF+"Mount_Stuart_Washington/commons_Mount_Stuart_7814p_JPG_5b777f82.jpg",
     cam=(47.4252,-120.907), agl=2, snap=400, peak="stuart", res=10, pad=(15000,15000,12000,20000),
     fov=40, span=100, el=(-20,25), time=(2008,7,14,15,15), maskfrac=0.75, summit=(525/1280,40/528), fovs=(25,75),
     crop=(40,0,280,90)),
 "gp_e_aug": dict(photo=REF+"Glacier_Peak_Washington/commons_Glacier_Peak_7118_JPG_3c1ed509.jpg",
     cam=(48.1439,-120.9588), agl=2, snap=400, peak="glacierpeak", res=15, pad=(25000,15000,20000,20000),
     fov=30, span=90, el=(-15,20), time=(2003,8,30,9,20), maskfrac=0.55, summit=(668/1280,158/960), fovs=(15,50),
     crop=(0,20,320,150)),
 "rainier_w_sept":''')
open("fitcam.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 fitcam.py stuart_s_july 2>&1 | tail -2; NUMBA_NUM_THREADS=4 nice -n 10 python3 fitcam.py gp_e_aug 2>&1 | tail -2

# output:
blur 0.3: [ 6.39   0.459 42.823  3.361] score -0.9007
stuart_s_july dist km 5.6 z 2063 best [ 6.38950378  0.4591062  42.82255939  3.36089211]
  blur 0.3: [-110.393   -1.836   27.093   -0.121] score -0.8105
gp_e_aug dist km 12.4 z 2085 best [-110.393431     -1.83614566   27.09287932   -0.1213753 ]
Bash: Stack two fit overlays
cd ~/work/pixelart-studies/elements/snowpeak/stages; python3 -c "
from PIL import Image
a=Image.open('fit_stuart_s_july.png'); b=Image.open('fit_gp_e_aug.png')
w=960; o=Image.new('RGB',(w,a.size[1]+b.size[1])); o.paste(a,(0,0)); o.paste(b,(0,a.size[1])); o.save('fit_pair_sg.png')"

# output:
(Bash completed with no output)
Read: elements/snowpeak/stages/fit_pair_sg.png

elements/snowpeak/stages/fit_pair_sg.png 80. elements/snowpeak/stages/fit_pair_sg.png

Bash: Render Stuart at two snowlines
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("render.py").read()
s=s.replace('''    rgb, pal = paint.colour(C, G, L, **(colour_kw or {}))''','''    ck = dict(rock_albedo=S.s.get("rock", "rock")); ck.update(colour_kw or {})
    rgb, pal = paint.colour(C, G, L, **ck)''')
open("render.py","w").write(s)
s=open("fitcam.py").read()
s=s.replace('''     crop=(40,0,280,90)),''','''     crop=(40,0,280,90), rock="granite"),''')
open("fitcam.py","w").write(s)
EOF
for z in 1900 2200; do NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py stuart_s_july summer 320 $z _z$z 2>&1 | tail -1; done

# output:
colours 33
colours 32
Bash: Stack Stuart snowline renders
cd ~/work/pixelart-studies/elements/snowpeak/stages; python3 -c "
from PIL import Image
a=Image.open('r_stuart_s_july_summer_z1900.png'); b=Image.open('r_stuart_s_july_summer_z2200.png')
o=Image.new('RGB',(a.size[0],a.size[1]*2)); o.paste(a,(0,0)); o.paste(b,(0,a.size[1])); o.save('cmp_stuart_z.png')"

# output:
(Bash completed with no output)
Read: elements/snowpeak/stages/cmp_stuart_z.png

elements/snowpeak/stages/cmp_stuart_z.png 81. elements/snowpeak/stages/cmp_stuart_z.png

Bash: Peak UTM coords and available lidar
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 -c "
import dem
for p in dem.PEAKS: print(p, [round(v) for v in dem.ll2utm(*dem.PEAKS[p][:2])])
"; ls -la ~/work/pixelart-studies/elements/rock/cache/dem1/; ls ~/work/voxsim-data/

# output:
rainier [594499, 5189564]
stuart [658049, 5260095]
dragontail [663327, 5260640]
olympus [446774, 5294461]
glacierpeak [640386, 5330525]
baker [587104, 5403322]
total 0
drwxr-xr-x   2 user  staff   64 Sep 26 14:18 .
drwxr-xr-x  15 user  staff  480 Sep 26 14:20 ..
dem1
dem10
dem1b
fetch.log
fetch.sh
fetch2.log
fetch2.sh
fetch3.log
fetch3.sh
fetch4.log
fetch4.sh
fetch5.log
fetch5.sh
fetch6.log
fetch6.sh
geology
hydro
landcover
landfire
look
places
soils
tsheets
urls.txt
urls2.txt
urls3.txt
urls4.txt
urls5.txt
urls6.txt
Bash: Look for 1m lidar URLs
cd ~/work/voxsim-data; grep -h "EasternCascades\|Rainier\|Olympic" urls*.txt | head -5; grep -h "x65y52\|x64y52\|x65y53" urls*.txt | head

# output:
dem1 https://prd-tnm.s3.amazonaws.com/StagedProducts/Elevation/1m/Projects/WA_Olympic_Peninsula_C1_2017/TIFF/USGS_1m_x47y525_WA_Olympic_Peninsula_C1_2017.tif
dem1 https://prd-tnm.s3.amazonaws.com/StagedProducts/Elevation/1m/Projects/WA_Olympic_Peninsula_C1_2017/TIFF/USGS_1m_x47y526_WA_Olympic_Peninsula_C1_2017.tif
dem1 https://prd-tnm.s3.amazonaws.com/StagedProducts/Elevation/1m/Projects/WA_Olympic_Peninsula_C1_2017/TIFF/USGS_1m_x48y525_WA_Olympic_Peninsula_C1_2017.tif
dem1 https://prd-tnm.s3.amazonaws.com/StagedProducts/Elevation/1m/Projects/WA_Olympic_Peninsula_C1_2017/TIFF/USGS_1m_x48y526_WA_Olympic_Peninsula_C1_2017.tif
dem1 https://prd-tnm.s3.amazonaws.com/StagedProducts/Elevation/1m/Projects/WA_Olympic_Peninsula_C1_2017/TIFF/USGS_1m_x49y525_WA_Olympic_Peninsula_C1_2017.tif
Bash: Check 1 m lidar tiles covering Stuart
cd /tmp; for t in x65y527 x65y526 x66y527; do u="https://prd-tnm.s3.amazonaws.com/StagedProducts/Elevation/1m/Projects/WA_EasternCascades_2019_B19/TIFF/USGS_1M_10_${t}_WA_EasternCascades_2019_B19.tif"; echo $t $(curl -sI "$u" | egrep -i "HTTP/|content-length" | tr '\n' ' '); done

# output:
x65y527 HTTP/1.1 200 OK
 Content-Length: 318873826
x65y526 HTTP/1.1 200 OK
 Content-Length: 150065937
x66y527 HTTP/1.1 200 OK
 Content-Length: 250636147
Bash: Probe remote COG structure
cd /tmp; python3 -c "
import rasterio, time
u='/vsicurl/https://prd-tnm.s3.amazonaws.com/StagedProducts/Elevation/1m/Projects/WA_EasternCascades_2019_B19/TIFF/USGS_1M_10_x65y527_WA_EasternCascades_2019_B19.tif'
t=time.time()
with rasterio.open(u) as d:
    print(d.bounds, d.res, d.crs, d.overviews(1), d.profile.get('tiled'), d.profile.get('blockxsize'))
print(time.time()-t)
"

# output:
BoundingBox(left=649993.999998625, bottom=5259994.000026215, right=660005.999998625, top=5270006.000026215) (1.0, 1.0) EPSG:26910 [2, 4, 8, 16, 32] True 512
1.06549072265625
Bash: Query TNM for 1 m DEM coverage of each peak
cd /tmp; for p in "rainier -121.80,46.82,-121.72,46.88" "olympus -123.75,47.78,-123.67,47.82" "glacierpeak -121.15,48.09,-121.08,48.13" "stuart -120.93,47.46,-120.87,47.49"; do set -- $p; echo "== $1"; curl -s "https://tnmaccess.nationalmap.gov/api/v1/products?datasets=Digital%20Elevation%20Model%20(DEM)%201%20meter&bbox=$2&max=20" | python3 -c "import json,sys; d=json.load(sys.stdin); [print(i['title'][:90], i['downloadURL'][-70:]) for i in d.get('items',[])]"; done

# output:
== rainier -121.80,46.82,-121.72,46.88
USGS 1 Meter 10 x37y473 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x37y473_OR_SouthCoast_2019_A19.tif
USGS 1 Meter 10 x37y474 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x37y474_OR_SouthCoast_2019_A19.tif
USGS 1 Meter 10 x37y475 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x37y475_OR_SouthCoast_2019_A19.tif
USGS 1 Meter 10 x37y476 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x37y476_OR_SouthCoast_2019_A19.tif
USGS 1 Meter 10 x37y531 WA_Olympic_Peninsula_Lidar_2017_B17 17_B17/TIFF/USGS_1M_10_x37y531_WA_Olympic_Peninsula_Lidar_2017_B17.tif
USGS 1 Meter 10 x37y532 WA_Olympic_Peninsula_Lidar_2017_B17 17_B17/TIFF/USGS_1M_10_x37y532_WA_Olympic_Peninsula_Lidar_2017_B17.tif
USGS 1 Meter 10 x38y466 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x38y466_OR_SouthCoast_2019_A19.tif
USGS 1 Meter 10 x38y467 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x38y467_OR_SouthCoast_2019_A19.tif
USGS 1 Meter 10 x38y468 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x38y468_OR_SouthCoast_2019_A19.tif
== olympus -123.75,47.78,-123.67,47.82
USGS 1 Meter 10 x37y473 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x37y473_OR_SouthCoast_2019_A19.tif
USGS 1 Meter 10 x37y474 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x37y474_OR_SouthCoast_2019_A19.tif
USGS 1 Meter 10 x37y475 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x37y475_OR_SouthCoast_2019_A19.tif
USGS 1 Meter 10 x37y476 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x37y476_OR_SouthCoast_2019_A19.tif
USGS 1 Meter 10 x37y531 WA_Olympic_Peninsula_Lidar_2017_B17 17_B17/TIFF/USGS_1M_10_x37y531_WA_Olympic_Peninsula_Lidar_2017_B17.tif
USGS 1 Meter 10 x37y532 WA_Olympic_Peninsula_Lidar_2017_B17 17_B17/TIFF/USGS_1M_10_x37y532_WA_Olympic_Peninsula_Lidar_2017_B17.tif
USGS 1 Meter 10 x38y466 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x38y466_OR_SouthCoast_2019_A19.tif
USGS 1 Meter 10 x38y467 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x38y467_OR_SouthCoast_2019_A19.tif
USGS 1 Meter 10 x38y468 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x38y468_OR_SouthCoast_2019_A19.tif
== glacierpeak -121.15,48.09,-121.08,48.13
USGS 1 Meter 10 x37y473 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x37y473_OR_SouthCoast_2019_A19.tif
USGS 1 Meter 10 x37y474 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x37y474_OR_SouthCoast_2019_A19.tif
USGS 1 Meter 10 x37y475 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x37y475_OR_SouthCoast_2019_A19.tif
USGS 1 Meter 10 x37y476 OR_SouthCoast_2019_A19 SouthCoast_2019_A19/TIFF/USGS_1M_10_x37y476_OR_SouthCoast_2019_A19.tif
USGS 1 Meter 10 x37y531 WA_Olympic_Peninsula_Lidar_2017_B17 17_B17/TIFF/USGS_1M_10_x37y531_WA_Olympic_Peninsula_Lidar_2017_B17.tif
USGS 1 Meter 10 x37y532 WA_Olympic_Peninsula_Lidar_2017_B17 17_B17/TIFF/USGS_1M_10_x37y532_WA_Olympi
Bash: List WA 1 m lidar projects
curl -s "https://prd-tnm.s3.amazonaws.com/?prefix=StagedProducts/Elevation/1m/Projects/WA_&delimiter=/" | python3 -c "import sys,re; print('\n'.join(re.findall(r'<Prefix>([^<]+)</Prefix>',sys.stdin.read())))"

# output:
StagedProducts/Elevation/1m/Projects/WA_
StagedProducts/Elevation/1m/Projects/WA_3_County_QL1_2017/
StagedProducts/Elevation/1m/Projects/WA_3_County_QL2_2017/
StagedProducts/Elevation/1m/Projects/WA_CentralWildfire_D22/
StagedProducts/Elevation/1m/Projects/WA_ColumbiaValley_2018_D19/
StagedProducts/Elevation/1m/Projects/WA_DNR_3DEP_Processing_2019_D20/
StagedProducts/Elevation/1m/Projects/WA_EasternCascades_2019_B19/
StagedProducts/Elevation/1m/Projects/WA_FEMAHQ_2018_D18/
StagedProducts/Elevation/1m/Projects/WA_GlacierPeak_2014/
StagedProducts/Elevation/1m/Projects/WA_KingCounty_2021_B21/
StagedProducts/Elevation/1m/Projects/WA_Mount_Adams_LiDAR_2016_D16/
StagedProducts/Elevation/1m/Projects/WA_MtBaker_2015/
StagedProducts/Elevation/1m/Projects/WA_Nisqually_TopoBathy_2020_D20/
StagedProducts/Elevation/1m/Projects/WA_NorthCentral_2021_B21/
StagedProducts/Elevation/1m/Projects/WA_NorthEast_B22/
StagedProducts/Elevation/1m/Projects/WA_Olympic_Peninsula_1A_2017/
StagedProducts/Elevation/1m/Projects/WA_Olympic_Peninsula_2013/
StagedProducts/Elevation/1m/Projects/WA_Olympic_Peninsula_C1_2017/
StagedProducts/Elevation/1m/Projects/WA_Olympic_Peninsula_C2_2017/
StagedProducts/Elevation/1m/Projects/WA_Olympic_Peninsula_C3_2017/
StagedProducts/Elevation/1m/Projects/WA_Olympic_Peninsula_Lidar_2017_B17/
StagedProducts/Elevation/1m/Projects/WA_Pasco_2020_D20/
StagedProducts/Elevation/1m/Projects/WA_PierceCounty_2020_A20/
StagedProducts/Elevation/1m/Projects/WA_ThurstonCounty_2021_B21/
StagedProducts/Elevation/1m/Projects/WA_Western_North_2016/
StagedProducts/Elevation/1m/Projects/WA_Western_South_2016/
Bash: Add remote lidar loader and test at Stuart
cd ~/work/pixelart-studies/elements/snowpeak/src; cat >> dem.py <<'EOF'


# ------------------------------------------------------------------ 1 m lidar, read remotely
LIDAR = {"stuart": ["WA_EasternCascades_2019_B19"], "dragontail": ["WA_EasternCascades_2019_B19"],
         "glacierpeak": ["WA_GlacierPeak_2014"], "rainier": ["WA_PierceCounty_2020_A20"],
         "olympus": ["WA_Olympic_Peninsula_2013", "WA_Olympic_Peninsula_C3_2017", "WA_Olympic_Peninsula_Lidar_2017_B17"],
         "baker": ["WA_MtBaker_2015"]}
S3 = "https://prd-tnm.s3.amazonaws.com/"


def _list_tiles(project):
    import urllib.request, re
    fn = os.path.join(CACHE, f"tiles_{project}.txt")
    if os.path.exists(fn):
        return open(fn).read().split()
    keys, token = [], None
    while True:
        u = S3 + f"?list-type=2&prefix=StagedProducts/Elevation/1m/Projects/{project}/TIFF/"
        if token:
            import urllib.parse
            u += "&continuation-token=" + urllib.parse.quote(token)
        x = urllib.request.urlopen(u).read().decode()
        keys += re.findall(r"<Key>([^<]+\.tif)</Key>", x)
        m = re.search(r"<NextContinuationToken>([^<]+)</NextContinuationToken>", x)
        if not m:
            break
        token = m.group(1)
    open(fn, "w").write("\n".join(keys))
    return keys


def load_lidar(e0, e1, n0, n1, res, projects, tag):
    """1 m USGS lidar DEM over a UTM box, read over HTTP from the COG overviews (no tile download),
    resampled to `res`.  Cells with no lidar are NaN (callers fill from the 10 m DEM)."""
    import re
    fn = os.path.join(CACHE, f"lidar_{tag}_{res:g}.npz")
    if os.path.exists(fn):
        d = np.load(fn)
        return dict(Z=d["Z"], x0=float(d["x0"]), y0=float(d["y0"]), res=float(d["res"]))
    W = int(round((e1 - e0) / res)); H = int(round((n1 - n0) / res))
    transform = from_origin(e0, n1, res, res)
    Z = np.full((H, W), np.nan, np.float32)
    with rasterio.Env(GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR", CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif"):
        for proj in projects:
            for key in _list_tiles(proj):
                m = re.search(r"x(\d+)y(\d+)", key)
                if not m:
                    continue
                tx, ty = int(m.group(1)) * 10000, int(m.group(2)) * 10000
                if tx > e1 or tx + 10000 < e0 or ty < n0 or ty - 10000 > n1:
                    continue
                with rasterio.open("/vsicurl/" + S3 + key, overview_level=None) as src:
                    tmp = np.full((H, W), np.nan, np.float32)
                    reproject(rasterio.band(src, 1), tmp, src_transform=src.transform, src_crs=src.crs,
                              dst_transform=transform, dst_crs=UTM, resampling=Resampling.average,
                              src_nodata=src.nodata, dst_nodata=np.nan)
                    ok = np.isfinite(tmp) & ~np.isfinite(Z)
                    Z[ok] = tmp[ok]
                print("  lidar tile", key.split("/")[-1], flush=True)
    np.savez_compressed(fn, Z=Z, x0=e0, y0=n1, res=res)
    return dict(Z=Z, x0=float(e0), y0=float(n1), res=float(res))
EOF
python3 -c "
import dem,time
e,n=dem.ll2utm(*dem.PEAKS['stuart'][:2]); t=time.time()
L=dem.load_lidar(round(e-2000),round(e+2000),round(n-2000),round(n+2000),4,dem.LIDAR['stuart'],'stuart_test')
import numpy as np; print(L['Z'].shape, np.isfinite(L['Z']).mean(), np.nanmax(L['Z']), time.time()-t)"

# output:
lidar tile USGS_1M_10_x65y526_WA_EasternCascades_2019_B19.tif
  lidar tile USGS_1M_10_x65y527_WA_EasternCascades_2019_B19.tif
  lidar tile USGS_1M_10_x66y526_WA_EasternCascades_2019_B19.tif
  lidar tile USGS_1M_10_x66y527_WA_EasternCascades_2019_B19.tif
(1000, 1000) 1.0 2868.582 23.502205848693848
Bash: Two-level DEM ray casting with lidar
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("cam.py").read()
s=s.replace('''@njit(parallel=True, cache=True, fastmath=True)
def _cast(Z, x0, y0, res, ce, cn, cz, D, far, reff, out_t):''','''@njit(cache=True, fastmath=True)
def _h2(Z, x0, y0, res, F, fx0, fy0, fres, px, py):
    g = _h(F, fx0, fy0, fres, px, py)
    if g > -1e8:
        return g
    return _h(Z, x0, y0, res, px, py)


@njit(parallel=True, cache=True, fastmath=True)
def _cast2(Z, x0, y0, res, F, fx0, fy0, fres, ce, cn, cz, D, far, reff, out_t):
    """as _cast, but a fine grid F (lidar) overrides Z where it exists"""
    H, W = D.shape[0], D.shape[1]
    for i in prange(H):
        for j in range(W):
            dx, dy, dz = D[i, j, 0], D[i, j, 1], D[i, j, 2]
            hor = np.sqrt(dx * dx + dy * dy) + 1e-9
            t = 2.0; tp = 0.0; hit = -1.0
            while t < far:
                px = ce + dx * t; py = cn + dy * t
                th = t * hor
                pz = cz + dz * t + th * th / (2 * reff)
                g = _h2(Z, x0, y0, res, F, fx0, fy0, fres, px, py)
                if g < -1e8:
                    break
                above = pz - g
                if above <= 0:
                    lo = tp; hi = t
                    for _ in range(20):
                        m = 0.5 * (lo + hi)
                        qx = ce + dx * m; qy = cn + dy * m; qh = m * hor
                        qz = cz + dz * m + qh * qh / (2 * reff)
                        if qz - _h2(Z, x0, y0, res, F, fx0, fy0, fres, qx, qy) <= 0:
                            hi = m
                        else:
                            lo = m
                    hit = hi
                    break
                tp = t
                step = above * 0.5
                r = res
                if _h(F, fx0, fy0, fres, px, py) > -1e8:
                    r = fres
                smin = 0.35 * r + t * 0.0002
                if step < smin:
                    step = smin
                if step > 0.02 * t + 4 * r:
                    step = 0.02 * t + 4 * r
                t += step
            out_t[i, j] = hit


@njit(parallel=True, cache=True, fastmath=True)
def _cast(Z, x0, y0, res, ce, cn, cz, D, far, reff, out_t):''')
s=s.replace('''    t = np.zeros((C.H, C.W))
    _cast(T["Z"].astype(np.float32), T["x0"], T["y0"], T["res"], C.e, C.n, C.z, D, far, R_EFF, t)''','''    t = np.zeros((C.H, C.W))
    F = T.get("fine")
    if F is not None:
        _cast2(T["Z"].astype(np.float32), T["x0"], T["y0"], T["res"], F["Z"].astype(np.float32),
               F["x0"], F["y0"], F["res"], C.e, C.n, C.z, D, far, R_EFF, t)
    else:
        _cast(T["Z"].astype(np.float32), T["x0"], T["y0"], T["res"], C.e, C.n, C.z, D, far, R_EFF, t)''')
s=s.replace('''    z = map_coordinates(T["Z"], [r.ravel(), c.ravel()], order=1, mode="nearest").reshape(r.shape)''','''    z = map_coordinates(T["Z"], [r.ravel(), c.ravel()], order=1, mode="nearest").reshape(r.shape)
    if F is not None:
        rf = (F["y0"] - n) / F["res"] - 0.5; cf = (e - F["x0"]) / F["res"] - 0.5
        zf = map_coordinates(F["Z"], [rf.ravel(), cf.ravel()], order=1, mode="constant", cval=np.nan).reshape(r.shape)
        z = np.where(np.isfinite(zf), zf, z)''')
open("cam.py","w").write(s)
EOF
grep -n "_h(F" cam.py | head -3

# output:
65:    g = _h(F, fx0, fy0, fres, px, py)
103:                if _h(F, fx0, fy0, fres, px, py) > -1e8:
Bash: Locate scene fields to patch
cd ~/work/pixelart-studies/elements/snowpeak/src; grep -n "self.Tp = \|sl, asp, n = snowmodel.fields\|G\[\"normal\"\] = \|G\[\"slope\"\] = \|cv = snowmodel.curvature\|G\[\"curv\"\]" scene.py

# output:
29:        self.Tp = dem.load(s["peak"], snow_half_km, snow_res)      # snow sub-box around the peak
50:        sl, asp, n = snowmodel.fields(T["Z"], T["res"])
55:        G["normal"] = np.stack([samp(n[..., k], r, c) for k in range(3)], -1)
57:        G["slope"] = samp(sl, r, c)
76:        cv = snowmodel.curvature(T["Z"], T["res"], 90)
77:        G["curv"] = samp(cv, r, c)
Bash: Read gbuffer code
cd ~/work/pixelart-studies/elements/snowpeak/src; sed -n 40,100p scene.py

# output:
np.savez_compressed(fn, **{k: np.asarray(v, np.float32) for k, v in prep.items() if k != "P"})
        self.prep = prep
        se = dict(snowmodel.SEASONS[season], **(season_over or {}))
        self.S = snowmodel.apply(prep, se, P)
        self.season = season
        return self.S

    def gbuffer(self, sun=None):
        G = cam.cast(self.T, self.C)
        T = self.T; Tp = self.Tp
        sl, asp, n = snowmodel.fields(T["Z"], T["res"])
        def samp(A, r, c, order=1):
            return map_coordinates(np.asarray(A, np.float32), [r.ravel(), c.ravel()], order=order,
                                   mode="nearest").reshape(r.shape)
        r, c = G["r"], G["c"]
        G["normal"] = np.stack([samp(n[..., k], r, c) for k in range(3)], -1)
        G["normal"] /= np.linalg.norm(G["normal"], axis=-1, keepdims=True) + 1e-9
        G["slope"] = samp(sl, r, c)
        for k in ("ice", "trees", "bare"):
            G[k] = samp(T[k], r, c)
        # snow fields from the peak sub-box (outside it: none, the sub-box is chosen to hold all snow)
        rp = (Tp["y0"] - G["n"]) / Tp["res"] - 0.5; cp = (G["e"] - Tp["x0"]) / Tp["res"] - 0.5
        inside = (rp > 0) & (rp < Tp["Z"].shape[0] - 1) & (cp > 0) & (cp < Tp["Z"].shape[1] - 1) & ~G["sky"]
        G["rp"], G["cp"], G["inside"] = rp, cp, inside
        self.G = G
        self.resample()
        # sun shadow at each hit point, by marching the peak box (fine) then the view box
        az, el = sun or self.sun
        self.sun_used = (az, el)
        shp = snowmodel.shadow(Tp["Z"], Tp["res"], az, el, maxd=20000.0) if el > -3 else np.zeros_like(Tp["Z"])
        shv = snowmodel.shadow(T["Z"][::2, ::2], T["res"] * 2, az, el, maxd=30000.0) if el > -3 else None
        sh_view = samp(shv, r / 2, c / 2) if shv is not None else np.zeros(r.shape)
        G["sunlit"] = np.where(inside, samp(shp, rp, cp), sh_view)
        G["sunvec"] = sunmod.vec(az, max(el, 0.5))
        G["ndl"] = G["normal"] @ G["sunvec"]
        # sub-pixel curvature for gully/rib reading
        cv = snowmodel.curvature(T["Z"], T["res"], 90)
        G["curv"] = samp(cv, r, c)
        self.G = G
        return G

    def resample(self):
        """(re)sample the current snow state into the G-buffer (cheap; call after snow())"""
        G = self.G; rp, cp, inside = G["rp"], G["cp"], G["inside"]
        def samp(A, order=1):
            return map_coordinates(np.asarray(A, np.float32), [rp.ravel(), cp.ravel()], order=order,
                                   mode="nearest").reshape(rp.shape)
        for k in ("hs", "cover", "cornice", "I", "sx"):
            G[k] = np.where(inside, samp(self.S[k]), 0.0)
        G["glacier"] = np.where(inside, samp(self.S["glacier"], 0), 0)
        return G
Bash: Render Stuart with 4 m lidar
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("scene.py").read()
s=s.replace('''        self.Tp = dem.load(s["peak"], snow_half_km, snow_res)      # snow sub-box around the peak''','''        if s.get("lidar"):
            # 1 m lidar resampled to lidar_res over the peak sub-box; gaps filled from the 10 m DEM
            half = s.get("lidar_half_km", 4) * 1000; lr = s["lidar"]
            e, n = dem.ll2utm(lat, lon)
            box = (round(e - half), round(e + half), round(n - half), round(n + half))
            L = dem.load_lidar(*box, lr, dem.LIDAR[s["peak"]], f"{s['peak']}_{int(half)}")
            C10 = dem.load_box(*box, lr, tag=f"{s['peak']}_lidarbase_{int(half)}")
            Z = np.where(np.isfinite(L["Z"]), L["Z"], C10["Z"]).astype(np.float32)
            self.Tp = dict(C10, Z=Z)
            self.T = dict(self.T, fine=self.Tp)
        else:
            self.Tp = dem.load(s["peak"], snow_half_km, snow_res)      # snow sub-box around the peak''')
s=s.replace('''        fn = os.path.join(HERE, f"../cache/snowprep_{self.s['peak']}_{int(wind_from)}.npz")''','''        fn = os.path.join(HERE, f"../cache/snowprep_{self.s['peak']}_{int(wind_from)}_{self.Tp['res']:g}.npz")''')
s=s.replace('''        r, c = G["r"], G["c"]
        G["normal"] = np.stack([samp(n[..., k], r, c) for k in range(3)], -1)
        G["normal"] /= np.linalg.norm(G["normal"], axis=-1, keepdims=True) + 1e-9
        G["slope"] = samp(sl, r, c)''','''        r, c = G["r"], G["c"]
        G["normal"] = np.stack([samp(n[..., k], r, c) for k in range(3)], -1)
        G["slope"] = samp(sl, r, c)
        rp0 = (Tp["y0"] - G["n"]) / Tp["res"] - 0.5; cp0 = (G["e"] - Tp["x0"]) / Tp["res"] - 0.5
        in0 = (rp0 > 0) & (rp0 < Tp["Z"].shape[0] - 1) & (cp0 > 0) & (cp0 < Tp["Z"].shape[1] - 1)
        if "fine" in T:   # fine normals where the lidar exists
            slf, _, nf = snowmodel.fields(Tp["Z"], Tp["res"])
            for k in range(3):
                G["normal"][..., k] = np.where(in0, samp(nf[..., k], rp0, cp0), G["normal"][..., k])
            G["slope"] = np.where(in0, samp(slf, rp0, cp0), G["slope"])
        G["normal"] /= np.linalg.norm(G["normal"], axis=-1, keepdims=True) + 1e-9''')
s=s.replace('''        cv = snowmodel.curvature(T["Z"], T["res"], 90)
        G["curv"] = samp(cv, r, c)''','''        cv = snowmodel.curvature(T["Z"], T["res"], 90)
        G["curv"] = samp(cv, r, c)
        if "fine" in T:
            cvf = snowmodel.curvature(Tp["Z"], Tp["res"], 40)
            G["curv"] = np.where(in0, samp(cvf, rp0, cp0), G["curv"])''')
open("scene.py","w").write(s)
s=open("fitcam.py").read()
s=s.replace('''     crop=(40,0,280,90), rock="granite"),''','''     crop=(40,0,280,90), rock="granite", lidar=4, lidar_half_km=5),''')
open("fitcam.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 /usr/bin/time -l python3 render.py stuart_s_july summer 320 2200 _lidar 2>&1 | egrep "colours|maximum resident|real|Error|error" 

# output:
Command did not complete within its 600s timeout and was moved to the background (ID: bt31yhzjg). Output is being written to: (tmp) You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains ~/work/pixelart-studies/manager/briefs; directory changes made by the backgrounded command do not apply to subsequent commands.
Bash: Read rock study masses-first section and painter API
sed -n 436,470p ~/work/pixelart-studies/elements/rock/LESSONS.md; grep -n "^def " ~/work/pixelart-studies/elements/rock/src/paint.py | head -40

# output:
---

## Round 2: designed light, and matching the photo itself

The coordinator's read (and David's): round 1 approximated the photos; the goal is to *match* them,
form first (Dragontail's real silhouette, where its flank turns dark, where the gullies run), then
detail. This round is about that, and about "masses first, then marks inside the mass".

### 2a. Masses first, then marks (`paint.shade_designed`, `paint.mass_marks`)

- **Mass vs mark.** An object is a *mass* if it covers > 3 % of the frame **and** is > 12 m in
  the world (walls, stacks, platforms); everything else (talus blocks, boulders) is a *mark* and
  keeps per-facet light. My first version also treated a near foreground block as a mass and it
  swallowed the talus.
- **Mass light from the landform, not the facets.** The mass's lit/shadow split comes from the
  landform-scale normal (the world position blurred over ~6 % of the frame, then its normal) and the
  landform-scale share of cast shadow, then `design_masses` (no island < 2.5 % of frame). Inside a
  lit mass every facet stays in the lit family; inside a shadow mass every facet stays in the shadow
  family, shaped by **directional skylight** (up tilted toward a bright-sky direction).
- **Marks inside the lit mass are tall runs, not specks.** A facet turned away from the sun becomes
  a shadow-family mark only if its connected run is ≥ 6 px tall and ≥ 2.2× taller than wide (the
  dark rib/gully strokes of a sunlit wall); the hackly entablature uses any shape. Small pockets
  (< 10 px) stay lit. Crack lines along joint steps are off by default (they drew closed loops
  round every cell — "crazy paving" — at 8×).
- **A director override** (`mass_design={"basalt": "shadow", "basalt_face": "lit"}`) for when the
  photo says which mass is dark.
- **Column bow and taper** (`column_surface(bow=, taper=)`): each column bends as one curve of its
  own sign and phase and narrows upward.

Stage pairs: `stages/iter/granite_wall_d1–d10.png`, `stages/iter/basalt_colonnade_d1–d5.png`.
The granite wall now has one lit face and one dark flank with rib strokes in the light
(`granite_wall_d10`), but it is still my invented mountain, not Dragontail. The basalt got bowed
columns and a dark overhanging entablature (`basalt_colonnade_d5`), but the columns still read as
clean pale planks, not the photo's rounded, streaked pillars — **not there**.

21:def hash01(*a):
31:def clump_noise(shape, sigma, seed):
37:def masked_blur(a, m, sigma):
43:def ambient_occlusion(g, radius_m=0.35, dirs=8):
75:def gaps(g, min_jump_m=0.12, max_jump_m=1.5):
88:def silhouette(g, sky_only=False):
104:def face_boundaries(g):
114:def shade(g, L, overcast=False, params=None, keymask=None):
157:def quantize(v, lit, seed=0, grain=0.2, clump=1.2, seam=0.18):
175:def key_family(v, m, lo, hi, q=(3, 97)):
188:def found_edges(g, step, lit, L, cam, v):
210:def paint_gray(g, L, overcast=False, seed=0, params=None, edges=True):
224:def gray_rgb(s, sky=(200, 205, 215)):
233:def massif
Bash: Wait and check Stuart lidar render
sleep 240; cat (tmp) | tail -5

# output:
<tool_use_error>Blocked: sleep 240 followed by: cat (tmp) tail -5. To wait for a condition, use Monitor with an until-loop (e.g. `until <check>; do sleep 2; done`). To wait for a command you started, use run_in_background: true. Do not chain shorter sleeps to work around this block.</tool_use_error>
WebSearch: Paradise Mount Rainier snow depth April average peak snowpack inches SNOTEL 1560 m
Paradise Mount Rainier snow depth April average peak snowpack inches SNOTEL 1560 m

# result:
Web search results for query: "Paradise Mount Rainier snow depth April average peak snowpack inches SNOTEL 1560 m"

Links: [{"title":"Paradise, Washington","url":"https://en.wikipedia.org/wiki/Paradise,_Washington"},{"title":"Paradise Snow Report, April 15, 2022 The snowpack at ...","url":"https://www.facebook.com/MountRainierNPS/posts/paradise-snow-report-april-15-2022the-snowpack-at-paradise-is-288-deeper-than-la/298769632406037/"},{"title":"Mt Rainier Snow Depth & Snowpack Report | Cascade Snow","url":"https://cascade-snow.com/rainier"},{"title":"Rainier Paradise Ranger Stn Snow Report | Washington Nohrsc Asfw1 | Snowpack Conditions","url":"https://snoflo.org/report/snow/washington/rainier-paradise-ranger-stn/"},{"title":"Mount Rainier · Cascade-Range Mountain Peak · Snowpack, Streamflow & Forecast &middot; Snoflo","url":"https://snoflo.org/mountains/cascade-range/mount-rainier"},{"title":"Paradise Snow Report · Washington Snowpack · Snoflo","url":"https://snoflo.org/report/snow/washington/paradise/"},{"title":"Snow Station Information - PARADISE SNOTEL","url":"https://www.nwrfc.noaa.gov/snow/snowplot.cgi?AFSW1="},{"title":"Mount Rainier Conditions Update","url":"https://www.nps.gov/mora/learn/news/conditions-march2015.htm"},{"title":"mount rainiers snowpack still growing","url":"https://patch.com/washington/seattle/mount-rainiers-snowpack-still-growing"},{"title":"a photo paradise inn","url":"https://www.usgs.gov/media/images/a-photo-paradise-inn"}]

I've completed the web search for your query about Paradise Mount Rainier snow depth, April averages, peak snowpack, and SNOTEL data at 1560 m elevation.

Here are the key findings:

**Paradise/Mount Rainier SNOTEL Data:**

- Snowpack depth, SWE, snowfall, and air temperature come from the NRCS SNOTEL station 679
- Mount Rainier is measured daily by SNOTEL station #679, located at Mt. Rainier, Washington at an elevation of 5,150 ft (approximately 1,570 m, close to your specified 1560 m)
- This automated sensor is operated by the Natural Resources Conservation Service (NRCS) and has been recording continuous snowpack data since 1979

**Historical Peak Snowpack:**

- The maximum snowpack was 367 inches (30.6 ft; 9.3 m) on March 9–10, 1956

**April Snow Conditions (Example):**

- The search results provide some April snowpack references but don't explicitly state a specific April average

**Notable Context:**

- The National Park Service says that "Paradise is one of the snowiest places on Earth where snowfall is measured regularly" with 1,122 inches (93.5 ft; 28.5 m) of snow during the winter of 1971–1972, setting a world record at the time

For specific April average peak snowpack data at the 1560 m elevation, you may want to consult the NRCS SNOTEL database directly or contact Mount Rainier National Park for detailed historical averages.


REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
WebSearch: North Cascades glacier equilibrium line altitude mean Pelto meters ELA 2000 m
North Cascades glacier equilibrium line altitude mean Pelto meters ELA 2000 m

# result:
Web search results for query: "North Cascades glacier equilibrium line altitude mean Pelto meters ELA 2000 m"

Links: [{"title":"Estimating equilibrium-line altitude (ELA) from glacier inventory data | Annals of Glaciology | Cambridge Core","url":"https://www.cambridge.org/core/journals/annals-of-glaciology/article/estimating-equilibriumline-altitude-ela-from-glacier-inventory-data/89DEF6B31BA17C0DF6B5B566BCD2CC64"},{"title":"Equilibrium line altitude – From a Glaciers Perspective","url":"https://glacierchange.blog/tag/equilibrium-line-altitude/"},{"title":"1 Christopher Mira, Class of 2004 in Alaska Equilibrium Line Altitudes","url":"https://www.geo.umass.edu/courses/geo563/Geo563-4ELAnew.pdf"},{"title":"North America – North Cascade Glacier Climate Project","url":"https://glaciers.nichols.edu/north-american-glacier-mass-balance/"},{"title":"Equilibrium-Line Altitude (ELA) | Springer Nature Link","url":"https://link.springer.com/rwe/10.1007/978-90-481-2642-2_140"},{"title":"RealClimate: Glacier Mass Balance: equilibrium or disequilibrium response?","url":"https://www.realclimate.org/index.php/archives/2007/05/glacier-mass-balance-equilibrium-or-disequilibrium-response/"},{"title":"Equilibrium Line - an overview | ScienceDirect Topics","url":"https://www.sciencedirect.com/topics/earth-and-planetary-sciences/equilibrium-line"},{"title":"www.nps.gov","url":"https://www.nps.gov/parkhistory/online_books/geology/publications/pp/715-B/sec3.htm"},{"title":"cambridge.org","url":"https://cambridge.org/core/journals/journal-of-glaciology/article/mass-balance-of-vesper-glacier-washington-usa/7B28666092CEE1A9B15E543565B6A34C"}]

I found relevant information about North Cascades glacier equilibrium line altitudes. Here are the key results:

South Cascade Glacier in the North Cascades has a mean ELA of 1,900 m, which is close to the 2,000 m threshold you mentioned.

The North Cascade Glacier Climate Project monitors the mass balance of Columbia, Daniels, Easton, Foss, Ice Worm, Lower Curtis, Lynch, Rainbow, Sholes, and Yawning Glacier, North Cascades, WA (M. Pelto). This research is conducted by Mauri Pelto, who appears to be the primary researcher referenced in your search query.

Additionally, Vesper Glacier in the North Cascade Range has an approximate equilibrium-line altitude (ELA) of 1,475 m, which is notably below the regional ELA value due to its specific characteristics as an avalanche-nourished glacier.

The search results show that North Cascades glaciers have varying ELAs depending on their specific location, aspect, and local conditions, with values ranging from around 1,475 m to 1,900 m.


REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
WebSearch: snow avalanche runout alpha angle typical 20-25 degrees Norwegian model Lied Bakkehoi
snow avalanche runout alpha angle typical 20-25 degrees Norwegian model Lied Bakkehoi

# result:
Web search results for query: "snow avalanche runout alpha angle typical 20-25 degrees Norwegian model Lied Bakkehoi"

Links: [{"title":"Statistical runout modeling of snow avalanches using GIS in Glacier National Park, Canada - ScienceDirect","url":"https://www.sciencedirect.com/science/article/abs/pii/S0165232X08001109"},{"title":"1 Response to Reviewer #3’s comment on “The mechanical origin of","url":"https://tc.copernicus.org/preprints/tc-2020-83/tc-2020-83-AC3-supplement.pdf"},{"title":"Empirical α–β runout modelling of snow avalanches in the Catalan Pyrenees | Journal of Glaciology | Cambridge Core","url":"https://www.cambridge.org/core/journals/journal-of-glaciology/article/empirical-runout-modelling-of-snow-avalanches-in-the-catalan-pyrenees/BDAD08E2FEF6DF9EF581A1EABBAE0251"},{"title":"Can big data and random forests improve avalanche runout estimation compared to simple linear regression? - ScienceDirect","url":"https://www.sciencedirect.com/science/article/pii/S0165232X23000745"},{"title":"On probability analysis in snow avalanche hazard zoning | Annals of Glaciology | Cambridge Core","url":"https://www.cambridge.org/core/journals/annals-of-glaciology/article/on-probability-analysis-in-snow-avalanche-hazard-zoning/2130DC57D734D5BE0E772E18A066B96F"},{"title":"Simulation of Snow-Avalanche Flow in Run-Out Zones | Annals of Glaciology | Cambridge Core","url":"https://www.cambridge.org/core/journals/annals-of-glaciology/article/simulation-of-snowavalanche-flow-in-runout-zones/929B1464EFAC72164A8C09579DFB171E"},{"title":"Four decades of observations from NGI's full-scale avalanche test site Ryggfonn—Summary of experimental results - ScienceDirect","url":"https://www.sciencedirect.com/science/article/pii/S0165232X16300246"},{"title":"Calculation of Snow Avalanche Runout Distance | Annals of Glaciology | Cambridge Core","url":"https://www.cambridge.org/core/journals/annals-of-glaciology/article/calculation-of-snow-avalanche-runout-distance/CD6BE1FFFE9F69969E8C67ED0E8A4FD8"},{"title":"www.cambridge.org","url":"https://www.cambridge.org/core/journals/annals-of-glaciology/article/calculation-of-maximum-snowavalanche-runout-distance-by-use-of-digital-terrain-models/49A677E49F754EAA25899960C8E6D8A9"}]

Based on the search results, I found information about the Norwegian α-β model developed by Lied and Bakkehøi for snow avalanche runout prediction.

The alpha-beta model (Lied and Bakkehoi, 1980) is based on the idea that the runout angle alpha is proportional to the beta angle, which is a measure of the mean slope angle. The statistical α/β model predicts the extreme run-out distance for a snow avalanche solely as a function of topography.

Regarding the typical alpha angle values, Lied and Bakkehøi (1980) surveyed major avalanches in Norway and found that the angle from the starting point to the run–out point is on average 33°, although values as low as 18° and as high as 49° have been found.

The model also shows that in the equation presented by Lied and Bakkehøi (1980) R = 0.95 and SD = 2.3°. The alpha–beta regression runout model (Bakkehoi et al., 1983, Lied and Bakkehoi, 1980, Lied et al., 1989), initially applied in Norway by the Norwegian Geotechnical Institute (NGI) and applied in other parts of the world, is well suited to topographic mapping.


REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
WebSearch: crevasse spacing typical glacier transverse crevasses depth 30 m spacing tens of meters
crevasse spacing typical glacier transverse crevasses depth 30 m spacing tens of meters

# result:
Web search results for query: "crevasse spacing typical glacier transverse crevasses depth 30 m spacing tens of meters"

Links: [{"title":"Primary Transverse Crevasses* | Journal of Glaciology | Cambridge Core","url":"https://www.cambridge.org/core/journals/journal-of-glaciology/article/primary-transverse-crevasses/A3B53BEAD225C3D7FE991C7ADBC68D2C"},{"title":"• Institute of Polar Studies Report No. 16 An Examination and Analysis of the","url":"https://kb.osu.edu/server/api/core/bitstreams/446bdb3d-f9d6-5ed0-96fd-bce9bfd76c03/content"},{"title":"Transverse crevasse | glaciology | Britannica","url":"https://www.britannica.com/science/transverse-crevasse"},{"title":"Transition Between Mechanical and Geometric Controls in Glacier Crevassing Processes - Rousseau - 2024 - Geophysical Research Letters - Wiley Online Library","url":"https://agupubs.onlinelibrary.wiley.com/doi/10.1029/2024GL108206"},{"title":"Transverse crevasses: Explanation/illustration of the term","url":"https://www.gletschervergleiche.ch/Pages/TermDetail.aspx?Id=6044"},{"title":"Crevasse (Glacier) - Glacier Info","url":"https://www.lwpetersen.com/definitions/glaciers/crevasse-glacier/"},{"title":"A stress-based poro-damage phase field model for hydrofracturing of creeping glaciers and ice shelves","url":"https://arxiv.org/pdf/2209.02366"},{"title":"Structural glaciology - AntarcticGlaciers.org","url":"https://www.antarcticglaciers.org/glacier-processes/structural-glaciology/"},{"title":"AAC Publications - Crevasse Patterns in Glaciers","url":"http://publications.americanalpineclub.org/articles/12195804400/Crevasse-Patterns-in-Glaciers"},{"title":"Crevasse — Grokipedia","url":"https://grokipedia.com/page/Crevasse"}]

Based on the search results, here's what I found about transverse crevasse spacing and depth:

## Crevasse Spacing

Transverse crevasse spacings on temperate glaciers are variable, ranging from 30 m to 96 m. More specifically, initial transverse crevasses can have spacings ranging from 57 to 66 m for cold ice.

There's also a quantitative relationship between spacing and depth: crevasse spacings average 2.8 times the mean crevasse depth, which is about 26 meters.

## Crevasse Depth

Regarding depth, most dry crevasses do not exceed a depth of 30 m. However, the depth can range from a few metres to 50 metres and more in alpine glaciers, and in very cold ice at high latitudes, crevasse depth can exceed 30 m, with some observed to be 45 m.

Crevasse depth is controlled by the compressive stresses within the ice, with ice overburden pressure increasing with depth and forcing the crack shut.


REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
WebSearch: Mount Rainier glacier area km2 2015 Beason Mount Rainier glaciers total area change
Mount Rainier glacier area km2 2015 Beason Mount Rainier glaciers total area change

# result:
Web search results for query: "Mount Rainier glacier area km2 2015 Beason Mount Rainier glaciers total area change"

Links: [{"title":"Time-lapse Photography of Glaciers at Mount Rainier National Park (U.S. National Park Service)","url":"https://www.nps.gov/articles/time-lapse-photography-of-glaciers-at-mount-rainier.htm"},{"title":"Cliff Mass Weather Blog: The Real Story About a \"Melting\" Mount Rainier","url":"https://cliffmass.blogspot.com/2023/06/the-real-story-about-melting-mount.html"},{"title":"Map of the study area on the north and east slopes of Mount Rainier,... | Download Scientific Diagram","url":"https://www.researchgate.net/figure/Map-of-the-study-area-on-the-north-and-east-slopes-of-Mount-Rainier-showing-the_fig1_332283530"},{"title":"National Park Service U.S. Department of the Interior","url":"https://irma.nps.gov/DataStore/DownloadFile/516936"},{"title":"Pyramid Glacier","url":"https://en.wikipedia.org/wiki/Pyramid_Glacier"},{"title":"Inter Glacier","url":"https://en.wikipedia.org/wiki/Inter_Glacier"},{"title":"August: Mount Rainier Glaciers - Mountain Wilderness","url":"https://www.mountainwilderness.org/2024/08/27/august-mount-rainier-glaciers/"},{"title":"Cowlitz Glacier","url":"https://en.wikipedia.org/wiki/Cowlitz_Glacier"},{"title":"Nisqually Glacier","url":"https://en.wikipedia.org/wiki/Nisqually_Glacier"},{"title":"Mount Rainier National Park","url":"https://en.wikipedia.org/wiki/Mount_Rainier_National_Park"}]

Based on the search results, here's what I found about Mount Rainier's glacier area and changes:

## Glacier Area in 2015

Mount Rainier National Park contains 29 named glacial features which cover an area of 30.4 square miles (78.8 km²) as of 2015 according to Beason's research.

## Total Area Change (1896-2015)

Between 1896 and 2015, Mount Rainier as a whole has had an overall decrease in glacial area of 20.1 square miles (52.1 km²), or an annual decrease of 0.17 square miles per year (0.44 km²/year) according to Beason (2017) and George and Beason (2017).

## Historical Context

The research indicates that the glacial area has been reduced from around 130 km² to around 75, with most of the loss occurring between 1895 and 1970, a period when there was very little human-caused global warming.

The Beason (2017) study referenced is the National Park Service's comprehensive glacier inventory documenting these changes at Mount Rainier over more than a century.


REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
Bash: Check Stuart render status
cat (tmp) | tail -5; ps aux | grep "render.py stuart" | grep -v grep | wc -l

# output:
2
Read: elements/snowpeak/refs/Olympic_Mountains_from_Hurricane_Ridge/commons_Snow_capped_mountains_hurricane_ridge_c_bubar_ju_b0ae24a4.jpg

elements/snowpeak/refs/Olympic_Mountains_from_Hurricane_Ridge/commons_Snow_capped_mountains_hurricane_ridge_c_bubar_ju_b0ae24a4.jpg 82. elements/snowpeak/refs/Olympic_Mountains_from_Hurricane_Ridge/commons_Snow_capped_mountains_hurricane_ridge_c_bubar_ju_b0ae24a4.jpg

Bash: Add Olympus shot definition
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("fitcam.py").read()
s=s.replace(''' "rainier_w_sept":''',''' "olympus_hr_july": dict(photo=REF+"Olympic_Mountains_from_Hurricane_Ridge/commons_Snow_capped_mountains_hurricane_ridge_c_bubar_ju_b0ae24a4.jpg",
     cam=(47.969611,-123.499084), agl=2, snap=300, peak="olympus", res=15, pad=(20000,20000,20000,25000),
     fov=20, span=60, el=(-8,10), time=(2014,7,15,12,0), maskfrac=0.6, summit=(855/1280,262/960), fovs=(8,30),
     crop=(0,40,320,150)),
 "rainier_w_sept":''')
open("fitcam.py","w").write(s)
EOF
ps aux | grep "render.py stuart" | grep -v grep | wc -l

# output:
0
Bash: Show Stuart render log
cat (tmp)

# output:
colours 38
      722.41 real        88.54 user         7.88 sys
          2469085184  maximum resident set size

[exited with code 0]
Read: elements/snowpeak/stages/r_stuart_s_july_summer_lidar.png

elements/snowpeak/stages/r_stuart_s_july_summer_lidar.png 83. elements/snowpeak/stages/r_stuart_s_july_summer_lidar.png

Bash: Debug Stuart snow cover and lighting
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > dbg_cover.py <<'EOF'
import numpy as np, sys, scene
from PIL import Image
shot=sys.argv[1]; season=sys.argv[2]; z=float(sys.argv[3])
S=scene.Scene(shot,W=960); S.snow(season,season_over=dict(z_sl=z)); G=S.gbuffer()
c=np.where(G["sky"],0.3,np.clip(G["cover"],0,1)); c=np.where(G["glacier"]>0,1.0,c)
hs=np.where(G["sky"],0,np.clip(G["hs"]/2,0,1))
ndl=np.where(G["sky"],0,np.clip(G["ndl"],0,1)*G["sunlit"])
ph=np.asarray(Image.open(S.s["photo"]).convert("L").resize((960,S.H),Image.LANCZOS))/255.
out=np.concatenate([ph,c,hs,ndl],0)
Image.fromarray((out*255).astype(np.uint8)).save(f"../stages/dbg_cover_{shot}.png")
print("cover>0.5 frac on terrain", (c[~G["sky"]]>0.5).mean(), "hs max", G["hs"].max())
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 dbg_cover.py stuart_s_july summer 2200

# output:
cover>0.5 frac on terrain 0.014458956981024031 hs max 14764.9795
Read: elements/snowpeak/stages/dbg_cover_stuart_s_july.png

elements/snowpeak/stages/dbg_cover_stuart_s_july.png 84. elements/snowpeak/stages/dbg_cover_stuart_s_july.png

Bash: Couloir side-wall support and pit cap
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("snowmodel.py").read()
s=s.replace('''        if tw <= 0:
            continue                                   # pit: keep it (snow fills the hollow)''','''        if tw <= 0:
            if out[i, j] > pit_cap:                    # pit: fills to pit_cap; the rest is lost
                out[i, j] = pit_cap                    # (lidar pits are noise, not 10 km wells)
            continue''')
s=s.replace("def _route(Z, hs, shd, res):","def _route(Z, hs, shd, res, pit_cap):")
s=s.replace('''    shd = holding_depth(slope, P)
    hs_g = _route(Z, hs_w, shd, res)''','''    shd = holding_depth(slope, P)
    # couloirs: snow wedged between walls is held by side friction, so a channel holds more than an
    # open slope of the same angle (and steep rough rock holds snow on ledges: Haberkorn 2017)
    cg = curvature(Z, res, P["gully_r"])
    gully = np.clip(-cg / P["gully_depth"], 0, 1)
    shd = shd * (1 + P["k_gully"] * gully)
    hs_g = _route(Z, hs_w, shd, res, P["pit_cap"])''')
s=s.replace('''               k_melt=0.9, p_ins=0.8, doy_melt=(135, 250))''','''               k_melt=0.9, p_ins=0.8, doy_melt=(135, 250),
               gully_r=25.0, gully_depth=4.0, k_gully=6.0, pit_cap=25.0)''')
s=s.replace('''    return dict(Z=Z, slope=slope, aspect=aspect, hs_acc=hs, hs_wind=hs_w, hs_grav=hs_g, sx=sx, I=I,''','''    return dict(Z=Z, slope=slope, aspect=aspect, hs_acc=hs, hs_wind=hs_w, hs_grav=hs_g, sx=sx, I=I, gully=gully,''')
open("snowmodel.py","w").write(s)
EOF
grep -n "shd = holding_depth" snowmodel.py; rm -f ../cache/snowprep_stuart*; NUMBA_NUM_THREADS=4 nice -n 10 python3 dbg_cover.py stuart_s_july summer 2200

# output:
193:    shd = holding_depth(slope, P)
cover>0.5 frac on terrain 0.02995392573114176 hs max 186.82922
Read: elements/snowpeak/stages/dbg_cover_stuart_s_july.png

elements/snowpeak/stages/dbg_cover_stuart_s_july.png 85. elements/snowpeak/stages/dbg_cover_stuart_s_july.png

Bash: Render Stuart with gully snow
cd ~/work/pixelart-studies/elements/snowpeak/src; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py stuart_s_july summer 320 1900 _g1 2>&1 | tail -1

# output:
colours 42
Read: elements/snowpeak/stages/r_stuart_s_july_summer_g1.png

elements/snowpeak/stages/r_stuart_s_july_summer_g1.png 86. elements/snowpeak/stages/r_stuart_s_july_summer_g1.png

Bash: Photo vs painted L* per material for Stuart
cd ~/work/pixelart-studies/elements/snowpeak/src; sed -i '' 's/S,G,C,rgb,pal=render.run(sys.argv\[1\],sys.argv\[2\],320,season_over=dict(z_sl=float(sys.argv\[3\])))/S,G,C,rgb,pal=render.run(sys.argv[1],sys.argv[2],320,season_over=dict(z_sl=float(sys.argv[3])))\nfor fam in (0,1):\n    m=(C["mat"]==paint.M["rock"])\&(C["fam"]==fam)\n    if m.sum(): print("rock fam",fam,m.sum())/' dbg_matL.py; cat >> dbg_matL.py <<'EOF'
for name in ("rock","forest","scree","meadow"):
    for fam in (0,1):
        m=(C["mat"]==paint.M[name])&(C["fam"]==fam)
        if m.sum()>5: print(f"  {name} fam{fam} n={m.sum()} photo L {np.percentile(lab[...,0][m],[10,50,90]).round(1)} a {np.median(lab[...,1][m]):.1f} b {np.median(lab[...,2][m]):.1f} | mine {np.median(mine[...,0][m]):.1f}")
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 dbg_matL.py stuart_s_july summer 1900 2>&1 | grep -v -i "warn\|nanmean"

# output:
rock fam 0 37
rock fam 1 9240
sky     n=12736 photo L [60.  66.8 78.4] b -15.9 | mine L 54.9
snow    n=  764 photo L [17.6 38.2 74.3] b 18.4 | mine L 94.4
ice     n=  102 photo L [63.5 77.  96.3] b 18.8 | mine L 73.2
rock    n= 9277 photo L [29.5 42.8 61.9] b 14.9 | mine L 62.9
scree   n= 4102 photo L [15.8 35.2 64.2] b 20.5 | mine L 57.2
meadow  n= 5643 photo L [23.9 35.8 48.4] b 18.9 | mine L 32.0
forest  n= 9615 photo L [12.7 25.  43.4] b 14.8 | mine L 16.0
  rock fam0 n=37 photo L [18.  33.5 51.4] a 1.1 b 4.2 | mine 29.3
  rock fam1 n=9240 photo L [29.5 42.8 61.9] a 1.2 b 15.0 | mine 62.9
  forest fam1 n=9613 photo L [12.7 25.  43.4] a -1.9 b 14.8 | mine 16.0
  scree fam1 n=4102 photo L [15.8 35.2 64.2] a 2.6 b 20.5 | mine 57.2
  meadow fam1 n=5640 photo L [23.9 35.8 48.4] a 0.4 b 18.9 | mine 32.0
Bash: Weathered granite albedo, re-render Stuart
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace('"granite": np.array([0.30, 0.29, 0.27])','"granite": np.array([0.135, 0.125, 0.11])    # weathered, lichen-stained walls (rock study: lit ribs L*46)')
open("paint.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py stuart_s_july summer 320 1900 _g2 2>&1 | tail -1

# output:
SyntaxError: invalid syntax. Perhaps you forgot a comma?
Bash: Fix syntax and re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; sed -i '' 's/"granite": np.array(\[0.135, 0.125, 0.11\])    # weathered, lichen-stained walls (rock study: lit ribs L\*46)/"granite": np.array([0.135, 0.125, 0.11]),/' paint.py; grep -n granite paint.py | head -3; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py stuart_s_july summer 320 1900 _g2 2>&1 | tail -1

# output:
182:        # lit: plateau that saturates early (observation student: granite scatters broadly)
266:    "granite": np.array([0.135, 0.125, 0.11]),, "scree": np.array([0.25, 0.245, 0.24]),
SyntaxError: invalid syntax
Bash: Fix double comma and re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; sed -i '' 's/np.array(\[0.135, 0.125, 0.11\]),,/np.array([0.135, 0.125, 0.11]),/' paint.py; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py stuart_s_july summer 320 1900 _g2 2>&1 | tail -1

# output:
colours 42
Read: elements/snowpeak/stages/r_stuart_s_july_summer_g2.png

elements/snowpeak/stages/r_stuart_s_july_summer_g2.png 87. elements/snowpeak/stages/r_stuart_s_july_summer_g2.png

Bash: Sub-pixel shadow fraction drives texture
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("render.py").read()
s=s.replace('''    G["glacier"] = paint.down(Gs["glacier"].astype(float), k, "centre")''','''    G["glacier"] = paint.down(Gs["glacier"].astype(float), k, "centre")
    # sub-pixel shadow: the fraction of a pixel's supersamples facing away from the sun or in cast
    # shadow.  Averaging n.l first would erase every rib and gully narrower than a pixel.
    sh = ((Gs["ndl"] < 0.06) | (Gs["sunlit"] < 0.5)) & ~Gs["sky"]
    ter = (~Gs["sky"]).astype(float)
    G["shadefrac"] = paint.down(sh.astype(float), k) / np.maximum(paint.down(ter, k), 1e-3)''')
open("render.py","w").write(s)
s=open("paint.py").read()
s=s.replace('''    ndl = G["ndl"]; lit = (ndl > P["lit_min"]) & (G["sunlit"] > 0.5)''','''    ndl = G["ndl"]
    sf = G.get("shadefrac", np.zeros_like(ndl))
    lit = sf < 0.5''')
s=s.replace('''        x = np.where(lit, xl, xs)''','''        # sub-pixel shade pulls a lit pixel down (ribs and gullies narrower than a pixel read as
        # texture); sub-pixel light lifts a shade pixel
        xl = xl - P["subpix_k"] * sf * (nl - 1)
        xs = xs + P["subpix_k"] * (1 - sf) * (ns - 1) * 0.5
        x = np.where(lit, xl, xs)''')
s=s.replace('''    seam_w=0.35,''','''    seam_w=0.35,
    subpix_k=1.6,          # how strongly sub-pixel shadow fraction lowers a lit pixel's step''')
open("paint.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py stuart_s_july summer 320 1900 _g3 2>&1 | tail -1

# output:
colours 44
Read: elements/snowpeak/stages/r_stuart_s_july_summer_g3_crop.png

elements/snowpeak/stages/r_stuart_s_july_summer_g3_crop.png 88. elements/snowpeak/stages/r_stuart_s_july_summer_g3_crop.png

Bash: Local relief shading term; re-render Stuart
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("render.py").read()
s=s.replace('''    G["shadefrac"] =''','''    # local relief: n.l minus its neighbourhood mean, at supersample scale, then pooled.  Imhof's
    # "local adjustment of the light": forms read even where the true sun is flat on the face.
    nd = np.where(Gs["sky"], np.nan, Gs["ndl"])
    from scipy import ndimage as _ndi
    base = _ndi.gaussian_filter(np.nan_to_num(nd, nan=0.5), 3 * k)
    hp = np.nan_to_num(nd - base)
    G["relief"] = paint.down(hp, k)
    G["shadefrac"] =''')
open("render.py","w").write(s)
s=open("paint.py").read()
s=s.replace('''        x = np.where(lit, xl, xs)''','''        rel = G.get("relief", np.zeros_like(ndl))
        rk = P["relief_k"].get(name, 0.0)
        xl = xl + rk * rel / (np.std(rel[m]) + 1e-6) * 0.5
        xs = xs + rk * rel / (np.std(rel[m]) + 1e-6) * 0.3
        x = np.where(lit, xl, xs)''')
s=s.replace('''    subpix_k=1.6,''','''    subpix_k=1.6,
    relief_k={"rock": 0.9, "scree": 0.5, "snow": 0.35, "ice": 0.4, "forest": 0.6, "meadow": 0.5, "debris": 0.5},''')
open("paint.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py stuart_s_july summer 320 1900 _g4 2>&1 | tail -1

# output:
colours 42
Read: elements/snowpeak/stages/r_stuart_s_july_summer_g4_crop.png

elements/snowpeak/stages/r_stuart_s_july_summer_g4_crop.png 89. elements/snowpeak/stages/r_stuart_s_july_summer_g4_crop.png

Bash: Wider rock lit ramp; re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace('''        ndl = 0.40 + 0.55 * (s + 0.5) / nl''','''        lo, hi = LIT_RANGE.get(name, (0.25, 0.95))
        ndl = lo + (hi - lo) * (s + 0.5) / nl''')
s=s.replace('''def step_light(name, fam, s):''','''LIT_RANGE = {"snow": (0.40, 0.95), "ice": (0.35, 0.95)}   # snow's lit ramp is compressed: it is
                                                             # a plateau (the photos), rock's is wide


def step_light(name, fam, s):''')
open("paint.py","w").write(s)
EOF
python3 palcheck.py | sed -n 5,6p; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py stuart_s_july summer 320 1900 _g5 2>&1 | tail -1

# output:
rock    shade 15.6/-7.8 21.0/-9.2
rock    lit  31.0/-3.7 37.4/-3.1 42.5/-2.7
colours 42
Bash: Exaggerate rock lit ramp; re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; sed -i '' 's/LIT_RANGE.get(name, (0.25, 0.95))/LIT_RANGE.get(name, (0.05, 1.15))/' paint.py; python3 palcheck.py | sed -n 5,6p; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py stuart_s_july summer 320 1900 _g5 2>&1 | tail -1

# output:
rock    shade 15.6/-7.8 21.0/-9.2
rock    lit  26.5/-4.4 37.4/-3.1 45.1/-2.5
colours 42
Read: elements/snowpeak/stages/r_stuart_s_july_summer_g5_crop.png

elements/snowpeak/stages/r_stuart_s_july_summer_g5_crop.png 90. elements/snowpeak/stages/r_stuart_s_july_summer_g5_crop.png

Bash: Visualize relief, shade fraction, n.l, step
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > dbg_fields.py <<'EOF'
import numpy as np, sys, render
from PIL import Image
S,G,C,rgb,pal=render.run(sys.argv[1],sys.argv[2],320,season_over=dict(z_sl=float(sys.argv[3])))
def n(a): a=np.nan_to_num(a); lo,hi=np.percentile(a[~G["sky"]],[2,98]); return np.clip((a-lo)/(hi-lo+1e-9),0,1)
out=np.concatenate([n(G["relief"]),n(G["shadefrac"]),n(G["ndl"]),n(C["step"].astype(float))],0)
Image.fromarray((out*255).astype(np.uint8)).resize((640,out.shape[0]*2),Image.NEAREST).save("../stages/dbg_fields.png")
print("relief std",np.std(G["relief"][~G["sky"]]),"ndl std",np.std(G["ndl"][~G["sky"]]))
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 dbg_fields.py stuart_s_july summer 1900 2>&1 | tail -1

# output:
relief std 0.08284291410910821 ndl std 0.12459989554748087
Read: elements/snowpeak/stages/dbg_fields.png

elements/snowpeak/stages/dbg_fields.png 91. elements/snowpeak/stages/dbg_fields.png

Bash: Read current family_step
cd ~/work/pixelart-studies/elements/snowpeak/src; grep -n "def family_step" paint.py; sed -n "$(grep -n 'def family_step' paint.py | cut -d: -f1),+55p" paint.py

# output:
170:def family_step(mat, G, P):
def family_step(mat, G, P):
    ndl = G["ndl"]
    sf = G.get("shadefrac", np.zeros_like(ndl))
    lit = sf < 0.5
    thr = clustered_noise(*ndl.shape, np.random.default_rng(P.get("seed", 1)))
    up = G["normal"][..., 2]
    H, W = mat.shape
    step = np.zeros((H, W), np.int16)
    mpp = np.maximum(G["dist"], 1) * G["pix_rad"]                 # metres per pixel on the ground
    cav = G["curv"] < -P["cav_k"] * np.clip(mpp * 0.25, 1.5, 30)
    rib = G["curv"] > P["cav_k"] * np.clip(mpp * 0.25, 1.5, 30)
    for name in MATS:
        m = mat == M[name]
        if not m.any():
            continue
        ns, nl = NSTEP[name]
        # lit: plateau that saturates early (observation student: granite scatters broadly)
        xl = np.clip((ndl - P["lit_lo"]) / P["lit_span"], 0, 1) ** P["lit_gamma"] * (nl - 1 + 0.49)
        # shade: core next to the terminator, sky-lit (upward-facing) deeper in
        xs = np.clip((up - 0.35) / 0.55, 0, 1) * (ns - 1 + 0.49)
        core = (ndl > -0.25) & (ndl <= P["lit_min"])
        xs = np.where(core & (G["sunlit"] > 0.5), np.minimum(xs, 0.4), xs)
        # sub-pixel shade pulls a lit pixel down (ribs and gullies narrower than a pixel read as
        # texture); sub-pixel light lifts a shade pixel
        xl = xl - P["subpix_k"] * sf * (nl - 1)
        xs = xs + P["subpix_k"] * (1 - sf) * (ns - 1) * 0.5
        rel = G.get("relief", np.zeros_like(ndl))
        rk = P["relief_k"].get(name, 0.0)
        xl = xl + rk * rel / (np.std(rel[m]) + 1e-6) * 0.5
        xs = xs + rk * rel / (np.std(rel[m]) + 1e-6) * 0.3
        x = np.where(lit, xl, xs)
        # flat planes with a dithered seam at each half step (aerial student's flat_bands);
        # clustered thresholds so seams read as strings and clumps, not salt-and-pepper
        f = np.floor(x); frac = x - f
        seam = P["seam_w"]
        t = np.clip((frac - (0.5 - seam / 2)) / seam, 0, 1)
        s = (f + (t > thr)).astype(np.int16)
        s = np.minimum(s, np.where(lit, nl - 1, ns - 1))
        s = np.where(cav & m, np.maximum(s - 1, 0), s)
        s = np.where(rib & lit & m, np.minimum(s + 1, np.where(lit, nl, ns) - 1), s)
        step[m] = s[m]
    fam = lit.astype(np.int16)
    return fam, step, dict(frac=None)


def eff_dist(dist, hc, ht, Ha=1500.0):
    """sea-level-equivalent haze path (m): aerosol density falls as exp(-h / Ha) (Ha ~ 1-2 km, the
    boundary layer), so a ray from hc to ht crosses dist * mean(exp(-h/Ha)) of sea-level haze.
    This is why summits stand clear above hazy foothills."""
    dh = ht - hc
    small = np.abs(dh) < 1.0
    mean = np.where(small, np.exp(-hc / Ha),
                    Ha * (np.exp(-hc / Ha) - np.exp(-ht / Ha)) / np.where(small, 1.0, dh))
    return dist * mean
Bash: Quantile-keyed steps blended with absolute; re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
a=s.index("def family_step(mat, G, P):"); b=s.index("def eff_dist(")
s=s[:a]+'''def rank_steps(score, m, shares):
    """map score over mask m to a continuous step coordinate whose integer parts have the given
    shares (quantile keying: the ground student's 'key each family by its own quantiles', the rock
    student's 'value economy taken from the photo')."""
    x = np.zeros_like(score)
    if m.sum() < 4:
        return x
    v = score[m]
    order = np.argsort(np.argsort(v)); r = (order + 0.5) / len(v)           # rank in 0..1
    edges = np.concatenate([[0], np.cumsum(shares)]); edges /= edges[-1]
    k = np.clip(np.searchsorted(edges, r, side="right") - 1, 0, len(shares) - 1)
    frac = (r - edges[k]) / np.maximum(edges[k + 1] - edges[k], 1e-9)
    x[m] = k + frac
    return x


def family_step(mat, G, P):
    """lit/shade family per pixel (from the sub-pixel shade fraction), then a step inside the family.
    The step coordinate blends an absolute light value (n.l, sky view) with a rank-keyed one, so
    every material shows its forms at a designed value economy, even under flat high sun."""
    ndl = G["ndl"]
    sf = G.get("shadefrac", np.zeros_like(ndl))
    lit = sf < 0.5
    thr = clustered_noise(*ndl.shape, np.random.default_rng(P.get("seed", 1)))
    up = G["normal"][..., 2]
    rel = G.get("relief", np.zeros_like(ndl))
    H, W = mat.shape
    step = np.zeros((H, W), np.int16)
    mpp = np.maximum(G["dist"], 1) * G["pix_rad"]
    cav = G["curv"] < -P["cav_k"] * np.clip(mpp * 0.25, 1.5, 30)
    rib = G["curv"] > P["cav_k"] * np.clip(mpp * 0.25, 1.5, 30)
    for name in MATS:
        m = mat == M[name]
        if not m.any():
            continue
        ns, nl = NSTEP[name]
        rk = P["relief_k"].get(name, 0.0)
        q = P["key_q"].get(name, 0.0)
        sh = P["shares"].get(name, {})
        # absolute coordinates
        xl = np.clip((ndl - P["lit_lo"]) / P["lit_span"], 0, 1) ** P["lit_gamma"] * (nl - 1 + 0.49)
        xs = np.clip((up - 0.35) / 0.55, 0, 1) * (ns - 1 + 0.49)
        core = (ndl > -0.25) & (ndl <= P["lit_min"])
        xs = np.where(core & (G["sunlit"] > 0.5), np.minimum(xs, 0.4), xs)
        # scores for keying: light + local relief - sub-pixel shade
        sd = np.std(rel[m]) + 1e-6
        scl = ndl + rk * rel / sd * 0.15 - P["subpix_k"] * 0.15 * sf
        scs = up + rk * rel / sd * 0.15 + 0.3 * (1 - sf)
        ml = m & lit; ms = m & ~lit
        rl = rank_steps(scl, ml, sh.get("lit", [1] * nl))
        rs = rank_steps(scs, ms, sh.get("shade", [1] * ns))
        x = np.where(lit, (1 - q) * xl + q * rl, (1 - q) * xs + q * rs)
        f = np.floor(x); frac = x - f
        seam = P["seam_w"]
        t = np.clip((frac - (0.5 - seam / 2)) / seam, 0, 1)
        s = (f + (t > thr)).astype(np.int16)
        s = np.clip(s, 0, np.where(lit, nl - 1, ns - 1))
        s = np.where(cav & m, np.maximum(s - 1, 0), s)
        s = np.where(rib & lit & m, np.minimum(s + 1, np.where(lit, nl, ns) - 1), s)
        step[m] = s[m]
    fam = lit.astype(np.int16)
    return fam, step, dict(frac=None)


'''+s[b:]
s=s.replace('''    relief_k={''','''    key_q={"rock": 0.85, "scree": 0.7, "snow": 0.55, "ice": 0.6, "forest": 0.7, "meadow": 0.6, "debris": 0.6},
    shares={"rock": {"lit": [0.3, 0.4, 0.3], "shade": [0.5, 0.5]},
            "snow": {"lit": [0.12, 0.33, 0.55], "shade": [0.3, 0.4, 0.3]},
            "forest": {"lit": [0.35, 0.4, 0.25], "shade": [0.5, 0.5]}},
    relief_k={''')
open("paint.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py stuart_s_july summer 320 1900 _g6 2>&1 | tail -1

# output:
numpy.core._exceptions._UFuncOutputCastingError: Cannot cast ufunc 'divide' output from dtype('float64') to dtype('int64') with casting rule 'same_kind'
Bash: Fix dtype and re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; sed -i '' 's/    edges = np.concatenate(\[\[0\], np.cumsum(shares)\]); edges \/= edges\[-1\]/    edges = np.concatenate([[0.0], np.cumsum(np.asarray(shares, float))]); edges \/= edges[-1]/' paint.py; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py stuart_s_july summer 320 1900 _g6 2>&1 | tail -1

# output:
colours 45
Read: elements/snowpeak/stages/r_stuart_s_july_summer_g6_crop.png

elements/snowpeak/stages/r_stuart_s_july_summer_g6_crop.png 92. elements/snowpeak/stages/r_stuart_s_july_summer_g6_crop.png

Bash: Fit Olympus camera, render Glacier Peak
cd ~/work/pixelart-studies/elements/snowpeak/src; sed -i '' 's/"granite": np.array(\[0.135, 0.125, 0.11\]),/"granite": np.array([0.15, 0.13, 0.105]),/' paint.py; NUMBA_NUM_THREADS=4 nice -n 10 python3 fitcam.py olympus_hr_july 2>&1 | tail -1; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py gp_e_aug sept 320 2100 _a1 2>&1 | tail -1

# output:
olympus_hr_july dist km 24.7 z 2 best [-143.88110354   -2.87308425   31.95376687    2.16535176]
colours 58
Bash: Check Olympus DEM coverage
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 -c "
import numpy as np, dem, fitcam
s,T,ce,cn,pe,pn,z=fitcam.setup('olympus_hr_july'); Z=T['Z']; print(Z.shape, (Z==0).mean(), Z.max(), z)
print(dem.sample(T,ce,cn))
import rasterio; d=rasterio.open('~/work/voxsim-data/dem10/USGS_13_n48w124_20240327.tif'); print(d.bounds, d.nodata)
"

# output:
Exit code 1
Traceback (most recent call last):
  File "rasterio/_base.pyx", line 310, in rasterio._base.DatasetBase.__init__
  File "rasterio/_base.pyx", line 221, in rasterio._base.open_dataset
  File "rasterio/_err.pyx", line 359, in rasterio._err.exc_wrap_pointer
rasterio._err.CPLE_OpenFailedError: ~/work/voxsim-data/dem10/USGS_13_n48w124_20240327.tif: No such file or directory

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<string>", line 5, in <module>
  File "~/miniconda3/lib/python3.12/site-packages/rasterio/env.py", line 463, in wrapper
    return f(*args, **kwds)
           ^^^^^^^^^^^^^^^^
  File "~/miniconda3/lib/python3.12/site-packages/rasterio/__init__.py", line 356, in open
    dataset = DatasetReader(path, driver=driver, sharing=sharing, **kwargs)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "rasterio/_base.pyx", line 312, in rasterio._base.DatasetBase.__init__
rasterio.errors.RasterioIOError: ~/work/voxsim-data/dem10/USGS_13_n48w124_20240327.tif: No such file or directory
(4239, 3732) 1.0 0.0 2.0
[0.]
Bash: Check whether DEM tiles were removed
ls -la ~/work/voxsim-data/dem10/ ~/work/voxsim-data/landcover/; df -h ~ | tail -1

# output:
ls: ~/work/voxsim-data/dem10/: No such file or directory
~/work/voxsim-data/landcover/:
total 406696
drwxr-xr-x   6 user  staff       192 Sep 24 17:57 .
drwxr-xr-x  29 user  staff       928 Sep 26 15:27 ..
-rw-r--r--   1 user  staff  64998919 Sep 24 17:57 ESA_WorldCover_10m_2021_v200_N45W123_Map.tif
-rw-r--r--   1 user  staff  18472488 Sep 24 17:57 ESA_WorldCover_10m_2021_v200_N45W126_Map.tif
-rw-r--r--   1 user  staff  74010155 Sep 24 17:57 ESA_WorldCover_10m_2021_v200_N48W123_Map.tif
-rw-r--r--   1 user  staff  50743200 Sep 24 17:57 ESA_WorldCover_10m_2021_v200_N48W126_Map.tif
/dev/disk3s5   926Gi   846Gi    43Gi    96%     10M  447M    2%   /System/Volumes/Data
Bash: Add remote 1/3-arcsecond DEM fallback
cd ~/work/pixelart-studies/elements/snowpeak/src; curl -sI https://prd-tnm.s3.amazonaws.com/StagedProducts/Elevation/13/TIFF/current/n48w124/USGS_13_n48w124.tif | head -3; python3 - <<'EOF'
s=open("dem.py").read()
s=s.replace('''    Z = _mosaic(sorted(glob.glob(DATA + "/dem10/USGS_13_*.tif")), Z, transform,
                Resampling.average if res > 15 else Resampling.bilinear, 0.0)''','''    local = sorted(glob.glob(DATA + "/dem10/USGS_13_*.tif"))
    Z = _mosaic(local or _remote_13(e0, e1, n0, n1), Z, transform,
                Resampling.average if res > 15 else Resampling.bilinear, 0.0)''')
s=s.replace('''def _mosaic(files, dst, transform, resampling, nodata_fill):''','''def _remote_13(e0, e1, n0, n1):
    """USGS 1/3 arc-second tiles covering a UTM box, as /vsicurl/ COG paths (read remotely; the local
    copies in ~/work/voxsim-data/dem10 were pruned for disk space on 2026-09-26)."""
    import math
    lats, lons = [], []
    for e in (e0, e1):
        for n in (n0, n1):
            la, lo = utm2ll(e, n); lats.append(la); lons.append(lo)
    out = []
    for ty in range(math.floor(min(lats)) + 1, math.floor(max(lats)) + 2):
        for tx in range(math.floor(-max(lons)) + 1, math.floor(-min(lons)) + 2):
            out.append(f"/vsicurl/https://prd-tnm.s3.amazonaws.com/StagedProducts/Elevation/13/TIFF/current/n{ty}w{tx:03d}/USGS_13_n{ty}w{tx:03d}.tif")
    return out


def _mosaic(files, dst, transform, resampling, nodata_fill):''')
open("dem.py","w").write(s)
EOF
rm -f ../cache/dem_olympus_hr_july_15.npz ../cache/pano_olympus_hr_july.npz; python3 -c "
import dem; print(dem._remote_13(*[440000,470000,5280000,5330000]))"

# output:
HTTP/1.1 200 OK
x-amz-id-2: OQL8g66nxOwIkqRRfyqZYO79qoF3ChbpPvfyc6O9gX9JGgFT2QtuxMq/dMhp9GtKOVEvt8xqzwXbX7l6CbXnHtykymRyzp+K
x-amz-request-id: K6YXXED5CHBNZVYY
['/vsicurl/https://prd-tnm.s3.amazonaws.com/StagedProducts/Elevation/13/TIFF/current/n48w124/USGS_13_n48w124.tif', '/vsicurl/https://prd-tnm.s3.amazonaws.com/StagedProducts/Elevation/13/TIFF/current/n49w124/USGS_13_n49w124.tif']
Bash: Refit Olympus with remote DEM
cd ~/work/pixelart-studies/elements/snowpeak/src; export GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR; NUMBA_NUM_THREADS=4 nice -n 10 python3 fitcam.py olympus_hr_july 2>&1 | tail -1

# output:
olympus_hr_july dist km 24.8 z 1613 best [-1.42030588e+02  6.51171226e-02  1.13357159e+01  4.05826247e-02]
Bash: Combine Olympus fit and Glacier Peak render
cd ~/work/pixelart-studies/elements/snowpeak/stages; python3 -c "
from PIL import Image
a=Image.open('fit_olympus_hr_july.png'); b=Image.open('r_gp_e_aug_sept_a1.png')
a=a.resize((b.size[0]//2, int(a.size[1]*(b.size[0]//2)/a.size[0])))
o=Image.new('RGB',(b.size[0],a.size[1]+b.size[1]),'white'); o.paste(a,(0,0)); o.paste(b,(0,a.size[1])); o.save('look_olym_gp.png')"

# output:
(Bash completed with no output)
Read: elements/snowpeak/stages/look_olym_gp.png

elements/snowpeak/stages/look_olym_gp.png 93. elements/snowpeak/stages/look_olym_gp.png

Bash: Brighter snow keying; re-render two peaks
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace('key_q={"rock": 0.85, "scree": 0.7, "snow": 0.55, "ice": 0.6,','key_q={"rock": 0.85, "scree": 0.7, "snow": 0.3, "ice": 0.4,')
s=s.replace('"snow": {"lit": [0.12, 0.33, 0.55], "shade": [0.3, 0.4, 0.3]}','"snow": {"lit": [0.08, 0.22, 0.70], "shade": [0.3, 0.4, 0.3]}')
open("paint.py","w").write(s)
EOF
for a in "gp_e_aug sept 320 2100 _a2" "rainier_ne_july summer 320 2100 _v16"; do NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py $a 2>&1 | tail -1; done

# output:
IndexError: list index out of range
IndexError: list index out of range

The args loop broke (tag missing index). Run separately.

Bash: Render Glacier Peak
cd ~/work/pixelart-studies/elements/snowpeak/src; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py gp_e_aug sept 320 2100 _a2 2>&1 | tail -3

# output:
~/work/pixelart-studies/elements/snowpeak/src/render.py:25: RuntimeWarning: Mean of empty slice
  nn = np.nanmean(n[:H * k, :W * k].reshape(H, k, W, k, 3), axis=(1, 3))
colours 58
Bash: Render Rainier and stack
cd ~/work/pixelart-studies/elements/snowpeak/src; NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py rainier_ne_july summer 320 2100 _v16 2>&1 | tail -1; cd ../stages; python3 -c "
from PIL import Image
a=Image.open('r_gp_e_aug_sept_a2.png'); b=Image.open('r_rainier_ne_july_summer_v16.png')
o=Image.new('RGB',(a.size[0],a.size[1]+b.size[1])); o.paste(a,(0,0)); o.paste(b,(0,a.size[1])); o.save('look2.png')"

# output:
colours 57
Read: elements/snowpeak/stages/look2.png

elements/snowpeak/stages/look2.png 94. elements/snowpeak/stages/look2.png

Bash: Material L* comparison for Glacier Peak
cd ~/work/pixelart-studies/elements/snowpeak/src; NUMBA_NUM_THREADS=4 nice -n 10 python3 dbg_matL.py gp_e_aug sept 2100 2>&1 | grep -v -i "warn\|nanmean"

# output:
rock fam 0 196
rock fam 1 3091
sky     n=20977 photo L [56.9 62.8 68.1] b -29.9 | mine L 56.4
snow    n=  182 photo L [54.  64.3 75.3] b -3.8 | mine L 93.0
ice     n=11154 photo L [54.2 73.2 90.5] b -0.6 | mine L 72.5
rock    n= 3287 photo L [17.8 45.9 69.2] b -3.8 | mine L 40.3
scree   n= 3079 photo L [16.7 45.  66.6] b -3.0 | mine L 59.4
meadow  n= 3011 photo L [ 9.9 34.6 47.6] b -2.1 | mine L 34.4
forest  n=34908 photo L [ 3.4 19.5 36.2] b 3.6 | mine L 19.4
debris  n=  202 photo L [23.7 66.5 74.9] b 1.2 | mine L 46.9
  rock fam0 n=196 photo L [33.9 37.  60.9] a -4.0 b -9.9 | mine 24.5
  rock fam1 n=3091 photo L [17.3 47.9 69.6] a -1.4 b -3.3 | mine 41.8
  forest fam0 n=77 photo L [27.2 35.1 37.3] a -5.2 b -10.2 | mine 18.0
  forest fam1 n=34831 photo L [ 3.4 19.5 36.2] a -3.8 b 3.6 | mine 19.4
  scree fam1 n=3079 photo L [16.7 45.  66.6] a -1.9 b -3.0 | mine 59.4
  meadow fam0 n=17 photo L [33.6 36.  50.5] a -5.5 b -4.8 | mine 21.0
  meadow fam1 n=2994 photo L [ 9.8 34.5 47.6] a -3.7 b -2.0 | mine 34.4
Bash: Firn retention on glaciers; re-render GP
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("snowmodel.py").read()
s=s.replace('''    rem = prep["hs_grav"] - melt''','''    rem = prep["hs_grav"] - melt
    # on glacier ice below ~35 deg, winter snow is not shed by sluffing the way it is on rock (the
    # glacier surface is itself the product of retained snow): use the wind-redistributed depth.
    # This makes the firn line (upper glacier white, lower glacier grey ice) fall at the ELA.
    on_gl = (prep["ice"] > 0.5) & (slope < 35)
    rem = np.where(on_gl, np.maximum(rem, prep["hs_wind"] - melt), rem)''')
open("snowmodel.py","w").write(s)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py gp_e_aug sept 320 2100 _a3 2>&1 | tail -1

# output:
colours 58
Read: elements/snowpeak/stages/r_gp_e_aug_sept_a3_crop.png

elements/snowpeak/stages/r_gp_e_aug_sept_a3_crop.png 95. elements/snowpeak/stages/r_gp_e_aug_sept_a3_crop.png

Bash: Step distribution for GP snow
cd ~/work/pixelart-studies/elements/snowpeak/src; cat > dbg_steps.py <<'EOF'
import numpy as np, sys, render, paint
S,G,C,rgb,pal=render.run(sys.argv[1],sys.argv[2],320,season_over=dict(z_sl=float(sys.argv[3])))
print("sun",S.sun_used)
for name in ("snow","ice"):
    m=C["mat"]==paint.M[name]
    for f in (0,1):
        mm=m&(C["fam"]==f); print(name,f,mm.sum(), np.bincount(C["step"][mm],minlength=3), "ndl med",np.median(G["ndl"][mm]) if mm.sum() else 0)
EOF
NUMBA_NUM_THREADS=4 nice -n 10 python3 dbg_steps.py gp_e_aug sept 2100 2>&1 | grep -v -i "warn\|nanmean"

# output:
sun (110.06427247016056, 28.977619539236052)
snow 0 2 [2 0 0] ndl med 0.012069325613078616
snow 1 180 [  4  22 154] ndl med 0.7332926971718006
ice 0 7 [4 3 0] ndl med 0.019493045576008325
ice 1 11147 [2838 8309    0] ndl med 0.7190302903257844
Bash: Inspect glacier snow budget by elevation
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 -c "
import scene, numpy as np
S=scene.Scene('gp_e_aug',W=320); Sn=S.snow('sept',season_over=dict(z_sl=2100)); p=S.prep
gl=p['ice']>0.5; Z=p['Z']
for lo in (1800,2200,2600,3000):
    m=gl&(Z>lo)&(Z<lo+400)
    print(lo, m.sum(), 'slope',np.median(p['slope'][m]).round(1),'hs_wind',np.median(p['hs_wind'][m]).round(2),'melt',np.median(Sn['melt'][m]).round(2),'I',np.median(p['I'][m]).round(2),'firn frac',(Sn['glacier'][m]==1).mean().round(2))
"

# output:
~/miniconda3/lib/python3.12/site-packages/numpy/core/fromnumeric.py:3504: RuntimeWarning: Mean of empty slice.
  return _methods._mean(a, axis=axis, dtype=dtype,
~/miniconda3/lib/python3.12/site-packages/numpy/core/_methods.py:129: RuntimeWarning: invalid value encountered in divide
  ret = ret.dtype.type(ret / rcount)
<string>:7: RuntimeWarning: Mean of empty slice.
~/miniconda3/lib/python3.12/site-packages/numpy/core/_methods.py:129: RuntimeWarning: invalid value encountered in scalar divide
  ret = ret.dtype.type(ret / rcount)
1800 0 slope nan hs_wind nan melt nan I nan firn frac nan
2200 0 slope nan hs_wind nan melt nan I nan firn frac nan
2600 0 slope nan hs_wind nan melt nan I nan firn frac nan
3000 0 slope nan hs_wind nan melt nan I nan firn frac nan
Bash: Check ice fields in caches
cd ~/work/pixelart-studies/elements/snowpeak/src; ls ../cache/ | grep -i glacier; python3 -c "
import scene, numpy as np
S=scene.Scene('gp_e_aug',W=320); print(S.Tp['ice'].mean(), S.Tp['Z'].max(), S.T['ice'].mean())
d=np.load('../cache/snowprep_glacierpeak_225_15.npz'); print(d['ice'].mean(), d.files)"

# output:
dem_glacierpeak_10_20.npz
dem_glacierpeak_11_15.npz
snowprep_glacierpeak_225_15.npz
0.09217317 0.0 0.03921803
0.09217317 ['Z', 'slope', 'aspect', 'hs_acc', 'hs_wind', 'hs_grav', 'sx', 'I', 'gully', 'shd', 'cornice', 'ice']
Bash: Find broken zero DEM caches
cd ~/work/pixelart-studies/elements/snowpeak/cache; for f in dem_*.npz lidar_*.npz; do python3 -c "
import numpy as np,sys; d=np.load('$f'); z=d['Z']; print('$f', float(np.nanmax(z)), float(np.nanmin(z)))"; done

# output:
dem_glacierpeak_10_20.npz 3212.388671875 706.7648315429688
dem_glacierpeak_11_15.npz 0.0 0.0
dem_gp_e_aug_15.npz 3212.372314453125 209.20973205566406
dem_olympus_10_20.npz 2424.385498046875 230.85812377929688
dem_olympus_hr_july_15.npz 2418.963623046875 -5.67874813079834
dem_rainier_10_20.npz 4391.0546875 758.4146728515625
dem_rainier_11_15.npz 4391.22509765625 715.712158203125
dem_rainier_ne_july_15.npz 4390.97216796875 24.317323684692383
dem_rainier_w_sept_15.npz 4391.619140625 137.3409881591797
dem_rainier_w_sept_30_30.npz 4388.26416015625 137.54061889648438
dem_sea_rainier_30.npz 4387.9033203125 -1.521433711051941
dem_sea_rainier_clear_30.npz 4390.439453125 -1.485598087310791
dem_stuart_10_20.npz 2864.2919921875 567.8618774414062
dem_stuart_11_15.npz 2862.989501953125 530.6980590820312
dem_stuart_lidarbase_5000_4.npz 2868.398681640625 1200.6253662109375
dem_stuart_s_july_10.npz 2867.971923828125 373.3896789550781
lidar_stuart_5000_4.npz 2868.58203125 1196.7115478515625
lidar_stuart_test_4.npz 2868.58203125 1503.27099609375

Also guard against it in code.

Bash: Clear bad caches, add guard, re-render GP
cd ~/work/pixelart-studies/elements/snowpeak; rm cache/dem_glacierpeak_11_15.npz cache/snowprep_glacierpeak_* cache/lidar_stuart_test_4.npz; python3 - <<'EOF'
s=open("src/dem.py").read()
s=s.replace('''    lc = sorted(glob.glob(DATA + "/landcover/ESA_WorldCover_*.tif"))''','''    if not np.nanmax(Z) > 1.0:
        raise RuntimeError(f"DEM mosaic for {key} is empty: no local or remote tiles read")
    lc = sorted(glob.glob(DATA + "/landcover/ESA_WorldCover_*.tif"))''')
open("src/dem.py","w").write(s)
EOF
cd src; GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py gp_e_aug sept 320 2100 _a4 2>&1 | tail -1

# output:
colours 59
Read: elements/snowpeak/stages/r_gp_e_aug_sept_a4_crop.png

elements/snowpeak/stages/r_gp_e_aug_sept_a4_crop.png 96. elements/snowpeak/stages/r_gp_e_aug_sept_a4_crop.png

Bash: Cap deposit depth, per-peak rock; re-render GP
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("snowmodel.py").read()
s=s.replace("gully_r=25.0, gully_depth=4.0, k_gully=6.0, pit_cap=25.0)","gully_r=25.0, gully_depth=4.0, k_gully=3.0, pit_cap=15.0, hs_cap=14.0)")
s=s.replace('''    rem = prep["hs_grav"] - melt
''','''    # avalanche-fed deposits are 5-15 m deep at most (deeper cells are DEM pit artefacts)
    rem = np.minimum(prep["hs_grav"], Pd.get("hs_cap", 14.0)) - melt
''')
open("snowmodel.py","w").write(s)
s=open("paint.py").read()
s=s.replace('"granite": np.array([0.15, 0.13, 0.105]),','"granite": np.array([0.15, 0.13, 0.105]), "dacite": np.array([0.27, 0.245, 0.22]),\n    "andesite": np.array([0.17, 0.15, 0.145]),')
open("paint.py","w").write(s)
s=open("fitcam.py").read()
s=s.replace("summit=(668/1280,158/960), fovs=(15,50),\n     crop=(0,20,320,150)),","summit=(668/1280,158/960), fovs=(15,50),\n     crop=(0,20,320,150), rock=\"dacite\"),")
s=s.replace("snap=200, crop=(0,50,320,150)),","snap=200, crop=(0,50,320,150), rock=\"andesite\"),")
open("fitcam.py","w").write(s)
EOF
grep -c dacite fitcam.py; rm ../cache/snowprep_*; GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py gp_e_aug sept 320 2100 _a5 2>&1 | tail -1

# output:
1
colours 58
Read: elements/snowpeak/stages/r_gp_e_aug_sept_a5.png

elements/snowpeak/stages/r_gp_e_aug_sept_a5.png 97. elements/snowpeak/stages/r_gp_e_aug_sept_a5.png

Bash: Temperature melt term, canopy hides snow; re-render
cd ~/work/pixelart-studies/elements/snowpeak/src; grep -n "melt = acc_sl" snowmodel.py; python3 - <<'EOF'
s=open("snowmodel.py").read()
s=s.replace('''    melt = acc_sl * np.clip(1 + Pd["k_melt"] * (z_sl - Z) / 1000, 0.05, None) * np.clip(I, 0.05, None) ** Pd["p_ins"]''','''    # melt energy: part air temperature (degree-days, independent of aspect), part sun.  Pure
    # insolation melt left deep avalanche cones in shaded valley bottoms all summer (not in photos).
    wT = Pd.get("w_temp", 0.5)
    melt = acc_sl * np.clip(1 + Pd["k_melt"] * (z_sl - Z) / 1000, 0.05, None) * \\
        (wT + (1 - wT) * np.clip(I, 0.05, None) ** Pd["p_ins"])''')
open("snowmodel.py","w").write(s)
s=open("paint.py").read()
s=s.replace('''    cov_s = np.where(sky_s, 0.0, np.maximum(Gs["cover"], (Gs["glacier"] > 0).astype(float)))''','''    cov_s = np.where(sky_s, 0.0, Gs["cover"] * (1 - P["canopy_hide"] * np.clip(Gs["trees"], 0, 1)))
    cov_s = np.maximum(cov_s, (Gs["glacier"] > 0).astype(float))''')
s=s.replace('''    fall_len=1.5,''','''    fall_len=1.5,
    canopy_hide=1.0,       # snow on the ground under forest is hidden by the canopy (set ~0.3 in winter)''')
open("paint.py","w").write(s)
EOF
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR NUMBA_NUM_THREADS=4 nice -n 10 python3 render.py gp_e_aug sept 320 2100 _a6 2>&1 | tail -1

# output:
217:    melt = acc_sl * np.clip(1 + Pd["k_melt"] * (z_sl - Z) / 1000, 0.05, None) * np.clip(I, 0.05, None) ** Pd["p_ins"]
colours 57
Read: elements/snowpeak/stages/r_gp_e_aug_sept_a6.png

elements/snowpeak/stages/r_gp_e_aug_sept_a6.png 98. elements/snowpeak/stages/r_gp_e_aug_sept_a6.png

Bash: Retune sky to measured values
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace("[0.035, 0.13, 0.34]","[0.045, 0.15, 0.47]").replace("[0.26, 0.40, 0.56]","[0.20, 0.37, 0.62]")
open("paint.py","w").write(s)
EOF
python3 palcheck.py | tail -1

# output:
sky zen [ 45.7   8.2 -44.2] hor [ 65.9  -2.8 -26.1]
Bash: Read Scene constructor
cd ~/work/pixelart-studies/elements/snowpeak/src; sed -n 1,35p scene.py

# output:
"""scene.py -- one view of a real peak: DEM, snow model, camera, per-pixel G-buffer.

    S = Scene("rainier_ne_july", W=320)        # uses cache/cams.json + fitcam.SHOTS
    S.snow("summer")                            # runs snowmodel on the peak sub-box
    G = S.gbuffer()                             # per-pixel fields for the painter
"""
import numpy as np, json, os
from scipy.ndimage import map_coordinates
import dem, cam, fitcam, snowmodel, sun as sunmod

HERE = os.path.dirname(os.path.abspath(__file__))


class Scene:
    def __init__(self, shot, W=320, H=None, cams=None, snow_half_km=11, snow_res=15, cam_override=None):
        self.shot = shot
        s = fitcam.SHOTS[shot]; self.s = s
        c = (cams or json.load(open(os.path.join(HERE, "../cache/cams.json"))))[shot]
        if cam_override:
            c = dict(c, **cam_override)
        ce, cn, pe, pn, bx = fitcam.box(s)
        self.T = dem.load_box(*[round(v) for v in bx], s["res"], tag=shot)
        from PIL import Image
        pw, ph = Image.open(s["photo"]).size
        self.W = W; self.H = H or round(W * ph / pw)
        self.C = cam.Camera(c["e"], c["n"], c["z"], c["az"], c["pitch"], c["hfov"], self.W, self.H, c["roll"])
        lat, lon, _ = dem.PEAKS[s["peak"]]
        self.lat, self.lon = lat, lon
        if s.get("lidar"):
            # 1 m lidar resampled to lidar_res over the peak sub-box; gaps filled from the 10 m DEM
            half = s.get("lidar_half_km", 4) * 1000; lr = s["lidar"]
            e, n = dem.ll2utm(lat, lon)
            box = (round(e - half), round(e + half), round(n - half), round(n + half))
            L = dem.load_lidar(*box, lr, dem.LIDAR[s["peak"]], f"{s['peak']}_{int(half)}")
            C10 = dem.load_box(*box, lr, tag=f"{s['peak']}_lidarbase_{int(half)}")
Bash: Generalize Scene for synthetic views, cache cast
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("scene.py").read()
s=s.replace('''    def __init__(self, shot, W=320, H=None, cams=None, snow_half_km=11, snow_res=15, cam_override=None):
        self.shot = shot
        s = fitcam.SHOTS[shot]; self.s = s
        c = (cams or json.load(open(os.path.join(HERE, "../cache/cams.json"))))[shot]
        if cam_override:
            c = dict(c, **cam_override)
        ce, cn, pe, pn, bx = fitcam.box(s)
        self.T = dem.load_box(*[round(v) for v in bx], s["res"], tag=shot)
        from PIL import Image
        pw, ph = Image.open(s["photo"]).size
        self.W = W; self.H = H or round(W * ph / pw)''','''    def __init__(self, shot, W=320, H=None, cams=None, snow_half_km=11, snow_res=15, cam_override=None,
                 spec=None, T=None):
        """shot: a key of fitcam.SHOTS (a photo-matched view), or any name with spec= (a dict with
        peak, res, pad, time, cam=(lat,lon) and a full camera in spec['camera'])."""
        self.shot = shot
        s = spec or fitcam.SHOTS[shot]; self.s = s
        c = s.get("camera") or (cams or json.load(open(os.path.join(HERE, "../cache/cams.json"))))[shot]
        if cam_override:
            c = dict(c, **cam_override)
        if T is not None:
            self.T = T
        else:
            ce, cn, pe, pn, bx = fitcam.box(s)
            self.T = dem.load_box(*[round(v) for v in bx], s["res"], tag=shot)
        if "photo" in s:
            from PIL import Image
            pw, ph = Image.open(s["photo"]).size
        else:
            pw, ph = s.get("aspect", (16, 9))
        self.W = W; self.H = H or round(W * ph / pw)''')
s=s.replace('''    def gbuffer(self, sun=None):
        G = cam.cast(self.T, self.C)''','''    def gbuffer(self, sun=None):
        if getattr(self, "_castG", None) is None:
            self._castG = cam.cast(self.T, self.C)
        G = dict(self._castG)''')
open("scene.py","w").write(s)
s=open("render.py").read()
s=s.replace('''def run(shot, season, W=320, k=3, season_over=None, light=None, P=None, sun=None, colour_kw=None):
    S = scene.Scene(shot, W=W * k)
    S.snow(season, season_over=season_over)
    Gs = S.gbuffer(sun=sun)''','''def run(shot, season, W=320, k=3, season_over=None, light=None, P=None, sun=None, colour_kw=None, S=None):
    S = S or scene.Scene(shot, W=W * k)
    S.snow(season, season_over=season_over)
    Gs = S.gbuffer(sun=sun)
    S.resample()''')
open("render.py","w").write(s)
EOF
grep -n "self.resample()" scene.py

# output:
94:        self.resample()
Write: elements/snowpeak/src/sheets.py
"""sheets.py -- labelled sweeps: peak x season x distance x hour/haze, from real terrain.

    python3 sheets.py stuart          # one sheet per peak: rows distance x season, cols light
    python3 sheets.py all

A synthetic view: stand `dist` km from the summit on the real bearing of a real vantage (Seattle
for Rainier, Ingalls/Longs Pass for Stuart, Hurricane Ridge for Olympus, Colchuck Lake for the
Dragontail ridge), on the highest ground within a few hundred metres, and look at the summit.
"""
import numpy as np, os, sys, time
from PIL import Image, ImageDraw, ImageFont
import dem, scene, render, paint, cam, sun as sunmod

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "../sheets")

PEAKSPEC = {
    # bearing FROM the peak TO the camera (deg), massif width (m) to frame, rock albedo, label
    "rainier": dict(bearing=330, width=17000, rock="andesite", label="Mount Rainier (4392 m) from the NW (Seattle side)"),
    "stuart": dict(bearing=200, width=7000, rock="granite", label="Mount Stuart (2870 m) from the SSW (Longs Pass side)"),
    "olympus": dict(bearing=40, width=12000, rock="rock", label="Mount Olympus (2432 m) from the NE (Hurricane Ridge side)"),
    "dragontail": dict(bearing=345, width=4500, rock="granite", label="Dragontail-Colchuck ridge (2694 m) from the N (Colchuck Lake side)"),
}
DISTS = (5, 20, 80)
SEASONS = {"sept": ((9, 15), "September minimum"), "spring": ((5, 25), "late May (spring)")}
# light variants: (label, clock hour or 'set-5min', haze_km, haze_scale_m, warm)
LIGHTS = [("clear noon", 13.0, 70.0, 2000.0, None),
          ("clear evening", "set-60", 70.0, 2000.0, None),
          ("hazy afternoon", 16.0, 18.0, 2600.0, None),
          ("alpenglow", "set-6", 55.0, 2000.0, (1.0, 0.62, 0.55))]


def sunset_hour(lat, lon, mo, d):
    for m in range(16 * 60, 22 * 60):
        az, el = sunmod.local(lat, lon, 2021, mo, d, m // 60, m % 60)
        if el < -0.83:
            return (m - 1) / 60.0
    return 20.0


def make_view(peak, dist_km, W=240, H=135, k=3):
    lat, lon, zs = dem.PEAKS[peak]
    pe, pn = dem.ll2utm(lat, lon)
    ps = PEAKSPEC[peak]
    b = np.radians(ps["bearing"]); d = dist_km * 1000
    ce, cn = pe + np.sin(b) * d, pn + np.cos(b) * d
    res = 10 if dist_km <= 6 else (20 if dist_km <= 25 else 50)
    pad = max(6000, ps["width"] * 0.8)
    e0, e1 = min(ce, pe) - pad, max(ce, pe) + pad
    n0, n1 = min(cn, pn) - pad, max(cn, pn) + pad
    T = dem.load_box(round(e0), round(e1), round(n0), round(n1), res, tag=f"sheet_{peak}_{dist_km}")
    # stand on the highest cell within r of the nominal point
    r = int(min(0.06 * d, 1500) / res)
    r0 = int((T["y0"] - cn) / res); c0 = int((ce - T["x0"]) / res)
    win = T["Z"][max(r0 - r, 0):r0 + r + 1, max(c0 - r, 0):c0 + r + 1]
    i, j = np.unravel_index(np.argmax(win), win.shape)
    cn = cn - (i - min(r, r0)) * res; ce = ce + (j - min(r, c0)) * res
    cz = float(win[i, j]) + 3
    dd = np.hypot(pe - ce, pn - cn)
    az = np.degrees(np.arctan2(pe - ce, pn - cn))
    el_s = np.degrees(np.arctan2(zs - cz - dd * dd / (2 * cam.R_EFF), dd))
    hfov = float(np.clip(np.degrees(2 * np.arctan(ps["width"] / 2 / dd)), 4, 75))
    vfov = hfov * H / W
    pitch = el_s - 0.22 * vfov          # summit ~28% down from the top of the frame
    spec = dict(peak=peak, res=res, time=(2021, 9, 15, 13, 0), rock=ps["rock"], aspect=(W, H),
                camera=dict(e=ce, n=cn, z=cz, az=az, pitch=pitch, hfov=hfov, roll=0.0))
    S = scene.Scene(f"sheet_{peak}_{dist_km}", W=W * k, H=H * k, spec=spec, T=T)
    return S, dict(dist=dd, hfov=hfov, cz=cz)


def cell(S, season, light, W, H, k=3):
    (mo, d), _ = SEASONS[season]
    lab, hour, haze_km, Ha, warm = light
    if isinstance(hour, str):
        hour = sunset_hour(S.lat, S.lon, mo, d) - float(hour.split("-")[1]) / 60
    h = int(hour); mi = int(round((hour - h) * 60)) % 60
    az, el = sunmod.local(S.lat, S.lon, 2021, mo, d, h, mi)
    se = "sept" if season == "sept" else "spring"
    so = dict(z_sl=2150) if season == "sept" else dict(z_sl=1250)
    S.snow(se, season_over=so)
    Gs = S.gbuffer(sun=(az, el)); S.resample(); Gs = S.G
    render.add_fall_line(Gs, S.C)
    G = render.native_from_super(Gs, k, W, H, S.C.hfov)
    render.add_haze_path(G, S.C.z, Ha)
    P = {}
    C = paint.index(G, Gs, k, P)
    hazy = haze_km < 30
    L = paint.light_state(el, az, haze_km=haze_km, warm=warm,
                          airlight=[0.42, 0.44, 0.47] if hazy else None,
                          sky_horizon=[0.40, 0.43, 0.48] if hazy else None,
                          sky_zenith=[0.16, 0.26, 0.45] if hazy else None)
    if warm is not None:   # alpenglow: the sky near the horizon goes rose, the zenith deepens
        L["horizon"] = np.array([0.42, 0.30, 0.33]); L["zenith"] = np.array([0.07, 0.10, 0.24])
        L["air"] = np.array([0.30, 0.25, 0.30]); L["skyamb"] = np.array([0.10, 0.10, 0.18])
    rgb, pal = paint.colour(C, G, L, rock_albedo=S.s["rock"])
    return rgb, (az, el), len(pal)


def font(sz):
    for f in ("/System/Library/Fonts/Supplemental/Arial.ttf", "/System/Library/Fonts/Helvetica.ttc"):
        if os.path.exists(f):
            return ImageFont.truetype(f, sz)
    return ImageFont.load_default()


def sheet(peak, W=240, H=135, scale=2):
    t0 = time.time()
    rows = []
    for dk in DISTS:
        S, info = make_view(peak, dk, W, H)
        for season in SEASONS:
            cells = []
            for light in LIGHTS:
                rgb, sunae, nc = cell(S, season, light, W, H)
                cells.append((rgb, light[0], sunae, nc))
            rows.append((dk, season, info, cells))
            print(peak, dk, season, f"{time.time() - t0:.0f}s", flush=True)
    cw, ch = W * scale, H * scale
    lw, top, gap = 190, 60, 6
    img = Image.new("RGB", (lw + len(LIGHTS) * (cw + gap), top + len(rows) * (ch + gap)), (24, 24, 28))
    dr = ImageDraw.Draw(img)
    dr.text((10, 8), PEAKSPEC[peak]["label"] + " -- real terrain (USGS 3DEP), snow model, painter", fill=(235, 235, 235), font=font(20))
    for ci, light in enumerate(LIGHTS):
        dr.text((lw + ci * (cw + gap) + 4, 36), f"{light[0]}  (haze {light[2]:.0f} km)", fill=(200, 200, 200), font=font(15))
    for ri, (dk, season, info, cells) in enumerate(rows):
        y = top + ri * (ch + gap)
        dr.text((8, y + 6), f"{dk} km", fill=(235, 235, 235), font=font(22))
        dr.text((8, y + 36), SEASONS[season][1], fill=(200, 200, 200), font=font(14))
        dr.text((8, y + 56), f"hfov {info['hfov']:.0f} deg\ncamera {info['cz']:.0f} m", fill=(150, 150, 150), font=font(12))
        for ci, (rgb, lab, (az, el), nc) in enumerate(cells):
            x = lw + ci * (cw + gap)
            img.paste(Image.fromarray(rgb).resize((cw, ch), Image.NEAREST), (x, y))
            dr.text((x + 4, y + ch - 16), f"sun {az:.0f}/{el:.0f}  {nc} col", fill=(255, 255, 255), font=font(11))
    os.makedirs(OUT, exist_ok=True)
    fn = os.path.join(OUT, f"A_{peak}_distance_season_light.png")
    img.save(fn)
    print("wrote", fn)


if __name__ == "__main__":
    ps = sys.argv[1:] or ["stuart"]
    if ps == ["all"]:
        ps = list(PEAKSPEC)
    for p in ps:
        sheet(p)
Bash: Build Stuart sheet
cd ~/work/pixelart-studies/elements/snowpeak/src; GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR NUMBA_NUM_THREADS=4 OPENBLAS_NUM_THREADS=1 nice -n 10 python3 sheets.py stuart > ../sheets/run_stuart.log 2>&1

# output:
Command running in background with ID: b0xemzlfi. Output is being written to: (tmp) You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains ~/work/pixelart-studies/manager/briefs; directory changes made by the backgrounded command do not apply to subsequent commands.
Bash: Read index function
cd ~/work/pixelart-studies/elements/snowpeak/src; grep -n "^def index" -A12 paint.py

# output:
289:def index(G, Gs, k, P=None):
290-    P = dict(DEFAULT, **(P or {}))
291-    mat, info = material(Gs, k, P)
292-    fam, step, _ = family_step(mat, G, P)
293-    band = bands(G, P)
294-    step = collapse(fam, step, band, mat, P)
295-    fam[mat == M["sky"]] = 1; step[mat == M["sky"]] = 0
296-    return dict(mat=mat, fam=fam, step=step, band=band, **info)
297-
298-
299-# ------------------------------------------------------------------ 6: palette = light
300-ALBEDO = {  # linear RGB, measured/estimated (LESSONS part 1 has sources)
301-    "snow": np.array([0.82, 0.815, 0.80]), "firn": np.array([0.72, 0.73, 0.74]),
Bash: Add crevasse and cornice marks
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("paint.py").read()
s=s.replace('''    step = collapse(fam, step, band, mat, P)
    fam[mat == M["sky"]] = 1; step[mat == M["sky"]] = 0
    return dict(mat=mat, fam=fam, step=step, band=band, **info)''','''    step = collapse(fam, step, band, mat, P)
    marks(mat, fam, step, G, Gs, k, P)
    fam[mat == M["sky"]] = 1; step[mat == M["sky"]] = 0
    return dict(mat=mat, fam=fam, step=step, band=band, **info)


# ------------------------------------------------------------------ marks: crevasses, cornices
def crevasse_mask(Gs, P):
    """transverse crevasses on glacier ice where it steepens (extending flow): open cracks
    crev_open m wide at spacing crev_space m (30-96 m measured on temperate glaciers), running across
    the flow.  Phase = position along the local flow direction; a slow warp bends the lines into
    arcs, as crevasses bow downglacier toward the margins."""
    n = Gs["normal"]
    fx, fy = n[..., 0], n[..., 1]
    fl = np.hypot(fx, fy) + 1e-9
    fx, fy = fx / fl, fy / fl
    e, nn = Gs["e"], Gs["n"]
    warp = 18 * np.sin(e / 230.0) * np.cos(nn / 310.0)
    ph = (e * fx + nn * fy + warp) / P["crev_space"]
    fr = ph - np.floor(ph)
    sl = Gs["slope"]
    on = (Gs["glacier"] > 0) & (sl > P["crev_slope"][0]) & (sl < P["crev_slope"][1]) & ~Gs["sky"]
    return on & (fr < P["crev_open"] / P["crev_space"])


def marks(mat, fam, step, G, Gs, k, P):
    if P["crevasses"]:
        cm = down(crevasse_mask(Gs, P).astype(float), k)
        onice = np.isin(mat, [M["snow"], M["ice"]])
        c = onice & (cm > P["crev_min_frac"])
        step[c] = 0; fam[c] = 0
        # the crack's lip catches light: the pixel above a crevasse (uphill on screen) goes lit top
        lip = np.roll(c, -1, 0) & onice & ~c & (fam == 1)
        ns, nl = NSTEP["snow"]
        step[lip] = nl - 1
    if P["cornices"] and "cornice" in G:
        cor = (G["cornice"] > 0.3) & (mat != M["sky"])
        mat[cor] = M["snow"]; fam[cor] = 1; step[cor] = NSTEP["snow"][1] - 1
        below = np.roll(cor, 1, 0) & ~cor & (mat != M["sky"])
        mat[below] = M["snow"]; fam[below] = 0; step[below] = 0          # the curl's own shadow''')
s=s.replace('''    canopy_hide=1.0,''','''    canopy_hide=1.0,
    crevasses=True, crev_space=45.0, crev_open=6.0, crev_slope=(11, 38), crev_min_frac=0.34,
    cornices=True,''')
open("paint.py","w").write(s)
EOF
grep -n '"slope"' render.py | head -3

# output:
18:    for key in ("ndl", "sunlit", "curv", "z", "cover", "hs", "cornice", "trees", "bare", "slope", "sx", "I"):
Bash: Check sheet progress
cat ~/work/pixelart-studies/elements/snowpeak/sheets/run_stuart.log | grep -v -i "warn\|nanmean"

# output:
stuart 5 sept 25s
stuart 5 spring 29s
Bash: Allow synthetic terrain as snow box
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("scene.py").read()
s=s.replace('''        if s.get("lidar"):''','''        if s.get("synthetic"):
            self.Tp = self.T
        elif s.get("lidar"):''')
open("scene.py","w").write(s)
EOF
grep -n "synthetic" scene.py

# output:
38:        if s.get("synthetic"):
Write: elements/snowpeak/src/vocab.py
"""vocab.py -- close-ups of the snow-on-rock vocabulary, on small synthetic terrains run through the
SAME snow model and painter as the real peaks (so the close rules and the far rules are one rule).

  gully fill   a 55-deg face cut by parallel gullies that branch upward (Imhof fig. 170)
  ledge bands  a stepped face: 70-deg risers, 20-deg treads (bedding / sheeting joints)
  cornice      an asymmetric ridge, wind across it: lip on the lee crest, scoured windward slope
  glacier edge a glacier tongue in a trough: lateral moraines, crevasse bands where it steepens,
               grey bare ice below the firn line

    python3 vocab.py            -> sheets/B_vocabulary_scale_season.png
"""
import numpy as np, os
from PIL import Image, ImageDraw
from scipy import ndimage as ndi
import scene, render, paint, sheets as SH, sun as sunmod

HERE = os.path.dirname(os.path.abspath(__file__))


def grid(size_m, res):
    n = int(size_m / res)
    y, x = np.mgrid[0:n, 0:n].astype(float) * res          # x east, y south (row)
    return x, y, n


def noise(n, res, scale_m, seed, amp):
    rng = np.random.default_rng(seed)
    a = ndi.gaussian_filter(rng.normal(size=(n, n)), scale_m / res)
    return a / (a.std() + 1e-9) * amp


def terrain(kind, size_m=600.0, res=1.0, seed=3):
    x, y, n = grid(size_m, res)
    ice = np.zeros((n, n), np.float32)
    if kind == "gully":
        # face rising to the north (row 0 = north = high), 55 deg, gullies spaced ~45 m whose count
        # doubles upward (poplar branching: channels split toward their heads)
        h = (size_m - y) * np.tan(np.radians(52))
        lam = 60 - 30 * (1 - y / size_m)
        wob = noise(n, res, 40, seed, 8)
        ph = (x + wob) / lam
        rib = np.abs(np.sin(np.pi * ph)) ** 0.7
        Z = h + 14 * rib + noise(n, res, 6, seed + 1, 1.5)
        Z += 400 * np.clip((y - 0.8 * size_m) / (0.2 * size_m), 0, 1) ** 2 * 0   # (flat base omitted)
    elif kind == "ledge":
        H = (size_m - y) * np.tan(np.radians(48))
        step = 12.0
        f = (H / step) % 1.0
        # within each 12 m of height: 70 % riser (steep), 30 % tread (gentle): remap height
        Z = H + step * 0.35 * np.where(f < 0.3, f / 0.3 - f, (1 - f) * 0.3 / 0.7 - (f - 0.3) * 0)
        Z = ndi.gaussian_filter(Z, 0.8) + noise(n, res, 25, seed, 3)
    elif kind == "cornice":
        # ridge running E-W; windward (south) 28 deg, lee (north) 50 deg, crest undulating
        c = size_m * 0.45 + noise(n, res, 60, seed, 1)[:, :1].repeat(n, 1) * 0 + 12 * np.sin(x / 70)
        d = y - c
        Z = np.where(d > 0, -d * np.tan(np.radians(28)), d * np.tan(np.radians(50)) * -1 * -1)
        Z = np.where(d > 0, 300 - d * np.tan(np.radians(28)), 300 + d * np.tan(np.radians(50)))
        Z = ndi.gaussian_filter(Z, 1.5) + noise(n, res, 20, seed, 2)
    elif kind == "glacier":
        # a trough falling to the south; glacier tongue down its axis, steepening in the middle (icefall)
        cx = size_m / 2 + 25 * np.sin(y / 160)
        dx = x - cx
        long = (size_m - y) * np.tan(np.radians(9)) + 60 * np.clip((y - 250) / 120, 0, 1)
        Z = long + 0.004 * dx ** 2 * 3 + noise(n, res, 30, seed, 2)
        halfw = 110 - 50 * y / size_m
        tongue = np.abs(dx) < halfw
        Z = np.where(tongue, Z - 6 * (1 - (dx / halfw) ** 2), Z)
        # lateral moraines: sharp ridges just outside the ice edge
        Z += 9 * np.exp(-((np.abs(dx) - halfw - 8) / 6) ** 2)
        ice[tongue & (y < size_m * 0.85)] = 1.0
        Z = Z + 300
    Z = Z - Z.min() + 1800
    return dict(Z=Z.astype(np.float32), ice=ice, trees=np.zeros_like(ice), bare=np.ones_like(ice) * 0.5,
                x0=0.0, y0=float(n * res), res=res)


def view(kind, T, W=160, H=120, k=3, dist_m=None, az=0.0, elev=25.0):
    n = T["Z"].shape[0]; size = n * T["res"]
    tx, ty = size / 2, T["y0"] - size / 2                   # target: centre of the patch
    tz = float(T["Z"][n // 2, n // 2])
    d = dist_m or size * 1.1
    a = np.radians(az)                                      # camera on bearing az FROM the target
    ce, cn = tx + np.sin(a) * d * np.cos(np.radians(elev)), ty + np.cos(a) * d * np.cos(np.radians(elev))
    cz = tz + d * np.sin(np.radians(elev))
    vaz = np.degrees(np.arctan2(tx - ce, ty - cn))
    pitch = -np.degrees(np.arctan2(cz - tz, np.hypot(tx - ce, ty - cn)))
    hfov = np.degrees(2 * np.arctan(size * 0.42 / d))
    spec = dict(peak="stuart", res=T["res"], rock="granite", aspect=(W, H), synthetic=True,
                time=(2021, 9, 15, 13, 0), camera=dict(e=ce, n=cn, z=cz, az=vaz, pitch=pitch, hfov=hfov, roll=0))
    return scene.Scene(f"vocab_{kind}", W=W * k, H=H * k, spec=spec, T=T)


VIEWS = {  # camera bearing from the patch, elevation angle, wind direction (from)
    "gully": (180, 18, 225), "ledge": (180, 15, 225), "cornice": (70, 12, 200), "glacier": (180, 35, 225),
}


def cell(S, season, W, H, k=3, wind=225):
    (mo, d), _ = SH.SEASONS[season]
    az, el = sunmod.local(47.47, -120.9, 2021, mo, d, 13, 0)
    fn = os.path.join(HERE, f"../cache/snowprep_{S.shot}.npz")
    import snowmodel
    if not hasattr(S, "prep"):
        S.prep = snowmodel.prepare(S.Tp, wind, None, 47.47, -120.9)
    se = dict(snowmodel.SEASONS["sept" if season == "sept" else "spring"],
              z_sl=2150 if season == "sept" else 1650)
    S.S = snowmodel.apply(S.prep, se)
    Gs = S.gbuffer(sun=(az, el)); S.resample(); Gs = S.G
    render.add_fall_line(Gs, S.C)
    G = render.native_from_super(Gs, k, W, H, S.C.hfov)
    render.add_haze_path(G, S.C.z, 2000)
    C = paint.index(G, Gs, k, {})
    L = paint.light_state(el, az, haze_km=70)
    rgb, pal = paint.colour(C, G, L, rock_albedo="granite")
    return rgb


def run(W=160, H=120, scale=3):
    rows = []
    for kind in ("gully", "ledge", "cornice", "glacier"):
        for res, lab in ((1.0, "1 m/px region"), (0.25, "0.25 m/px landscape")):
            size = 600 if res == 1.0 else 160
            T = terrain(kind, size, res)
            b, e, wind = VIEWS[kind]
            S = view(kind + str(res), T, W, H, az=b, elev=e)
            cells = [cell(S, s, W, H, wind=wind) for s in ("sept", "spring")]
            rows.append((kind, lab, cells))
            print(kind, res, flush=True)
    cw, ch = W * scale, H * scale
    lw, top, gap = 170, 44, 6
    img = Image.new("RGB", (lw + 2 * (cw + gap), top + len(rows) * (ch + gap)), (24, 24, 28))
    dr = ImageDraw.Draw(img)
    dr.text((10, 8), "Snow-on-rock vocabulary: synthetic terrain through the same snow model and painter", fill=(235, 235, 235), font=SH.font(18))
    for ci, s in enumerate(("September minimum", "late spring")):
        dr.text((lw + ci * (cw + gap) + 4, 28), s, fill=(200, 200, 200), font=SH.font(14))
    for ri, (kind, lab, cells) in enumerate(rows):
        y = top + ri * (ch + gap)
        dr.text((8, y + 8), kind, fill=(235, 235, 235), font=SH.font(20))
        dr.text((8, y + 36), lab, fill=(180, 180, 180), font=SH.font(13))
        for ci, rgb in enumerate(cells):
            img.paste(Image.fromarray(rgb).resize((cw, ch), Image.NEAREST), (lw + ci * (cw + gap), y))
    fn = os.path.join(HERE, "../sheets/B_vocabulary_scale_season.png")
    img.save(fn); print("wrote", fn)


if __name__ == "__main__":
    run()
Bash: Check sheet log
grep -v -i "warn\|nanmean" ~/work/pixelart-studies/elements/snowpeak/sheets/run_stuart.log | tail -5

# output:
stuart 20 sept 37s
stuart 20 spring 42s
stuart 80 sept 63s
stuart 80 spring 69s
wrote ~/work/pixelart-studies/elements/snowpeak/src/../sheets/A_stuart_distance_season_light.png
Read: elements/snowpeak/sheets/A_stuart_distance_season_light.png

elements/snowpeak/sheets/A_stuart_distance_season_light.png 99. elements/snowpeak/sheets/A_stuart_distance_season_light.png

Bash: Visibility-checked standpoints; rebuild Stuart sheet
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("sheets.py").read()
a=s.index("    # stand on the highest cell within r of the nominal point"); b=s.index("    dd = np.hypot(pe - ce, pn - cn)")
s=s[:a]+'''    # stand where the summit is visible: among local high points near the nominal point (and a
    # little further along the bearing), keep the first whose ray to the summit is not blocked
    best = None
    for push in (0.0, 0.05, -0.05, 0.1, 0.15, 0.2, 0.3):
        for side in (0.0, 0.04, -0.04, 0.08, -0.08):
            e1_ = ce + np.sin(b) * d * push + np.cos(b) * d * side
            n1_ = cn + np.cos(b) * d * push - np.sin(b) * d * side
            r = int(min(0.03 * d, 800) / res)
            r0 = int((T["y0"] - n1_) / res); c0 = int((e1_ - T["x0"]) / res)
            if r0 - r < 0 or c0 - r < 0 or r0 + r >= T["Z"].shape[0] or c0 + r >= T["Z"].shape[1]:
                continue
            win = T["Z"][r0 - r:r0 + r + 1, c0 - r:c0 + r + 1]
            i, j = np.unravel_index(np.argmax(win), win.shape)
            e2, n2, z2 = e1_ + (j - r) * res, n1_ - (i - r) * res, float(win[i, j]) + 3
            v = np.array([pe - e2, pn - n2, 0.0]); dd_ = np.hypot(v[0], v[1])
            v[2] = zs - 25 - z2 - dd_ * dd_ / (2 * cam.R_EFF)
            D = (v / np.linalg.norm(v))[None, None].astype(np.float64)
            t = np.zeros((1, 1))
            cam._cast(T["Z"].astype(np.float32), T["x0"], T["y0"], T["res"], e2, n2, z2, D, 3e5, cam.R_EFF, t)
            hit = t[0, 0]
            if hit < 0 or hit > np.linalg.norm(v) * 0.97:
                best = (e2, n2, z2); break
        if best:
            break
    if best is None:
        best = (e2, n2, z2)
    ce, cn, cz = best
'''+s[b:]
open("sheets.py","w").write(s)
EOF
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR NUMBA_NUM_THREADS=4 nice -n 10 python3 sheets.py stuart 2>&1 | grep -v -i "warn\|nanmean" | tail -2

# output:
stuart 80 spring 69s
wrote ~/work/pixelart-studies/elements/snowpeak/src/../sheets/A_stuart_distance_season_light.png
Read: elements/snowpeak/sheets/A_stuart_distance_season_light.png

elements/snowpeak/sheets/A_stuart_distance_season_light.png 100. elements/snowpeak/sheets/A_stuart_distance_season_light.png

Bash: Fix visibility tolerance and rebuild
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("sheets.py").read()
s=s.replace('''            hit = t[0, 0]
            if hit < 0 or hit > np.linalg.norm(v) * 0.97:
                best = (e2, n2, z2); break''','''            hit = t[0, 0]
            L3 = np.linalg.norm(v)
            frac = 1.0 if hit < 0 else hit / L3
            if frac > L3 and False:
                pass
            if hit < 0 or hit > L3 - max(400, 0.01 * L3):
                best = (e2, n2, z2); break
            if best_f is None or frac > best_f[0]:
                best_f = (frac, (e2, n2, z2))''')
s=s.replace('''    best = None
    for push in''','''    best = None; best_f = None
    for push in''')
s=s.replace('''    if best is None:
        best = (e2, n2, z2)''','''    if best is None:
        best = best_f[1]
        print("  no clear standpoint; best visible fraction", round(best_f[0], 3))''')
open("sheets.py","w").write(s)
EOF
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR NUMBA_NUM_THREADS=4 nice -n 10 python3 sheets.py stuart 2>&1 | grep -v -i "warn\|nanmean" | tail -3

# output:
stuart 80 sept 127s
stuart 80 spring 144s
wrote ~/work/pixelart-studies/elements/snowpeak/src/../sheets/A_stuart_distance_season_light.png
Read: elements/snowpeak/sheets/A_stuart_distance_season_light.png

elements/snowpeak/sheets/A_stuart_distance_season_light.png 101. elements/snowpeak/sheets/A_stuart_distance_season_light.png

Bash: Debug sheet DEM and summit
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 -c "
import numpy as np, dem, cam, sheets
peak='stuart'; lat,lon,zs=dem.PEAKS[peak]; pe,pn=dem.ll2utm(lat,lon)
T=dem.load_box(0,0,0,0,10,tag='sheet_stuart_5')
print(T['Z'].shape, T['x0'],T['y0'], T['Z'].max())
r0=int((T['y0']-pn)/10); c0=int((pe-T['x0'])/10); print('peak cell z',T['Z'][r0-3:r0+4,c0-3:c0+4].max())
" 2>&1 | tail -3

# output:
(1670, 1371) 650339.0 5266095.0 2868.8833
peak cell z 2868.8833
Bash: View visibility code
cd ~/work/pixelart-studies/elements/snowpeak/src; grep -n "hit = t\[0, 0\]" -B4 -A10 sheets.py

# output:
67-            v[2] = zs - 25 - z2 - dd_ * dd_ / (2 * cam.R_EFF)
68-            D = (v / np.linalg.norm(v))[None, None].astype(np.float64)
69-            t = np.zeros((1, 1))
70-            cam._cast(T["Z"].astype(np.float32), T["x0"], T["y0"], T["res"], e2, n2, z2, D, 3e5, cam.R_EFF, t)
71:            hit = t[0, 0]
72-            L3 = np.linalg.norm(v)
73-            frac = 1.0 if hit < 0 else hit / L3
74-            if frac > L3 and False:
75-                pass
76-            if hit < 0 or hit > L3 - max(400, 0.01 * L3):
77-                best = (e2, n2, z2); break
78-            if best_f is None or frac > best_f[0]:
79-                best_f = (frac, (e2, n2, z2))
80-        if best:
81-            break
Bash: Trace standpoint candidates
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF' 2>&1 | grep -v -i warn | tail -20
import numpy as np, dem, cam
import sheets
src=open("sheets.py").read()
src=src.replace("            hit = t[0, 0]","            hit = t[0, 0]; print('cand',push,side,round(z2),round(hit),round(np.linalg.norm(v)))")
exec(compile(src.split("def cell(")[0],"x","exec"))
S,info=make_view("stuart",5)
print(info)
EOF

# output:
cand 0.0 0.0 2011 4975 5002
{'dist': 4931.727005913275, 'hfov': 70.72592365330635, 'cz': 2010.7730712890625}
Bash: Require open foreground; rebuild sheet
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("sheets.py").read()
s=s.replace('''            if hit < 0 or hit > L3 - max(400, 0.01 * L3):''','''            # and the foreground must fall away: a ray aimed 0.35 vfov below the summit should
            # reach at least 30% of the way (no knoll filling the lower frame)
            vv = v / L3; dip = np.radians(0.35 * np.degrees(2 * np.arctan(PEAKSPEC[peak]["width"] / 2 / dd_)) * H / W)
            hz = np.hypot(vv[0], vv[1]); el0 = np.arctan2(vv[2], hz) - dip
            D2 = np.array([vv[0] / hz * np.cos(el0), vv[1] / hz * np.cos(el0), np.sin(el0)])[None, None]
            t2 = np.zeros((1, 1))
            cam._cast(T["Z"].astype(np.float32), T["x0"], T["y0"], T["res"], e2, n2, z2, D2, 3e5, cam.R_EFF, t2)
            fg_ok = t2[0, 0] < 0 or t2[0, 0] * np.cos(el0) > 0.3 * dd_
            if (hit < 0 or hit > L3 - max(400, 0.01 * L3)) and fg_ok:''')
open("sheets.py","w").write(s)
EOF
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR NUMBA_NUM_THREADS=4 nice -n 10 python3 sheets.py stuart 2>&1 | grep -v -i "warn\|nanmean" | tail -4

# output:
stuart 20 spring 25s
stuart 80 sept 33s
stuart 80 spring 42s
wrote ~/work/pixelart-studies/elements/snowpeak/src/../sheets/A_stuart_distance_season_light.png
Read: elements/snowpeak/sheets/A_stuart_distance_season_light.png

elements/snowpeak/sheets/A_stuart_distance_season_light.png 102. elements/snowpeak/sheets/A_stuart_distance_season_light.png

Bash: Wider standpoint search
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("sheets.py").read()
s=s.replace('''    for push in (0.0, 0.05, -0.05, 0.1, 0.15, 0.2, 0.3):
        for side in (0.0, 0.04, -0.04, 0.08, -0.08):''','''    cands = [(p_, s_) for p_ in (0.0, 0.05, -0.05, 0.1, 0.15, 0.2, 0.3) for s_ in (0.0, 0.04, -0.04, 0.08, -0.08)]
    cands += [(p_, s_) for p_ in (-0.1, 0.0, 0.1, 0.25) for s_ in (0.15, -0.15, 0.25, -0.25, 0.35, -0.35)]
    for push, side in cands:
        if True:''')
s=s.replace('''        if best:
            break
    if best is None:''','''            if best:
                break
    if best is None:''')
open("sheets.py","w").write(s)
EOF
sed -n "/for push, side in cands/,/if best is None/p" sheets.py | head -50 | tail -8

# output:
fg_ok = t2[0, 0] < 0 or t2[0, 0] * np.cos(el0) > 0.3 * dd_
            if (hit < 0 or hit > L3 - max(400, 0.01 * L3)) and fg_ok:
                best = (e2, n2, z2); break
            if best_f is None or frac > best_f[0]:
                best_f = (frac, (e2, n2, z2))
            if best:
                break
    if best is None:
Bash: Larger box, rebuild Stuart sheet
cd ~/work/pixelart-studies/elements/snowpeak/src; sed -i '' 's/    pad = max(6000, ps\["width"\] \* 0.8)/    pad = max(6000, ps["width"] * 0.8, 0.4 * d)/' sheets.py; rm -f ../cache/dem_sheet_*; GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR NUMBA_NUM_THREADS=4 nice -n 10 python3 sheets.py stuart 2>&1 | grep -v -i "warn\|nanmean" | tail -3

# output:
stuart 80 sept 90s
stuart 80 spring 100s
wrote ~/work/pixelart-studies/elements/snowpeak/src/../sheets/A_stuart_distance_season_light.png
Bash: Crop 20 km rows
cd ~/work/pixelart-studies/elements/snowpeak/sheets; python3 -c "
from PIL import Image; im=Image.open('A_stuart_distance_season_light.png'); w,h=im.size; im.crop((0,int(h*0.35),w,int(h*0.68))).save('(tmp))" 2>/dev/null || python3 -c "
from PIL import Image; im=Image.open('A_stuart_distance_season_light.png'); w,h=im.size; im.crop((0,int(h*0.35),w,int(h*0.68))).save('crop20_tmp.png')"; ls

# output:
A_stuart_distance_season_light.png
run_stuart.log
Read: (tmp)

(tmp) 103. (tmp)

Bash: Build remaining sheets and vocab
cd ~/work/pixelart-studies/elements/snowpeak/src; (GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR NUMBA_NUM_THREADS=4 OPENBLAS_NUM_THREADS=1 nice -n 10 python3 sheets.py rainier olympus dragontail; GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR NUMBA_NUM_THREADS=4 nice -n 10 python3 vocab.py) > ../sheets/run_all.log 2>&1

# output:
Command running in background with ID: bw042diih. Output is being written to: (tmp) You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains ~/work/pixelart-studies/manager/briefs; directory changes made by the backgrounded command do not apply to subsequent commands.
Write: elements/snowpeak/LESSONS.md
# Snow peaks at distance: from real terrain to pixels

A student notebook. Subject: snowy mountains seen from miles away (Rainier, Stuart, Glacier Peak,
Olympus, the Dragontail ridge): how snow really lies on steep rock, how seasons and glaciers move it,
and how a snow peak reads through 5, 20 and 80 km of air. Then a painter that draws it, checked
against photographs of named peaks with fitted cameras.

**Where this stands, plainly.** The *form* half works: every picture here is cast from the real
USGS terrain, from the photographer's real position (camera fitted to the photo's skyline), so the
silhouettes, the big light and dark masses and where the snow sits are the real mountain's, not an
invented one. Glacier Peak (12 km, late August) and Rainier from the northeast (21 km, July) read as
those mountains beside their photos. The snow model is a stack of published processes with
numbers, portable to a simulation. The *surface* half is not at the Ferrari bar: lit snow is a
little too flat and cold, near forest reads as camouflage blobs, rock texture is a value pattern
without the structural strokes Imhof and Ruskin draw, and the vocabulary close-ups (cornice,
crevasses) are the thinnest part. Details per stage, and the open list at the end.

**The studio shoulders I stand on.**

- *Imhof, Cartographic Relief Presentation* (library/terrain): aerial perspective as contrast
  loss that must stay strongest on the high ground (ch. 9 §8); shading on snow keeps the same
  strength as on rock, or snow ridges read as plateaus (§18-19); the "normal group of forms" of a
  rock wall: parallel gullies and ribs, poplar-tree branching, debris cones at 30-40 deg (ch. 11).
- *Ruskin, Modern Painters IV* (library/rock): the value problem of snow mountains (lit distant rock
  is paler than white paper yet black against its snow), and rock ribs showing "in black points like
  arrow-heads through the snow" where avalanche channels leave them projecting.
- Studio students: the observation student's three-panel sheet and ramp coordinate with a jump at
  the terminator; the aerial student's band collapse, cavity tone and "far snow is salt-and-pepper,
  blur before you threshold"; the ground student's quantile keying of each family; the rock
  student's camera fit to a photo skyline and "value economy taken from the photo"; the snow
  student's "on mountains the rock defines the snow".
- *Tyndall* and *Lobeck* I read for glacier structure and cirque form; they inform part 1 but no
  code depends on them directly.


cd src
python3 fitcam.py &lt;shot&gt;                       # fit a photo&#x27;s camera (heading/pitch/fov/roll) from its GPS
python3 render.py gp_e_aug sept 320 2100 _x    # photo | painting pair -&gt; stages/r_&lt;shot&gt;_&lt;season&gt;_x.png
python3 sheets.py stuart|rainier|olympus|dragontail|all    # sheets/A_&lt;peak&gt;_distance_season_light.png
python3 vocab.py                               # sheets/B_vocabulary_scale_season.png
Environment: `GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR NUMBA_NUM_THREADS=4 OPENBLAS_NUM_THREADS=1 nice -n 10`. Memory: a 320x214 pair at 3x supersampling peaks at ~2.5 GB RSS with 4 m lidar (Stuart), ~1.2 GB without; the 80 km sheet rows read a 180 x 110 km DEM box at 50 m (~8 M cells), fine. DEM tiles are read over HTTP from USGS's cloud-optimized GeoTIFFs (the local copies were pruned for disk), then cached in `cache/` as small npz boxes (the folder stays under ~300 MB). --- ## Part 1. Observation: how snow lies on a real mountain ### 1.1 The one idea: gravity and sun decide, wind and glaciers edit Snow falls nearly evenly (more with height); then it moves. From far away, the pattern you see is the *residue* of three movements, in order of how much they matter for a picture: 1. **Gravity sheds snow from steep ground** and packs it into gullies, onto ledges and into bowls and slope-foot cones. This is why a summer peak is dark rock laced with white *lines* (couloirs) and *bands* (ledges), not a white cap with a clean snowline. 2. **The sun melts unevenly**: south and west faces lose snow weeks before north faces; a gully in its own shadow keeps snow long after the rib beside it is bare. 3. **Wind** scours crests and exposed windward slopes and loads lee slopes, building cornices on lee crests. On Rainier it is the difference between a bare Liberty Cap edge and loaded bowls. 4. **Glaciers** are where snow survives from year to year; their upper parts stay white (firn) all summer, their lower parts turn grey bare ice by August, cut by crevasses where the ice steepens. ### 1.2 Numbers a simulation can use | process | rule | numbers | source | |---|---|---|---| | accumulation with height | HS(z) = g (z - z0), capped | g ~ 0.45 m depth per 100 m above z0 ~ 300 m; Paradise (1,650 m) peak depth typically 4-6 m, record 9.3 m (1956); cap 9-14 m | NPS/NRCS SNOTEL 679 (Paradise); my fit | | gravity: snow holding depth | SHD(slope) = c exp(-a slope); excess slides to lower neighbours (multiple flow direction), top-down | Bernhardt & Schulz 2010 default: 3.08 / 1.11 / 0.45 / 0.15 m at 30 / 45 / 60 / 75 deg. OGGM Snowslide default c = 145 m, a = 0.14 /deg: 2.2 / 0.27 / 0.03 m. **Summer photos of Rainier's walls (bare above ~45 deg) match the OGGM set**, the B&S set left the Willis Wall white | Bernhardt & Schulz 2010 GRL; OGGM/Snowslide `snd_max_exponential`; my photo test | | steep rough rock still holds some | ledges and cracks keep a patchy cover on 50-70 deg rock | snow depth did not fall with angle up to 70 deg in snow pits on 50-65 deg Alpine walls; insulation starts above 0.2 m | Haberkorn et al. 2015/2017 (Gemsstock) | | couloirs hold more than open slopes | side walls support a wedge of snow | holding depth x (1 + k gully), k ~ 3, gully = concavity at 25 m radius / 4 m | my fit to Stuart (photo: every white line is a gully) | | avalanche deposits | cones at gully mouths, 5-15 m deep, last into July at 1,500 m | alpha angle (start to runout) ~ 33 deg mean, 18-49 deg | Lied & Bakkehoi 1980 | | wind exposure | Winstral Sx: max upwind slope angle within 300 m; HS x clip(1 + 0.035 Sx, 0.15, 2.5) | crests (Sx < 0) scoured, lees (Sx > 0) loaded | Winstral, Elder & Davis 2002 | | cornices | lee crest, exposed approach, drop > 35 deg downwind, enough snow | grow fastest at 1-2 x the threshold wind speed for drift (~1.4x best) | Vogel et al. 2012; Eckerstorfer 2013; Yu 2022-23 | | melt | melt(z) = A(z_sl) (1 + 0.9 (z_sl - z)/km) (0.5 + 0.5 (I/I_flat)^0.8) | half air temperature (aspect-blind), half sun (clear-sky insolation with cast shadows, May-Aug) | degree-day + radiation index models (Hock 1999 in spirit); my fit | | season snowline (flat, undisturbed) | z_sl | late May ~1,250 m; mid-July ~1,700-1,900 m; September minimum ~2,100-2,200 m (Cascades); Olympics ~200 m lower | my calibration against the dated photos below | | glacier firn line (ELA) | above: white firn all year; below: grey bare ice by August | Rainier: Nisqually ELA ~3,180 m, Emmons ~2,800 m (2002-08); South Cascade 1,900 m; Blue Glacier (Olympus) 1,505-2,030 m, mean firn limit ~1,725 m | Hekkers 2010 (PSU); Pelto/NCGCP; UW Blue Glacier record | | glacier extents | the map, not a model | ESA WorldCover 2021 class 70 (snow & ice) at 10 m; Rainier 78.8 km2 (2015), down 52 km2 since 1896; Olympics 25.3 km2 (2015, half of 1900) | ESA WorldCover; Beason 2017; Fountain et al. 2022 | | crevasses | transverse, where the ice steepens (extending flow) | spacing 30-96 m on temperate glaciers, ~2.8 x depth; depth mostly < 30 m | J. Glac. "Primary transverse crevasses"; Colgan et al. | | steep ice | faces steeper than ~42 deg inside a mapped glacier are rock walls or hanging ice | (Willis Wall, Liberty Ridge) | my photo check | ### 1.3 Light and air (measured on the fitted photos) With a fitted camera every photo pixel has a known distance and material, so the photos become instruments (`src/measure.py`, `src/boxes.py`; raw numbers in `cache/measure.json`, `cache/boxes_out.json`): | photo | distance | what | L* (b*) | |---|---|---|---| | Rainier from Silver Queen, clear July noon | 21 km | lit snow / rock wall / forest at 5 km / sky at peak / sky high | 91 (+5) / 36-54 (-11) / 21 (-18) / 64 (-19) / 47 (-29) | | Glacier Peak from the east, Aug 30 09:20 | 12 km | lit firn / bare ice / dacite rock / forest | 73-90 / 54-73 / 46 (-4) / 19.5 | | Rainier from Seattle tower, clear May evening | 94 km | lit snow / shaded snow / sky at peak / foothills | 65 (+11) / 60 (+6) / 58 (-2) / 45 (-3) | | Rainier from Elliott Bay, Aug 20:52 | 96 km | snow / rock / sky | 73 / 69 / 74 (snow = sky: weak alpenglow) | What these say: - **At 94 km on a clear day, lit snow is only +7 L\* over the sky** and shaded snow +2. The far peak is a pale shape a hair lighter than its sky, warmer (b\* +11 against -2): the snow keeps the sun's colour, the sky and the foothills go blue-grey. Rock at that distance is gone into the snow's value. - **The foothills are the darkest thing on the horizon** (-12 L\* under the sky at 94 km) because they are lower, in thicker air, and forested. The peak above them is lighter than the sky. - **Haze is not uniform.** Forest 5-8 km below the Silver Queen camera (2,129 m) is nearly un-hazed (L\* 14.6 against ~12 intrinsic), while forest at 60-70 km in the lowland photo fits a visual range of ~80 km. One Koschmieder distance can't fit both. Aerosol density falls with height (scale height ~1.5-2.5 km, the boundary layer), so the painter uses a sea-level-equivalent path: `deff = d * Ha (e^(-hc/Ha) - e^(-ht/Ha)) / (ht - hc)`. This single change put the forest at the photo's value (22.6 -> 14.7) and is *the* reason summits stand clear over hazy foothills. - **At dusk the lit snow sinks to the sky's value** (96 km, sun just set at sea level): alpenglow is weak and warm, and the peak nearly vanishes. Imhof's rule that high ground keeps its contrast is a clear-air rule. - Earth curvature with standard refraction (R_eff = R/(1-0.13)): the terrain drops d^2/(2R_eff), 630 m at 96 km. Rainier from Seattle stands on a sunk horizon; foothills 60 km out lose their lower slopes behind nearer ridges. The caster bends the terrain for every ray. ### 1.4 How a snow peak changes through the year (from the dated photos) - **Late May** (Stuart spring rows): continuous snow above ~1,300 m on shaded aspects, but ribs and steep faces already dark; the mountain reads as white with dark *arrowheads* (Ruskin). - **July** (Rainier, Silver Queen): the snowline is up at ~1,900 m on open slopes; below it, snow survives only in gullies and avalanche cones, as white threads and wedges. - **Late August / September minimum** (Glacier Peak; Stuart July 2008 in a low-snow year): seasonal snow gone from rock except couloirs and north-facing ledges; glaciers split at the firn line into white upper and grey lower; pumice and dacite (Glacier Peak) are pale tan, granite (Stuart) warm grey and lichen-dark. - **First autumn snow** (Stuart, Oct 2024 photos): a thin veneer that sheds from steep rock at once and draws every ledge and bedding line; the `fresh` season option models it (not in the sheets). --- ## Part 2. Translation: the method ### 2.1 Real form first: fitted cameras on real terrain `src/dem.py` (USGS 3DEP 1/3" and 1 m lidar, read remotely from the cloud-optimized GeoTIFFs; ESA WorldCover for ice, trees, bare), `src/cam.py` (curved-earth ray caster, numba, two-level for lidar), `src/fit.py` + `src/fitcam.py` (camera fit). The camera position comes from the photo's GPS (Commons metadata, `refs/commons_meta.json`). One panorama is cast from there; heading, pitch, fov and roll are then found by warping it, so the fit costs image warps, not ray casts. Score: the photo's Lab edge strength along the render's silhouettes (skyline + depth jumps); colour sky masks failed in haze (`stages/t_sk_all.png`). Two fixes made it reliable: (1) **a control point**: put the summit on its photo pixel and scan fov, then refine (local optima otherwise); (2) **snap** the camera to the highest ground within a few hundred metres (photographers stand on high points; GPS tags sit in hollows). | shot | distance | result | |---|---|---| | Rainier from Silver Queen Peak, July | 21 km | excellent; foreground ridges line up (`stages/fit_rainier_ne_july.png`) | | Glacier Peak from the east, Aug 2003 | 12 km | excellent (`stages/fit_pair_sg.png`, bottom) | | Mount Stuart from the south, July 2008 | 5.6 km | excellent (`stages/fit_pair_sg.png`, top) | | Rainier from the Seattle tower | 94 km | good (`stages/fit_sea_rainier_clear.png`) | | Rainier from Elliott Bay | 96 km | good (`stages/fit_sea_rainier.png`) | | Rainier "20200906" (west side, September) | 16 km | **failed**: the GPS tag is not where the photo was taken; a 9 x 9 km position search found nothing convincing | | Olympus from Hurricane Ridge (telephoto) | 25 km | **failed**: the fit locks onto the wrong ridge line; my summit pixel guess is probably not Olympus | ### 2.2 The snow model (`src/snowmodel.py`) `prepare()` runs once per peak (fields, Sx, gravity routing, insolation over the melt season with cast shadows); `apply(prep, season)` is cheap (melt, fresh snow, glacier surface state, cover). Every rule is part 1's table. Calibration went through the photos, and each correction was forced by a picture: - **Holding depth.** The Bernhardt & Schulz defaults left Rainier's steep walls white in July. OGGM's steeper curve bared everything above ~45 deg, as the photo does (`stages/dbg_cover_crop.png`). - **Couloirs.** With plain holding depth, Stuart kept almost no snow (1.4% of terrain vs the photo's white threads). Gully side-wall support (x4 holding depth in a 4 m-deep, 25 m-radius concavity) put the snow in the gullies and on the Stuart Glacier bench, where the photo has it (`stages/dbg_cover_stuart_s_july.png`). - **Lidar pits.** The routing kept everything that reached a closed pit: one lidar pit held 14 km of snow. Pits now fill to 15 m and deposits are capped at 14 m (real avalanche cones: 5-15 m). - **Glaciers keep their firn.** Routing shed snow from 30-deg glacier surfaces too, so all ice read as grey. On mapped glacier below 35 deg the winter snow is kept (the glacier *is* retained snow), and the firn line then falls near the ELA, white above, grey below. - **Melt is half temperature.** Pure insolation melt kept deep avalanche cones in shaded valley bottoms through August (white patches in the Glacier Peak forest). Real low snow melts from warm air regardless of aspect. And forest canopy hides ground snow from far away. Photo check: Rainier NE (July), model snow vs photo snow (L\* threshold) inside the terrain above 1,500 m: IoU 0.68-0.72 across z_sl 1,500-2,500 (`src/calib.py`), best ~1,900-2,100. ### 2.3 The painter (`src/paint.py`, `src/render.py`) Everything is drawn into an index canvas (material, family, step, haze band); the palette is the light (as in every good study here), so one canvas re-lights per hour and haze. Stages: 1. **Coverage at 3x supersampling, then a decision per pixel.** Snow cover is smoothed along the screen-space *fall line* (1.5 px line-integral) before the threshold, so snow patches stretch down the slope as tongues and ribs stretch as arrowheads, never as round blobs or noise. Then: majority clean; islands under 3 px absorbed unless the curvature says they are a gully (snow) or a rib (rock); one-pixel-thick dashes removed unless they are long ledges. 2. **Materials:** sky, snow, ice (bare glacier), debris-covered ice (below 1,850 m), rock (per-peak albedo: weathered granite, andesite, pale dacite), scree (bare + gentle), meadow, forest. 3. **Family from the sub-pixel shade fraction**, not from averaged n.l: a pixel is shade if more than half its supersamples face away or sit in cast shadow. Averaging normals first erased every rib narrower than a pixel. 4. **Step inside the family** blends an absolute light coordinate (n.l for lit, sky view for shade) with a **rank-keyed** one (quantile shares per material: rock lit 30/40/30%, snow lit 8/22/70%): the ground student's keying and the rock student's value economy. It is what makes Stuart's face read at high noon, where true n.l is nearly flat. A **local relief** term (n.l minus its neighbourhood mean: Imhof's local adjustment of light) and the curvature steps (gullies down, sun-facing ribs up) put the structure in. 5. **Haze bands** from the altitude-weighted haze path, quantized finely (11 edges, 0.6-45 km) and 3x3-majority cleaned. Two dead ends first: dithered seams made smoke (the aerial student's trap, again); coarse bands with big value jumps made blobs across the valley. Beyond 14 / 35 km of haze path, each family collapses to 2 / 1 steps. 6. **Colour** = albedo x (sun n.l + sky x sky-view + snow bounce into shade) at each step's representative n.l, then Koschmieder mixing to the air colour. Snow's lit ramp is compressed (it is a plateau in every photo), rock's is exaggerated. Sky: measured zenith/horizon. 7. **Marks:** transverse crevasses on glacier ice at 11-38 deg (45 m spacing, 6 m open, warped into arcs; drawn only when a crack fills a third of a pixel, so they fade into texture with distance), with a lit lip above each; cornice lips on lee crests (top step) with the curl's shadow under them. ### 2.4 Photo | mine (the honest pairs) (Photo on the left, box-downsampled to the same pixels, as the answer key.) **Glacier Peak from the east, 12 km, Aug 30** (`stages/r_gp_e_aug_sept_a6.png`). The best pair. Silhouette, the summit horns, the white upper glaciers over grey lower ice, the pale dacite aprons and the dark forested ridge running up the middle are where the photo has them. Wrong: my sky is duller, the valley forest is darker and flatter than the photo's hazy teal, lit snow is a step too cold, and the summit rock is too dark and too blocky. **Rainier from Silver Queen Peak, 21 km, July noon** (`stages/r_rainier_ne_july_summer_v16.png`). Silhouette exact; Little Tahoma, the Emmons/Winthrop firn, Steamboat Prow and the dark Willis-side rock masses in place. Wrong: the photo's lower mountain is a warm mid-value band of rock and moraine that mine renders as scattered grey; a few snow patches still sit in the upper forest; the snow surface lacks the flow-parallel crevasse streaks of the photo. **Mount Stuart from the south, 5.6 km, July 2008** (`stages/r_stuart_s_july_summer_g6_crop.png`). Form right (lidar at 4 m); snow in the right couloirs, including the big central one. Wrong: the photo's rock is warm gold-grey with sharp dark structural lines (Imhof's rock drawing); mine is a grey value texture without direction. The gully snow is too wide and too bright at the bottom. **Far pairs (94 and 96 km)**: the numbers in 1.3 are in the palette logic; the painted 80 km sheet rows follow them (peak a little lighter than sky, foothills darkest). A painted pair against the Seattle photo is on the open list. ### 2.5 Sheets - `sheets/A_<peak>_distance_season_light.png`: rows 5 / 20 / 80 km x September minimum / late May; columns clear noon, clear evening, hazy afternoon (visual range 18 km), alpenglow. Peaks: Stuart (SSW, Longs Pass side), Rainier (NW, Seattle side), Olympus (NE, Hurricane Ridge side), and the Dragontail-Colchuck ridge as the local ridge (N, Colchuck Lake side). The standpoint is on the real bearing, on high ground from which the summit is visible and the foreground falls away. - `sheets/B_vocabulary_scale_season.png`: gully fill, ledge bands, cornice, glacier edge on synthetic terrain at 1 and 0.25 m/px, through the same model and painter. ### 2.6 Parameters (the knobs a lane will touch) `snowmodel.DEFAULT`: `g_acc, z_rs, hs_max` (accumulation), `k_wind, sx_dmax` (wind), `shd_c, shd_a` (holding depth), `k_gully, gully_r, gully_depth` (couloirs), `pit_cap, hs_cap`, `k_melt, p_ins, w_temp` (melt), `ice_max_slope`. `SEASONS[...]["z_sl"]` is the one seasonal knob. `paint.DEFAULT`: `snow_thr, fall_len, min_island, ledge_min_len` (shape cleaning), `key_q, shares, relief_k, subpix_k, lit_lo, lit_span, seam_w` (values), `bands_km, collapse_km` (air), `debris_z, crevasses, crev_space, crev_open, crev_slope, cornices, canopy_hide`. `paint.light_state(sun_el, sun_az, haze_km, warm, airlight, sky_zenith, sky_horizon)`; `render.add_haze_path(G, cam_z, Ha)` (Ha = aerosol scale height). For a lane porting this: the snow model is plain numpy + two numba loops and works on any heightfield (voxsim's worldgen DEM included); the painter wants a G-buffer (normal, distance, height, sun visibility, material fractions) at 2-3x supersampling. --- ## Open list (not there yet, and why) 1. **Rock surface.** Imhof's and Ruskin's rock is structural strokes (ribs, joints, gully walls) in a few values; mine is a value texture. Next: draw rib/gully lines from the curvature ridges as clean runs (`clean_profile`), dark on the shade side, lit lip on the sun side. 2. **Near forest reads as camo.** Forest and meadow blobs from WorldCover at 10 m. It needs the aerial student's canopy technique, or just one designed value per ridge layer. 3. **Snow warmth.** Lit snow in the photos is warm white (b\* +2..+11); mine is neutral to cold. 4. **Olympus** has no photo-matched pair (fit failed) and the Olympics' lower snowlines are only a global offset. 5. **Cornices and crevasses** are marks with measured spacing but untested against a photo pair. 6. **Close scale (0.1 m/px)** is not done here; the snow student's loaf/cap grammar (`pixelart-studies/snow/snowpaint.py`) is the tool for it. 7. **80 km painted pair** against the Seattle photos.
Bash: Check background progress
grep -v -i "warn\|nanmean" ~/work/pixelart-studies/elements/snowpeak/sheets/run_all.log | tail -5

# output:
no clear standpoint; best visible fraction 0.759
rainier 5 sept 31s
rainier 5 spring 47s
rainier 20 sept 68s
rainier 20 spring 78s
Agent: Critic: GP pair and Stuart sheet
ToolSearch: select:Monitor
Monitor: sheet and vocab build progress

Waiting for events.

Bash: Wait for builds to finish
until grep -qE "Traceback|B_vocabulary" ~/work/pixelart-studies/elements/snowpeak/sheets/run_all.log; do sleep 5; done; grep -v -i "warn\|nanmean" ~/work/pixelart-studies/elements/snowpeak/sheets/run_all.log | tail -8

# output:
Command running in background with ID: b2pq1ld9d. Output is being written to: (tmp) You will be notified when it completes. To check interim output, use Read on that file path.
Monitor: wait for build end

Waiting.

Bash: Check build progress
grep -v -i "warn\|nanmean" ~/work/pixelart-studies/elements/snowpeak/sheets/run_all.log | tail -6; ls ~/work/pixelart-studies/elements/snowpeak/sheets/

# output:
rainier 80 sept 151s
rainier 80 spring 162s
wrote ~/work/pixelart-studies/elements/snowpeak/src/../sheets/A_rainier_distance_season_light.png
  no clear standpoint; best visible fraction 0.996
olympus 5 sept 29s
olympus 5 spring 37s
A_rainier_distance_season_light.png
A_stuart_distance_season_light.png
run_all.log
run_stuart.log
Read: elements/snowpeak/sheets/A_rainier_distance_season_light.png

elements/snowpeak/sheets/A_rainier_distance_season_light.png 104. elements/snowpeak/sheets/A_rainier_distance_season_light.png

Bash: Record critic findings
cd ~/work/pixelart-studies/elements/snowpeak; cat >> LESSONS.md <<'EOF'

### Critic pass (fresh-eyes agent, end of session), folded in

- Terrain below the snow lacks ridge-plane structure: one blue-black slab at Glacier Peak, faceted
  camo at Rainier and Stuart 20 km. Highest-leverage next fix: a value per occlusion layer (lighter
  and bluer with distance), warm mid-values on sunlit slopes, pale buff for pumice/debris.
- Snow still has per-pixel confetti in places: false snow blobs in Rainier's upper forest band,
  speckled spring snow at Stuart. Fall-line smoothing is too weak at the transition band.
- Stuart sheet: a straight horizontal seam across the frame through the peak (5 and 20 km rows):
  suspect the edge of the 11 km snow sub-box / fine-vs-coarse shadow switch. Not yet chased.
- Rainier sheet 5 km: the standpoint search put the camera on the glacier itself (a sunk 5 km
  view is inside the massif); crevasse marks show there as isolated dots. The 20 and 80 km rows
  read best of all the sheets.
- 80 km hazy afternoon: the peak nearly disappears (consistent with the measured 96 km dusk photo,
  but a painter would keep one lit step on the snow).
EOF

# output:
(Bash completed with no output)

Waiting for the remaining builds (Olympus, Dragontail, vocab).

Bash: Block until builds finish
until grep -qE "Traceback|B_vocabulary" ~/work/pixelart-studies/elements/snowpeak/sheets/run_all.log; do sleep 10; done; grep -v -i "warn\|nanmean" ~/work/pixelart-studies/elements/snowpeak/sheets/run_all.log | tail -8

# output:
gully 0.25
ledge 1.0
ledge 0.25
cornice 1.0
cornice 0.25
glacier 1.0
glacier 0.25
wrote ~/work/pixelart-studies/elements/snowpeak/src/../sheets/B_vocabulary_scale_season.png
Read: elements/snowpeak/sheets/B_vocabulary_scale_season.png

elements/snowpeak/sheets/B_vocabulary_scale_season.png 105. elements/snowpeak/sheets/B_vocabulary_scale_season.png

Read: elements/snowpeak/sheets/A_olympus_distance_season_light.png

elements/snowpeak/sheets/A_olympus_distance_season_light.png 106. elements/snowpeak/sheets/A_olympus_distance_season_light.png

Read: elements/snowpeak/sheets/A_dragontail_distance_season_light.png

elements/snowpeak/sheets/A_dragontail_distance_season_light.png 107. elements/snowpeak/sheets/A_dragontail_distance_season_light.png

The vocab sheet came out all sky: its cameras sit outside the synthetic terrain grid, and the ray caster stops at the grid edge. Padding the terrain so the cameras sit inside it.

Bash: Pad synthetic terrain and rebuild vocab
cd ~/work/pixelart-studies/elements/snowpeak/src; python3 - <<'EOF'
s=open("vocab.py").read()
s=s.replace('''    Z = Z - Z.min() + 1800
    return dict(Z=Z.astype(np.float32), ice=ice, trees=np.zeros_like(ice), bare=np.ones_like(ice) * 0.5,
                x0=0.0, y0=float(n * res), res=res)''','''    Z = Z - Z.min() + 1800
    # pad by 1.3 patch widths (edge values) so the camera stands inside the grid (the caster
    # stops at the grid edge)
    p = int(1.3 * n)
    Zp = np.pad(Z, p, mode="edge"); icep = np.pad(ice, p)
    return dict(Z=Zp.astype(np.float32), ice=icep, trees=np.zeros_like(icep), bare=np.ones_like(icep) * 0.5,
                x0=-p * res, y0=float((n + p) * res), res=res, core=(p, n))''')
s=s.replace('''    n = T["Z"].shape[0]; size = n * T["res"]
    tx, ty = size / 2, T["y0"] - size / 2                   # target: centre of the patch
    tz = float(T["Z"][n // 2, n // 2])''','''    p, n = T["core"]; size = n * T["res"]
    tx, ty = size / 2, T["y0"] - (p + n / 2) * T["res"]    # target: centre of the patch
    tz = float(T["Z"][p + n // 2, p + n // 2])''')
open("vocab.py","w").write(s)
EOF
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR NUMBA_NUM_THREADS=4 nice -n 10 python3 vocab.py 2>&1 | grep -v -i "warn\|nanmean" | tail -2

# output:
Command did not complete within its 600s timeout and was moved to the background (ID: bl8up8lfa). Output is being written to: (tmp) You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains ~/work/voxsim-wt/landing; directory changes made by the backgrounded command do not apply to subsequent commands.
Bash: Wait for vocab build
until [ -n "$(grep -E 'wrote|Traceback' (tmp))" ]; do sleep 10; done; grep -v -i "warn\|nanmean" (tmp) | tail -4

# output:
glacier 0.25
wrote ~/work/pixelart-studies/elements/snowpeak/src/../sheets/B_vocabulary_scale_season.png

[exited with code 0]
Read: elements/snowpeak/sheets/B_vocabulary_scale_season.png

elements/snowpeak/sheets/B_vocabulary_scale_season.png 108. elements/snowpeak/sheets/B_vocabulary_scale_season.png

Bash: Raise vocab terrain elevation and rebuild
cd ~/work/pixelart-studies/elements/snowpeak/src; sed -i '' 's/    Z = Z - Z.min() + 1800/    Z = Z - Z.min() + 2450/' vocab.py; GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR NUMBA_NUM_THREADS=4 nice -n 10 python3 vocab.py > ../sheets/run_vocab.log 2>&1; grep -E "wrote|Traceback" ../sheets/run_vocab.log

# output:
Command running in background with ID: by6rcfisn. Output is being written to: (tmp) You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains ~/work/voxsim-wt/landing; directory changes made by the backgrounded command do not apply to subsequent commands.
Bash: Wait for vocab rebuild
until grep -qE "wrote|Traceback" ~/work/pixelart-studies/elements/snowpeak/sheets/run_vocab.log 2>/dev/null; do sleep 10; done; grep -E "wrote|Traceback" ~/work/pixelart-studies/elements/snowpeak/sheets/run_vocab.log

# output:
Command did not complete within its 600s timeout and was moved to the background (ID: bpk6v0u25). Output is being written to: (tmp) You will be notified when it completes. To check interim output, use Read on that file path.
Bash: Wait for vocab rebuild
until grep -qE "wrote|Traceback" ~/work/pixelart-studies/elements/snowpeak/sheets/run_vocab.log 2>/dev/null; do sleep 10; done; grep -E "wrote|Traceback" ~/work/pixelart-studies/elements/snowpeak/sheets/run_vocab.log

# output:
wrote ~/work/pixelart-studies/elements/snowpeak/src/../sheets/B_vocabulary_scale_season.png
Read: elements/snowpeak/sheets/B_vocabulary_scale_season.png

elements/snowpeak/sheets/B_vocabulary_scale_season.png 109. elements/snowpeak/sheets/B_vocabulary_scale_season.png

Cornice and glacier rows read now; gully and ledge are still weak. Recording that in LESSONS, then handing back.

Bash: Add final findings, check folder size
cd ~/work/pixelart-studies/elements/snowpeak; cat >> LESSONS.md <<'EOF'

- Vocabulary sheet (after padding the synthetic grids so the camera sits inside them, and lifting
  them to 2,450 m): **cornice and glacier rows read** (windward scour vs lee load, a lit crest lip;
  white firn tongue, moraine ridges, crevasse arcs where it steepens). **Gully and ledge rows are
  weak**: the gully face keeps almost no snow (the synthetic gullies are too shallow for the
  couloir rule's 4 m depth scale at 1 m/px, and at 0.25 m/px the 25 m radius spans the whole
  patch), and the 1 m ledge face shows snow as striped scanline dashes (70-deg risers alternate
  with treads 1-2 px tall, below the dash filter's limit). Both need the curvature scales set in
  metres per pixel, not fixed metres.
- Olympus sheet: 80 km rows are broken (camera at 3 m on the Strait shore; the peak is under the
  horizon haze, and the foreground water is drawn as forest). The Dragontail 20 km rows show the
  best snow pattern of the whole sheet set (gully threads in September, broken cover in May).
- Disk: study folder ~300 MB (cache 250 MB). DEM tiles are read over HTTP, nothing big stored.
EOF
du -sh ~/work/pixelart-studies/elements/snowpeak

# output:
1.3G    ~/work/pixelart-studies/elements/snowpeak
Bash: Find what is large
cd ~/work/pixelart-studies/elements/snowpeak; du -sh * | sort -h | tail -6; du -sh cache/* | sort -h | tail -8

# output:
1.4M    src
1.6M    sheets
2.3M    img
 20M    stages
 80M    refs
1.2G    cache
 54M    cache/dem_olympus_hr_july_15.npz
 65M    cache/dem_rainier_w_sept_15.npz
 69M    cache/snowprep_rainier_225_15.npz
 69M    cache/snowprep_stuart_225_15.npz
 70M    cache/snowprep_glacierpeak_225_15.npz
 70M    cache/snowprep_olympus_225_15.npz
 72M    cache/dem_rainier_ne_july_15.npz
 85M    cache/snowprep_dragontail_225_15.npz

Over the 1 GB rule. All of this regenerates from code, so I'm pruning the failed-shot caches and the sheet DEMs.

Bash: Prune regenerable caches
cd ~/work/pixelart-studies/elements/snowpeak/cache; rm -f dem_rainier_w_sept_* pano_rainier_w_sept* dem_olympus_hr_july_* pano_olympus* dem_sheet_* snowprep_sheet_* snowprep_vocab* dem_sea_rainier_30.npz; cd ..; du -sh cache .; sed -i '' 's/- Disk: study folder ~300 MB (cache 250 MB)./- Disk: study folder kept under 1 GB (caches are regenerable npz boxes; sheet DEMs and failed-shot caches pruned)./' LESSONS.md

# output:
(eval):1: no matches found: snowprep_sheet_*
1.2G    cache
1.3G    .
Bash: Prune caches (zsh-safe)
cd ~/work/pixelart-studies/elements/snowpeak/cache; setopt nonomatch 2>/dev/null; for p in dem_rainier_w_sept_ pano_rainier_w_sept dem_olympus_hr_july_ pano_olympus dem_sheet_ snowprep_vocab dem_sea_rainier_30; do rm -f ${p}*; done; ls | sed 's/_[^_]*$//' | sort | uniq -c | sort -rn | head; du -sh .

# output:
1 tiles_WA_EasternCascades_2019
   1 snowprep_stuart_225
   1 snowprep_rainier_225
   1 snowprep_olympus_225
   1 snowprep_glacierpeak_225
   1 snowprep_dragontail_225
   1 pano_stuart_s
   1 pano_sea_rainier
   1 pano_sea
   1 pano_rainier_ne
760M    .
Bash: Drop panoramas too; final size
cd ~/work/pixelart-studies/elements/snowpeak; rm -f cache/pano_*.npz; du -sh cache .; sed -i '' 's/- Disk: study folder ~300 MB (cache 250 MB)./- Disk: study folder kept under 1 GB (caches are regenerable npz boxes; sheet DEMs, panoramas and failed-shot caches pruned)./' LESSONS.md; grep -n "Disk:" LESSONS.md

# output:
661M    cache
766M    .
307:- Disk: study folder kept under 1 GB (caches are regenerable npz boxes; sheet DEMs and failed-shot caches pruned). DEM tiles are read over HTTP, nothing big stored.
SubagentHandback:

I sent my report to the agent that launched me.