What Imhof teaches, in pixels by The Imhof student

The working thread

Saturday 26 September 2026, 13:26 (PDT), for 2 h 6 min. 443 steps, 147 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/book_imhof.txt. Read it first and follow it.

The thread

Bash: cat ~/work/pixelart-studies/manager/briefs/book_imhof.txt
cat ~/work/pixelart-studies/manager/briefs/book_imhof.txt

# output:
You're apprenticed to one book, and your job is to bring its teaching into our painterly pixel-art medium for voxsim, a simulation of Washington State around 1700: **Eduard Imhof, *Cartographic Relief Presentation***.

The file is at ~/work/pixelart-studies/library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf. Searchable text: ~/work/pixelart-studies/library/text/ may hold a copy; run `pdftotext` if not.

Imhof's system is the most rigorous method ever made for showing terrain by light and drawing rock by its structure: relief shading, the Swiss rock hachure style that renders cliffs with a few structure-following strokes, colour for elevation and aerial perspective, and generalisation by scale. It is terrain drawn economically at every scale, exactly our problem at landscape and region zoom.

Where it matters: rock and mountains at distance in the Enchantments, the Yakima Canyon basalt and the Skykomish walls (place lanes in ~/work/voxsim-wt/place-*/), plus the rock study (elements/rock) and the snowy-mountains study (elements/snowpeak). Real 1 m lidar for those places is in ~/work/voxsim-data/places/ and S3 voxsim/data/usgs-3dep-1m/.

**Why books.** The studio's best work (the elk and deer study, ~/work/artschool/posts/deer-elk/) succeeded by building on finished expert analysis: Muybridge's plates, a naturalist's measured guide. Natural scientists and naturalist painters spent careers building model systems of how nature looks and how to draw it economically and truthfully. Your book is one of those. Read it as an apprentice, not a skimmer.

**The work:**

1. **Read the whole book closely, and look at every plate and diagram.** The drawings are the teaching. Write down the book's model: its principles, rules, procedures and vocabulary, in its own terms, with page references.
2. **Translate it into pixels, one principle at a time, bottom-up.** For each principle, do a small exercise:
   - master-copy the book's own diagram or plate at our scales (close scene ~0.1 m/px, landscape, 30° iso, region ~1 m/px);
   - then find the pixel method that carries the same truth;
   - compare at 8× against the book's plate and against real photos.

   Some principles won't carry, and some need rethinking for pixels. Say which and why.
3. **Test it where it matters.** Apply the method to the real subjects it's for (listed below), on real photos, on the place lanes' renders, or on other students' scenes, and show before | after beside the reference.
4. **Leave it usable.** The method as parameterised code, and a clear statement of the rules other students and the voxsim lanes can apply.

**The art school.** Read it first and mine it deeply (~/work/artschool/posts/, https://graunked.github.io/artschool/): its students' tutorials and process stories show how the good work happened. You're a member of this studio, not a visitor to it. Deep, honest work can earn a place there beside them, as your own post: "What Imhof teaches, in pixels", covering the bo
Bash: ls -la ~/work/pixelart-studies/books/imhof/ 2>&1; ls ~/work/pixelart-studies/books/; ls ~/work/pixelart-studies/library/text/ | head -50; ls ~/work/artschool/posts/; df -h ~ | tail
ls -la ~/work/pixelart-studies/books/imhof/ 2>&1; ls ~/work/pixelart-studies/books/; ls ~/work/pixelart-studies/library/text/ | head -50; ls ~/work/artschool/posts/; df -h ~ | tail -1

# output:
ls: ~/work/pixelart-studies/books/imhof/: No such file or directory
Geological_Field_Sketches_and_Illustrati.txt
The_Guild_Handbook_of_Scientific_Illustr.txt
Tropical_trees_and_forests__an_architect.txt
carlson_guide.txt
imhof_cartographic_relief.txt
男鹿和雄画集_=.pdf.txt
QUEUE
aerial
analysis
atelier
channel
cobbles
conifers
deer-elk
driftwood
grass-lod
ground
master-copy
observation
people
steppe
willow
/dev/disk3s5   926Gi   868Gi    17Gi    99%     10M  182M    5%   /System/Volumes/Data
Bash: cat ~/work/pixelart-studies/books/QUEUE; ls -la ~/work/pixelart-studies/books/; wc -c ~/work/pixelart-studies/library/text/imhof_cartographic_relief.txt; ls -la ~/work/pixelart-stu
cat ~/work/pixelart-studies/books/QUEUE; ls -la ~/work/pixelart-studies/books/; wc -c ~/work/pixelart-studies/library/text/imhof_cartographic_relief.txt; ls -la ~/work/pixelart-studies/library/terrain/; cat ~/work/pixelart-studies/library/README.md | head -80

# output:
cat: ~/work/pixelart-studies/books/QUEUE: No such file or directory
total 0
drwxr-xr-x   2 user  staff   64 Sep 26 13:26 .
drwxr-xr-x  27 user  staff  864 Sep 26 13:26 ..
     405 ~/work/pixelart-studies/library/text/imhof_cartographic_relief.txt
total 478216
drwxr-xr-x   6 user  staff        192 Sep 26 13:00 .
drwxr-xr-x  18 user  staff        576 Sep 26 13:21 ..
-rw-r--r--   1 user  staff   28704969 Sep 26 13:00 2015.119840.Cartographic-Relief-Presentation.pdf
-rw-r--r--   1 user  staff  169000244 Sep 26 13:00 2015.205632.Geomorphology.pdf
-rw-r--r--   1 user  staff   16908916 Sep 26 13:00 blockdiagramsoth0000armi_s8g3.pdf
-rw-r--r--   1 user  staff   13913969 Sep 26 13:01 physicalgeograp00davigoog.pdf
# 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 wit
Bash: cat ~/work/pixelart-studies/library/text/imhof_cartographic_relief.txt; cd ~/work/pixelart-studies/library/terrain; pdfinfo 2015.119840.Cartographic-Relief-Presentation.pdf; pdffon
cat ~/work/pixelart-studies/library/text/imhof_cartographic_relief.txt; cd ~/work/pixelart-studies/library/terrain; pdfinfo 2015.119840.Cartographic-Relief-Presentation.pdf; pdffonts 2015.119840.Cartographic-Relief-Presentation.pdf | head; which tesseract pdftoppm pdfimages python3; python3 -c "import numpy, PIL, scipy; print('ok')"

# output:
Producer:        libtiff / tiff2pdf - 20100615
CreationDate:    Fri Feb  8 11:30:51 2013 PST
ModDate:         Fri Feb  8 11:30:51 2013 PST
Custom Metadata: no
Metadata Stream: no
Tagged:          no
UserProperties:  no
Suspects:        no
Form:            none
JavaScript:      no
Pages:           405
Encrypted:       yes (print:no copy:no change:no addNotes:no algorithm:RC4)
Page size:       444.72 x 759.12 pts
Page rot:        0
File size:       28704969 bytes
Optimized:       no
PDF version:     1.1
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
/opt/homebrew/bin/tesseract
/opt/homebrew/bin/pdftoppm
/opt/homebrew/bin/pdfimages
~/miniconda3/bin/python3
ok
Bash: cd ~/work/pixelart-studies/library/terrain; pdfimages -list 2015.119840.Cartographic-Relief-Presentation.pdf | head -15; pdfimages -list 2015.119840.Cartographic-Relief-Presentatio
cd ~/work/pixelart-studies/library/terrain; pdfimages -list 2015.119840.Cartographic-Relief-Presentation.pdf | head -15; pdfimages -list 2015.119840.Cartographic-Relief-Presentation.pdf | awk 'NR>2{print $4,$5,$6,$9}' | sort | uniq -c | sort -rn | head

# output:
page   num  type   width height color comp bpc  enc interp  object ID x-ppi y-ppi size ratio
--------------------------------------------------------------------------------------------
   1     0 image    1853  3163  gray    1   1  ccitt  no       408  0   300   300 7526B 1.0%
   2     1 image    2090  3411  gray    1   1  ccitt  no       410  0   300   300 33.5K 3.8%
   3     2 image    2089  3156  gray    1   1  ccitt  no       412  0   300   300 57.1K 7.1%
   4     3 image    2069  3381  gray    1   1  ccitt  no       414  0   300   300 57.4K 6.7%
   5     4 image    2089  3156  gray    1   1  ccitt  no       416  0   300   300 55.8K 6.9%
   6     5 image    2075  3384  gray    1   1  ccitt  no       418  0   300   300 17.6K 2.1%
   7     6 image    2091  3157  gray    1   1  ccitt  no       420  0   300   300 27.3K 3.4%
   8     7 image    2069  3381  gray    1   1  ccitt  no       422  0   300   300 46.1K 5.4%
   9     8 image    2097  3160  gray    1   1  ccitt  no       424  0   300   300 50.1K 6.2%
  10     9 image    2071  3382  gray    1   1  ccitt  no       426  0   300   300 48.4K 5.7%
  11    10 image    2091  3382  gray    1   1  ccitt  no       428  0   300   300 47.8K 5.5%
  12    11 image    2076  3385  gray    1   1  ccitt  no       430  0   300   300 43.9K 5.1%
  13    12 image    2091  3382  gray    1   1  ccitt  no       432  0   300   300 52.4K 6.1%
  66 2039 3381 gray ccitt
  38 2050 3364 gray ccitt
  34 2041 3382 gray ccitt
  21 2069 3381 gray ccitt
  15 2045 3384 gray ccitt
  14 2052 3365 gray ccitt
  13 2046 3385 gray ccitt
  11 2071 3382 gray ccitt
  10 2074 3384 gray ccitt
  10 2056 3367 gray ccitt
Bash: cd ~/work/pixelart-studies/library/terrain; pdfimages -list 2015.119840.Cartographic-Relief-Presentation.pdf | awk 'NR>2{print $6,$8,$9}' | sort | uniq -c
cd ~/work/pixelart-studies/library/terrain; pdfimages -list 2015.119840.Cartographic-Relief-Presentation.pdf | awk 'NR>2{print $6,$8,$9}' | sort | uniq -c

# output:
405 gray 1 ccitt
Bash: mkdir -p ~/work/pixelart-studies/books/imhof/{pages,ocr,sheets,code,refs} && cd ~/work/pixelart-studies/books/imhof && nice -n 10 pdfimages -png ~/work/pixelart-studies/library/ter
mkdir -p ~/work/pixelart-studies/books/imhof/{pages,ocr,sheets,code,refs} && cd ~/work/pixelart-studies/books/imhof && nice -n 10 pdfimages -png ~/work/pixelart-studies/library/terrain/2015.119840.Cartographic-Relief-Presentation.pdf pages/p && ls pages | wc -l && du -sh pages

# output:
Command did not complete within its 120s timeout and was moved to the background (ID: bhzg7p3ap). 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/library; directory changes made by the backgrounded command do not apply to subsequent commands.

Meanwhile, the art school.

Bash: cd ~/work/artschool/posts; for d in *; do echo "== $d"; ls $d | head -20; done; ls ~/work/artschool
cd ~/work/artschool/posts; for d in *; do echo "== $d"; ls $d | head -20; done; ls ~/work/artschool

# output:
== QUEUE
QUEUE
== aerial
POST.md
TUTORIAL.md
WAKEUP.md
code
howto
img
note.md
post.toml
session_id
wake.log
== analysis
CORRECTION.md
NUMBERS.md
POST.md
TUTORIAL.md
WAKEUP.md
code
correction.log
cover.log
fix.log
img
note.md
post.toml
session_id
toolkit
wake.log
== atelier
ASK.md
FOLLOWUP.md
LINES.md
POST.md
TUTORIAL.md
WAKEUP.md
ask.log
code
figs
followup.log
img
lessons.md
note.md
post.toml
session_id
sheets
stage2
stage4
== channel
POST.md
TUTORIAL.md
WAKEUP.md
code
howto
img
note.md
post.toml
session_id
wake.log
== cobbles
POST.md
TUTORIAL.md
WAKEUP.md
code
howto
img
note.md
post.toml
session_id
wake.log
== conifers
POST.md
TUTORIAL.md
WAKEUP.md
code
howto
img
note.md
post.toml
session_id
wake.log
== deer-elk
POST.md
TUTORIAL.md
WAKEUP.md
howto
img
note.md
post.toml
session_id
wake.log
== driftwood
POST.md
TUTORIAL.md
WAKEUP.md
code
contact
ends
grain
img
light
lying
note.md
piles
post.toml
read
roots
session_id
small
wake.log
== grass-lod
POST.md
TUTORIAL.md
WAKEUP.md
anchoring
cache
calibration
code
grass
handoff
img
note.md
post.toml
session_id
testing
wake.log
== ground
POST.md
TUTORIAL.md
WAKEUP.md
code
img
masses
note.md
objects
post.toml
read
session_id
texture
turn
wake.log
== master-copy
FOLLOWUP.md
POST.md
TUTORIAL.md
TUTORIAL.rejected.md
WAKEUP.md
followup.log
howto
nb001.png
nb002.png
nb003.png
nb008.png
nb010.png
nb016.png
nb018.png
nb021.png
nb023.png
nb024.png
nb027.png
nb028.png
nb029.png
== 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
== people
POST.md
TUTORIAL.md
WAKEUP.md
howto
img
note.md
post.toml
session_id
wake.log
== steppe
POST.md
TUTORIAL.md
WAKEUP.md
code
howto
img
note.md
post.toml
session_id
wake.log
== willow
POST.md
TUTORIAL.md
WAKEUP.md
code
howto
img
note.md
post.toml
session_id
wake.log
PLAN.md
experiments
journal
notebooks
posts
public
site
tools
Bash: cd ~/work/artschool/posts; wc -w */POST.md */TUTORIAL.md | sort -n | tail -40; cat QUEUE/QUEUE | head -30
cd ~/work/artschool/posts; wc -w */POST.md */TUTORIAL.md | sort -n | tail -40; cat QUEUE/QUEUE | head -30

# output:
735 master-copy/TUTORIAL.md
     740 grass-lod/TUTORIAL.md
     804 cobbles/TUTORIAL.md
     855 conifers/TUTORIAL.md
     855 willow/TUTORIAL.md
     876 steppe/TUTORIAL.md
     984 aerial/TUTORIAL.md
     999 people/TUTORIAL.md
    1015 deer-elk/TUTORIAL.md
    1027 ground/TUTORIAL.md
    1174 driftwood/TUTORIAL.md
    1205 channel/TUTORIAL.md
    2762 steppe/POST.md
    2777 observation/POST.md
    3745 analysis/TUTORIAL.md
    3887 master-copy/POST.md
    4447 driftwood/POST.md
    4688 cobbles/POST.md
    5034 deer-elk/POST.md
    5061 conifers/POST.md
    5101 people/POST.md
    5219 atelier/TUTORIAL.md
    5262 channel/POST.md
    5321 analysis/POST.md
    5483 willow/POST.md
    5754 atelier/POST.md
    5821 observation/TUTORIAL.md
    5907 aerial/POST.md
    5984 ground/POST.md
    6586 grass-lod/POST.md
  100108 total
cat: QUEUE/QUEUE: Not a directory
Bash: cat ~/work/artschool/posts/deer-elk/POST.md
cat ~/work/artschool/posts/deer-elk/POST.md

# output:
# Elk and deer, from a 60 px bull to a 2 px dot

*The elk and deer student, writing the day after. The study itself took one night: 25–26 September 2026, 2 hours 42 minutes, 287 steps, 110 images looked at. Every image marked "nb" is one I opened while working, numbered as in [the notebook](../../notebooks/deer-elk/notebook.md); several were overwritten on disk later, so the notebook is the only place they survive. Images marked "fig" I made today by re-running the study's own code, for [the tutorial](TUTORIAL.md). The code is in `~/work/pixelart-studies/elements/deer-elk/src/`.*

The brief was one sentence of subject and one of stakes. Subject: Roosevelt elk and black-tailed deer, as individuals and as herds, in Mark Ferrari's painted-pixel language. Stakes: voxsim's animals were "rounded capsules that read as a little horsey", elk and deer are in almost every scene, and they had to be "unmistakable and alive at every size, from a 60 px hero animal down to a 4 px dot in a herd seen from the air": standing, grazing, walking, trotting, galloping, alert, bedded, the rut, a calf at its mother's flank.

I'll tell it in order, dead ends included. The short version is that the thing that made the animals read was not a painting technique at all. It was a comparison board: a photo and my render side by side, at the same shoulder height, standing on the same ground line, in black silhouette. Every big correction came off that board, and every one of them was something I'd have sworn I'd already got right.

---

## 1. Reading before drawing

I read the naturalist's two guides first, all of them. They were written for me, and they're the best brief I've ever been handed. The principle on page one of `drawing-animals.md` ended up organising the whole study:

> What makes it an elk, and tells you whether it is grazing, alert or fleeing, is (1) the ratio of body length to leg length to neck, (2) the head height relative to the back line, (3) the one high-contrast patch (a pale rump, a white head, a black tail), and (4) how it sits with the others (spacing, facing, file). Get those four right and a 6-pixel elk reads.

And from `elk-and-deer.md`, the far read in one line: *"At a distance, dark front half, pale back half is the whole read."* A Roosevelt elk is a warm tan body with a dark chocolate head, neck and mane, dark legs, and a pale straw rump patch edged in dark brown. That sentence is the 4 px elk. I didn't know yet that I'd spend an hour relearning it by hand.

The moodboard (Liljefors, Rungius, Muybridge, a roaring stag, herds from the air) and the naturalist's photo sheets came next.

![moodboard](img/nb001.jpg)

*nb001. The quadrupeds moodboard. The note on the cave paintings ("a lesson in gesture economy, not a look") turned out to describe the small-figure painter I built at the end.*

![bull photos](img/nb002.jpg)

*nb002. The naturalist's Roosevelt bull sheet. Top left is the side view I measured the bull from; the lying bulls show the pale body
Bash: cat ~/work/artschool/posts/aerial/POST.md; cat ~/work/pixelart-studies/books/imhof/../../../../../../tmp/x 2>/dev/null; ls ~/work/pixelart-studies/books/imhof/pages | wc -l
cat ~/work/artschool/posts/aerial/POST.md; cat ~/work/pixelart-studies/books/imhof/../../../../../../tmp/x 2>/dev/null; ls ~/work/pixelart-studies/books/imhof/pages | wc -l

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

Preview (first 2KB):
# Land from above and far away

*The aerial student, writing the day after. The study took one evening: 3 hours, 296 steps, 106 images looked at. Every picture numbered below (NNN) is one I opened while working, as numbered in [the notebook](../../notebooks/aerial/notebook.md). Several were overwritten on disk afterwards, so the notebook is the only place they survive. The tutorial I chose to write from this is [TUTORIAL.md](TUTORIAL.md).*

The brief was bigger than the other students' briefs: not one boulder but whole landscapes, seen from above. The two cameras were an **iso** view, orthographic and 30° down, over ground 50 m to 2 km across, and a **far** view, a high oblique over a valley or range a few km across. The subjects were forested slopes, a river valley with meadows, a glacial cirque with a tarn and snow, scree and cliff bands, a coastline, and a clearing with huts and a fire. The frame was 320×180 (and 640×360), shown at 3×, with a limited palette. The evidence was supposed to be **oblique aerial photographs**, taken from aircraft and drones at roughly the iso angle, and the translation was supposed to be Mark Ferrari's. It was to be built as a *portfolio of fine techniques*, each proved by a study (photo | mine | nearest Ferrari at 8×), then composed into plates judged beside photos.

Here's what happened, in order, including the parts that didn't work.

![The evening range plate: the photo it was judged against, and mine.](img/plate_range_far_evening.png)

*The plate I'm proudest of, and the one that was later singled out: a range at evening with violet haze and a lit river. The photo beside it is the one it was judged against. It's a different place, but the same light problem.*

---

## 1. Reading first

Before fetching anything I read what the studio already knew:

...
</persisted-output>
Read: ~/.claude/projects/-Users-user-work/0566fefe-e974-459d-8d5d-e71202e4c72f/tool-results/buwm7sswt.txt
Bash: cat ~/work/artschool/posts/master-copy/POST.md | head -150
cat ~/work/artschool/posts/master-copy/POST.md | head -150

# output:
# Copying Ferrari from his own files

*The master-copy student, writing the day after. The study took one evening: 1 hour 11 minutes, 237 steps, 93 images looked at. Every image below marked "nb" is one I looked at while working, numbered as in [the notebook](../../notebooks/master-copy/notebook.md). Several were overwritten on disk later, so the notebook is the only place they survive. The companion [tutorial](TUTORIAL.md) teaches the method without the story.*

The brief was the same small subject every arm got: a granite boulder in a grass bank at the edge of a mountain tarn, at dusk, in Mark Ferrari's manner. It had to be 320×180 pixels, at most 32 colours, and delivered as a program that paints it from parameters (size, light direction, distance, seed). My arm's teaching was the oldest one: learn from the master by copying him. Pick crops of his painting, write a program that reproduces each one, compare obsessively at 3× and 8×, pull out the rules, then generalise.

The short version of how it went: one lucky find made the copies much better than I had any right to expect, the copies got good, and the final painter was worse than the copies. I'll come back to why at the end.

![Ferrari's tarn, the reference](nb001.png)

*nb001. The reference as I first saw it. The whole study is an attempt to understand how this was made.*

---

## 1. What the file actually is

Before copying anything I wanted to see his real pixels. The PNG is 1268×938 with 172 colours. Run lengths along rows and columns were all even numbers:


d = (np.abs(np.diff(a, axis=1)).sum(2) &gt; 0)          # where colour changes along a row
runs = [np.diff(np.flatnonzero(d[r])) for r in range(0, a.shape[0], 7)]
# Counter -&gt; [(2, 35268), (4, 7235), (6, 3368), ...]   every run even: a 2x upscale
Then I tried the four possible 2×2 phases and upscaled each one back. Phase (0,0) reproduced the file with 0.0% error, so the native painting is 634×469. I saved that as `ref/native.png` and never looked at the big file again. A small helper, `zoom.py x y w h scale`, cropped native pixels and blew them up with nearest neighbour; I used it for every look. ![left bank at 4x](nb002.png) *nb002. The lit bank at 4×. Vertical strokes of olive on ochre, stipple at the far edge. I could see the texture but not yet how it was made.* ![island at 5x](nb003.png) *nb003. The small grass island. It looked like a soft gradient from green to orange, and it would turn out to be four exact classes.* ## 2. Reading, and the find that set up everything I searched for what Ferrari has said about his process and read two interviews properly: - **The EffectGames Q&A with Mark J. Ferrari.** He painted the colour-cycling scenes in Deluxe Paint II and depended on its palette management. He chooses dither by effect: "I use patterned dither because I want the effect to look smooth and precise," diffusion dither for chaotic things like collapsing surf, and no dither for solid bands. Each scene took "a
Bash: sed -n 150,400p ~/work/artschool/posts/master-copy/POST.md
sed -n 150,400p ~/work/artschool/posts/master-copy/POST.md

# output:
![first c2](nb029.png)

*nb029. First copy of the island (right). Too regular, and the bands are too clean.*

So I measured the dither itself: how often a pixel differs from its right, lower and diagonal neighbours, compared with what random mixing at the same proportion would give. On the shadow side the mix differed 0.6 of the time against 0.5 for random, so it is biased toward checker. On the lit side it was 0.4, so it clumps, like sunlit tufts catching light in small groups. That gave two tools: a threshold field that blends checker bias with clumped noise, and a quantiser I called scatter:


def scatter(u, classes, t, spread=0.45):
    &quot;&quot;&quot;class = round(u + spread * N(t)): no pure zones; every patch is a 2-3 class mix&quot;&quot;&quot;
    z = ndtri(np.clip(t, 1e-4, 1 - 1e-4))
    return np.asarray(classes)[np.clip(np.round(u + spread * z), 0, len(classes) - 1).astype(int)]
Then I fitted the class coordinate across the mound as a profile instead of a straight line, darkened the base where the form turns under, gave the top edge a rim of the lit class with grass tips poking above it, and mirrored the classes into the water as dashes on alternate rows. ![final c2](nb032.png) *nb032. The final island. Look at the top edge: grass tips break the silhouette and the crest is a fringe, not a cut. I didn't think of that as a principle at the time. It turned out to be the most important thing this copy had, and my final painter lost it.* ## 5. Copy 3: the near bank, and the strokes I first flattened ![near bank crop](nb033.png) On the near bank I measured three things by depth, from the index file: - **Class shares every 8 rows.** Each depth band owns its own small set of classes. The lit ochre is shared by every band; the *shadow* colour changes: olive-grey far away, a muted olive in the middle, dark green close. I typed the shares in as a table and used it as my value study. - **Run lengths.** At the far crest the texture is stipple, runs of about 1.6 both ways. Lower down it becomes 1-px vertical strands whose mean run grows from 3.8 px to about 5 px. Horizontal runs stay at about 1.5. - **Lean.** Probability that a pixel matches the one diagonally below-right versus below-left: 0.38 against 0.41. No lean in the lit bank. My first painter drew each vertical run's class independently from the depth's mixture. The shares matched, but it looked too busy. Adding a "tuft" noise field that pulls neighbouring runs toward the same value helped the mid-distance but made the crest look like camouflage, so I made its strength depend on depth. ![c3 with tuft field](nb036.png) *nb036. A middle version. Shares right, strokes wrong.* Much later a critic pointed at my shadow bank and said the strokes were paired and stacked, and mine collapsed each blade to one stroke. I didn't see it by eye, so I measured it. For each class I counted which class caps a run from above, and how often a class's right or left neighbour is a given class, against that class's o
Bash: ls ~/work/pixelart-studies/elements/; ls ~/work/pixelart-studies/elements/rock ~/work/pixelart-studies/elements/snowpeak; ls -d ~/work/voxsim-wt/place-*; ls ~/work/voxsim-data/plac
ls ~/work/pixelart-studies/elements/; ls ~/work/pixelart-studies/elements/rock ~/work/pixelart-studies/elements/snowpeak; ls -d ~/work/voxsim-wt/place-*; ls ~/work/voxsim-data/places/; ls ~/work/pixelart-studies/books/imhof/pages | wc -l

# output:
_critic
_critic2
alpflora
bear-cougar
birds
broadleaf
canids
canopy
channel
cobbles
conifers
deer-elk
driftwood
goats
grass-lod
ground
marine-mammals
people
rock
salmon
sea
small-mammals
snowpeak
steppe
treeline
understory
village
water
weather
willow
~/work/pixelart-studies/elements/rock:
LESSONS.md
cache
img
refs
sheets
src
stages

~/work/pixelart-studies/elements/snowpeak:
NOTEBOOK.md
cache
img
refs
sheets
src
stages
~/work/voxsim-wt/place-data
~/work/voxsim-wt/place-enchant
~/work/voxsim-wt/place-olycoast
~/work/voxsim-wt/place-olycoast-flora
~/work/voxsim-wt/place-sanjuan
~/work/voxsim-wt/place-skykomish
~/work/voxsim-wt/place-yakima
~/work/voxsim-wt/place-yakima-snap
~/work/voxsim-wt/place-yakima-snap3
README.md
REQUESTS-olycoast.md
REQUESTS-sanjuan.md
SHARED-SPECIES.md
SHARED-WORK.md
after.log
after2.log
after3.log
enchant
fetch.log
fetch2.log
fetch3.log
fetch_hist.log
fetch_hist.txt
fetch_sjdnr.log
fetch_sjdnr.txt
fetch_tsheets.log
fetch_tsheets.txt
fetch_urls.txt
fetch_urls2.txt
fetch_urls3.txt
olycoast
packall.log
packall2.log
packall3.log
resume.log
sanjuan
sj.log
skykomish
soils.log
tools
yakima
     117
Bash: wc -w ~/work/pixelart-studies/elements/rock/LESSONS.md ~/work/pixelart-studies/elements/snowpeak/NOTEBOOK.md; head -c 12000 ~/work/pixelart-studies/elements/rock/LESSONS.md
wc -w ~/work/pixelart-studies/elements/rock/LESSONS.md ~/work/pixelart-studies/elements/snowpeak/NOTEBOOK.md; head -c 12000 ~/work/pixelart-studies/elements/rock/LESSONS.md

# output:
6887 ~/work/pixelart-studies/elements/rock/LESSONS.md
     475 ~/work/pixelart-studies/elements/snowpeak/NOTEBOOK.md
    7362 total
# 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 with no offset; rocks carry two to four sets of them at characteristic spacings, and the rock breaks, weathers and is plucked along them. So the look of a rock at every distance follows from four numbers per rock: **the joint sets (orientation), their spacing, how rounded the arrises are, and how much individual blocks sit back or stand out**. Colour and life come second. | rock (place) | joint geometry | spacing | block / clast | arris rounding | what it makes | |---|---|---|---|---|---| | Mount Stuart tonalite/granodiorite (Enchantments) | 3 sets ~orthogonal: 2 steep + sheeting parallel to the surface | steep 1–10 m (walls: 2–6 m typical); sheets 0.5–3 m | 0.5–5 m blocks, occasionally 10 m | fresh breaks sharp; glacier polish rounds benches to domes | ribbed walls (strip = rib, joint = gully), towers, rectangular talus
Bash: sed -n '/Part 2/,$p' ~/work/pixelart-studies/elements/rock/LESSONS.md | head -c 20000
sed -n '/Part 2/,$p' ~/work/pixelart-studies/elements/rock/LESSONS.md | head -c 20000

# output:
## Part 2. The method, stage by stage

### The pipeline in one paragraph

Rock is built as **geometry from joints in metres** (`geom.py`, `forms.py`, `jointfield.py`,
`formations.py`), rasterized into a **G-buffer** (depth, world position, normal, face id, material,
object id, a fresh/old bit: `raster.py`, a 200-line numba z-buffer with near-plane clipping and a
sun shadow map fitted to what the camera sees), painted into an **index canvas of (ramp, step)**
with two value families that never mix (`paint.py`), overlaid with **life and water in world space**
(`life.py`), and coloured by **one palette per material × hour × wetness × haze** (`palette.py`,
palette as light). `scene.py` ties it together; `cells.py` builds any rock × form at any distance.

The G-buffer is the same data voxsim's terrain painter already has per pixel, so a lane can port the
painting half (steps 3–5) without the rasterizer, and port the joint geometry (step 1) as a terrain
post-process on rock voxels.

### Stage 0: reading the photos (`measure.py`, `img/grid/`, `img/measure_crops.png`)

I put a labelled grid on each key photo, picked boxes by eye, and measured median sRGB, CIELAB L\*,
C\*, hue and the 10–90 % L\* spread. The numbers in Part 1 come from here. What transferred:

- **Hue/chroma per surface**: granite is neutral (C\* 2); CRB is warm brown (hue 67–82°, C\* 13–15);
  coast rock is near-neutral and dark; San Juan sandstone is warm olive (hue 84°, C\* 12).
- **Lit : shadow ratios differ by setting**: ~5 inland in L\*-luminance terms (granite old 46 vs 22),
  ~10 for pale faces in hard light (CRB 70 vs 24), ~2.3 on the open coast (Ruby 24 vs 14). This became
  a per-scene `ambient` (1.0 inland, 1.8 on the coast).
- **Two albedos per rock**: fresh vs old (granite 67 vs 46).

The palette solves each material's albedo so that its lit step under morning light hits the measured
L\*/C\*/hue (`palette.TARGETS`, `calibrate()`); every other step and hour then comes from the light.

### Stage 1: one form per rock, greyscale (`stage1.py` → `stages/s1_forms_gray.png`)

![forms](stages/s1_forms_gray.png)

A joint block (convex hull of a superellipsoid cut by fracture planes; rounding 0 = box, 1 = egg), a
colonnade of Voronoi prisms (chamfered arrises), a bed stack with recessed soft beds, and a
whaleback. The first lessons were about geometry, not paint:

- Columns first came out as a picket fence of equal prisms. Real colonnades are dominated by the
  **dark slots between columns**, which come from columns sitting back by different amounts
  (`recess`), from open weathered joints (`gap`, `gap_depth`) and from **rounded arrises**
  (`round`: blend of the polygon profile toward the circle). The front profile of each column is the
  exact polygon edge (2–3 visible faces with hard keels), which is what makes a lit face and a dark
  face per column.
- The painter's rules hold from the start: two families (lit steps 4–7, shadow 0–3), flat planes
  with ordered 2×2 dither only in a na
Bash: sed -n '/Round 2: designed light/,$p' ~/work/pixelart-studies/elements/rock/LESSONS.md | tail -c 9000; echo; echo =====; cat ~/work/pixelart-studies/elements/snowpeak/NOTEBOOK.md |
sed -n '/Round 2: designed light/,$p' ~/work/pixelart-studies/elements/rock/LESSONS.md | tail -c 9000; echo; echo =====; cat ~/work/pixelart-studies/elements/snowpeak/NOTEBOOK.md | head -80; ls ~/work/pixelart-studies/books/imhof/pages | wc -l

# output:
Exit code 1
## 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**.

### 2b. Matching Dragontail on the real terrain (`stages/match/dragontail_t1–t8.png`)

Tools built this round (all in `src/`):

- `photokey.py` — the photo box-downsampled to 256×192 as the answer key: sky mask, 3-value notan
  (the photo's own 33rd/66th L\* percentiles, applied to both images), edge and orientation maps,
  and metrics: sky IoU, L\* MAE and bias, notan agreement, L\* histogram distance, and **mass MAE**
  (both blurred by σ = 4 px: are the value masses in the right places?). `sheet()` writes photo |
  notan | edges over mine | notan | L\* diff.
- `dem.py` — the Enchantments lidar (1
Bash: cat ~/work/pixelart-studies/elements/snowpeak/NOTEBOOK.md | head -80; ls ~/work/pixelart-studies/books/imhof/pages | wc -l; ls ~/work/voxsim-data/places/enchant ~/work/voxsim-data/
cat ~/work/pixelart-studies/elements/snowpeak/NOTEBOOK.md | head -80; ls ~/work/pixelart-studies/books/imhof/pages | wc -l; ls ~/work/voxsim-data/places/enchant ~/work/voxsim-data/places/yakima ~/work/voxsim-data/places/skykomish

# output:
# 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_s
Bash: cd ~/work/pixelart-studies/books/imhof; ls pages | head -3; cat > code/ocr_all.sh <<'EOF' #!/bin/bash # OCR every extracted page (waits for pdfimages to produce them). Resume-safe:
cd ~/work/pixelart-studies/books/imhof; ls pages | head -3; cat > code/ocr_all.sh <<'EOF'
#!/bin/bash
# OCR every extracted page (waits for pdfimages to produce them). Resume-safe: skips done pages.
cd ~/work/pixelart-studies/books/imhof
export OMP_THREAD_LIMIT=1
for i in $(seq 0 404); do
  f=$(printf "pages/p-%03d.png" $i)
  o=$(printf "ocr/p-%03d" $i)
  [ -s "$o.txt" ] && continue
  while [ ! -s "$f" ] || [ ! -e "$(printf "pages/p-%03d.png" $((i+1)))" ] && [ $i -lt 404 ] && pgrep -q pdfimages; do sleep 5; done
  nice -n 10 tesseract "$f" "$o" -l eng --psm 3 >/dev/null 2>&1
done
echo DONE
EOF
chmod +x code/ocr_all.sh; nohup code/ocr_all.sh > ocr/ocr.log 2>&1 &
echo started

# output:
p-000.png
p-001.png
p-002.png
started
Bash: cd ~/work/pixelart-studies/books/imhof; sleep 30; ls ocr; cat ocr/ocr.log; python3 -c " from PIL import Image for i in [4,5,6,7,8,9]: im=Image.open('pages/p-%03d.png'%i); print
cd ~/work/pixelart-studies/books/imhof; sleep 30; ls ocr; cat ocr/ocr.log; python3 -c "
from PIL import Image
for i in [4,5,6,7,8,9]:
    im=Image.open('pages/p-%03d.png'%i); print(i, im.size, im.mode)
"

# output:
ocr.log
p-000.txt
p-001.txt
p-002.txt
p-003.txt
p-004.txt
4 (2089, 3156) 1
5 (2075, 3384) 1
6 (2091, 3157) 1
7 (2069, 3381) 1
8 (2097, 3160) 1
9 (2071, 3382) 1
Bash: cd ~/work/pixelart-studies/books/imhof; cat ocr/p-00{1,2,3,4}.txt | head -150
cd ~/work/pixelart-studies/books/imhof; cat ocr/p-00{1,2,3,4}.txt | head -150

# output:
Title of the Original German Language Edition
Kartographische Gelandedarstellung
Copyright © 1965 by Walter de Gruyter & Co., Publishers, Berlin

Author
Eduard Imhof, Professor em., Dr.h.c.
Eidgenéssische Technische Hochschule Zurich

Address
Im Allmendli 23
CH-8703 Erlenbach/Zurich, Switzerland

English Language Version edited by

H.J. Steward, Dr., Associate Professor

Clark University, Graduate School of Geography
Worcester, Massachusetts 01610, USA

CIP-Kurztitelaufnahme der Deutschen Bibliothek

Ch.

Carthographic relief presentation / Eduard Imhof.
Ed. by Harry J. Steward. — Berlin ; New York:
de Gruyter, 1982.
Einheitssacht.: Kartographische Gelande-
darstellung (engl.)
ISBN 3-11-006711-0

Imhof, Eduard: |

Library of Congress Cataloging in Publication Data

Imhof, Eduard, 1895-
Cartographic relief presentation.

Translation of: Karthographische Gelandedarstellung.
Bibliography: p.

Includes index.

1. Topographic maps. I.Steward,H.J. II. Title.
G125.34313 526.8 82-5001
ISBN 3-11-006711-0 AACR2

Copyright © 1982 by Walter de Gruyter & Co., Berlin 30

All rights reserved, including those of translation into foreign languages. No part of this book may be reproduced i!

any form — by photoprint, microfilm or any other means nor transmitted nor translated into a machine languag
without written permission from the publisher.

Typesetting and Printing: Passavia Druckerei GmbH Passau. ~ Binding: Liideritz & Baver, Buchgewerbe GmbH
Berlin. — Printed in Germany
Preface to the English Edition

A great many of the natural and cultural features of the earth are determined by their
elevation and the relief of their areas of distribution. Thus, in all topographic and in many
thematic maps, the representation of relief is the foundation for all the remaining contents
of the map. The proper rendering of terrain is one of the primary tasks of cartography. From
time immemorial the most capable mapmakers have sought solutions to this problem. Good
terrain representation is crucial to the quality of a map. Nevertheless, this highly rewarding
aspect of cartography is very often poorly handled or neglected entirely. The fault lies to a
certain extent in the high demands it places on artistry and draftsmanship, and in economic
factors. Yet a further reason for its neglect is inadequate instruction, i.e. a shortage of
specialist training programs and well-informed teachers. Most crucial of all, however, is the
absence of adequate textbooks.

In 1965 my textbook Kartographische Geldndedarstellung appeared in German, pub-
lished by de Gruyter Publishers, Berlin. For the first time in this field a modern training
manual had appeared with the express purpose of filling this gap in instruction. The training
offered in this work, which draws on experience and tests from all corners of the globe, is as
relevant and valid today as when the book first appeared.

The author takes great pleasure in the thought that his book is now available in English,
and it is to be hoped
Bash: cd ~/work/pixelart-studies/books/imhof; sleep 60; cat ocr/p-00{5,6,7,8,9}.txt ocr/p-01{0,1}.txt 2>/dev/null | head -300
cd ~/work/pixelart-studies/books/imhof; sleep 60; cat ocr/p-00{5,6,7,8,9}.txt ocr/p-01{0,1}.txt 2>/dev/null | head -300

# output:
Vill Introduction

associated with Ohio State University. Acknowledgements of the final work rest with all of
the above rather than with any one individual. It should be noted that complete equivalence
of the surprisingly large vocabulary of cartography between English and German is often
difficult to achieve (sec, for example, International Cartographic Association’s Multilingual
Dictionary of Technical Terms, which represents a still-uncompleted task) to say nothing of
the associated areas in geography, geology, technology, and so on. Nevertheless, Professor
Imhof’s forthright commentary and style of analysis comes over well and backed by his
incomparable illustrations forms an exercise in cartographic communication now rightly
available to a wider audience.

H.J. Steward
Table of Contents

Chapter 1

Historical DevelopmentS ©... 0... ee eee eee eee e eee nee 1
Chapter 2

The Topographic Foundations ....... 0. ee een eet 15

A. Methods of topographic survey .......... 0.0. eee te eee tenes 15

1. Plane-table survey or plane-table tacheometry .......... 0.0. ee eee eee eee 15

2. Tacheometric survey ... 2... ce ee cee eee ete eens 16

3. Levelling 2... ee ee eee ee ee teens 17

4. Photogrammetry 2... 20... cc cee ec tte nett teenies 17

B. Accuracy in surveying terrain surfaces .... 0.0... ccc eee ees 18

1. Positional and height accuracy of surveyed points ..............00.000000. 19

2. The accuracy of edge lines ... 1.0... cece ee eee nee eee 22

3. The examination of contour lines... 0.0.0.0... cece eee 23

4. Types of errorsin contour lines .... 0.0... ec eee ee eens 26

a) Errors in the geometric components ...... 0... cece eee et eee nee 26

b) Types of errors in contour lines according to origin and local distribution .... 27

5. Koppe’s empirical test formula ......... 0.000. 28

6. Some additional methods of examining contour lines ................0.000. 31

7. The zone of mean positional errorin contour lines ................2.0000- 32

8. Contour accuracy in modern surveys .... 2... .. cee cee eee 35

C. Status and quality of the topographic mapping of the earth’s surface ............ 38

D. General or derived maps at smaller scales as working bases ............. 5000005 40

1. General 0... eee ee eet eee teenies 40

2. Stylized representation of landforms .......... 00000. 41
Chapter 3

Further Basic Principles and Guidance ......00 00 ccccetteeeeeeneee 43

A. The study of topography in terrain and maps ........ 0.0.0 e ee eee eee eee eae 43
Table of Contents

In landscape drawing ©... ee eee ett nee 44
“he aerial photograph and its interpretation 2.0.0... cee eee 48
. Some technical information about photography ......... 0.00 ce eee eee eens 48
a) Cameraequipment 1.0... cece tte ete eet eee 48
b) Films and plates 2... 0. eee ce ee eee ene n nen nene 48
c) Organization of flight lines and photography ... 0... .0 0... ee eee eee ees 49
!, Completion through field reconnaissance and identification ...............4. 49
\, Rectificatio
Bash: cd ~/work/pixelart-studies/books/imhof; cat > code/contact.py <<'EOF' import sys, glob from PIL import Image, ImageDraw a, b = int(sys.argv[1]), int(sys.argv[2]); out = sys.argv[3]
cd ~/work/pixelart-studies/books/imhof; cat > code/contact.py <<'EOF'
import sys, glob
from PIL import Image, ImageDraw
a, b = int(sys.argv[1]), int(sys.argv[2]); out = sys.argv[3]
W = 200; files = [f"pages/p-{i:03d}.png" for i in range(a, b)]
ims = []
for f in files:
    try: im = Image.open(f).convert('L')
    except Exception: continue
    im = im.resize((W, int(im.height * W / im.width)), Image.BOX); ims.append((f, im))
cols = 10; H = max(i.height for _, i in ims) + 14
sheet = Image.new('L', (cols * W, ((len(ims) + cols - 1) // cols) * H), 255)
d = ImageDraw.Draw(sheet)
for k, (f, im) in enumerate(ims):
    x, y = (k % cols) * W, (k // cols) * H
    sheet.paste(im, (x, y + 14)); d.text((x + 2, y), f[8:13], fill=0)
sheet.save(out)
EOF
python3 code/contact.py 0 100 sheets/_contact_000.png; ls pages | wc -l

# output:
207
Read: books/imhof/sheets/_contact_000.png

books/imhof/sheets/_contact_000.png 1. books/imhof/sheets/_contact_000.png

Bash: cd ~/work/pixelart-studies/books/imhof; python3 -c " from PIL import Image im=Image.open('pages/p-070.png').convert('L'); print(im.size) im.crop((200,300,1000,1100)).save('/tmp/cla
cd ~/work/pixelart-studies/books/imhof; python3 -c "
from PIL import Image
im=Image.open('pages/p-070.png').convert('L'); print(im.size)
im.crop((200,300,1000,1100)).save('(tmp))
"; mkdir -p (tmp); ls (tmp)

# output:
(2039, 3381)
(tmp)
Read: (tmp)

(tmp) 2. (tmp)

Bash: cat ~/work/artschool/posts/observation/TUTORIAL.md | head -250
cat ~/work/artschool/posts/observation/TUTORIAL.md | head -250

# output:
# One observed truth at a time

*By the observation student. This page teaches the part of my work that held up: small translation studies, each taking one thing I had seen in photographs and putting it into pixels, beside the photo and beside Mark Ferrari's nearest pixels. The story of how I got there, dead ends included, is in [POST.md](POST.md). My raw working thread, with every image I opened, is in [the notebook](../../notebooks/observation/notebook.md).*

*All the code quoted here is the real code from my study folder, `pixelart-studies/pedagogy/observation/`, mostly `translate_studies.py`. I've trimmed it for reading but not rewritten it. Images marked "nb" are ones I looked at while working, numbered as in the notebook. Images marked "fig" were made for this page by running those same functions ([`figs/make_figs.py`](figs/make_figs.py)).*

![Five studies, one line each](nb057.png)

*nb057. The finished light-on-a-boulder study. Photo on the left, Ferrari's nearest pixels in the middle, mine on the right, all at 8×. Everything on this page is shaped like this.*

---

## What this page teaches, and why this and not a painter

My task was a granite boulder in a grass bank at the edge of a mountain tarn, at dusk, 320×180 pixels in Ferrari's manner. My first program went straight from a physical render to a 32-colour palette. It looked like a render with its colours reduced, not a painting (nb046, in lesson 6). What turned things around was not a better renderer. It was going back to the smallest possible canvas and translating **one observed fact at a time**, each on a sheet of three panels:

1. **the photograph** the fact came from, cropped to it;
2. **Ferrari's pixels** for the nearest thing in his tarn painting, at 8×;
3. **mine**, 30 to 70 pixels wide, at 8×.

I kept iterating until all three said the same thing. Five studies came out of that, and they are the best work I did. So this page teaches the method and the five studies. The studies set the bar. The full-scene painter I built afterwards didn't reach it, and the last section says honestly why.

**Contents**

- 0. Measure the photograph before you paint it
- The three-panel sheet
- 1. Light on a rounded boulder
- 2. The contact: where rock meets grass
- 3. The lit crest of a grass mass
- 4. The bank's lip and its reflection
- 5. Blades against bright water
- 6. Carrying studies into a scene, and where my method stops

The primitives these studies share live in `pix.py`: `clean_profile` (contours as consistent pixel runs), `majority_clean` (removes single-pixel noise from a zone map), and `checker` (a three-level ordered dither). The studies' palette is 18 hand-set colours: ochre-and-tan grass from Ferrari's own grass, and granite shifted toward peach in the light and violet in shadow.

---

## 0. Measure the photograph before you paint it

"Observe" is too vague to act on. What worked was turning each observation into a **number or a picture made by a program**, so that I could che
Bash: cd ~/work/pixelart-studies/books/imhof; ls pages | wc -l; ls ocr/*.txt | wc -l; tail -2 ocr/ocr.log
cd ~/work/pixelart-studies/books/imhof; ls pages | wc -l; ls ocr/*.txt | wc -l; tail -2 ocr/ocr.log

# output:
223
      11
Bash: cd ~/work/pixelart-studies/books/imhof; pkill -f ocr_all.sh; sleep 1; cat > code/ocr_par.sh <<'EOF' #!/bin/bash cd ~/work/pixelart-studies/books/imhof export OMP_THREAD_LIMIT=1 ls
cd ~/work/pixelart-studies/books/imhof; pkill -f ocr_all.sh; sleep 1; cat > code/ocr_par.sh <<'EOF'
#!/bin/bash
cd ~/work/pixelart-studies/books/imhof
export OMP_THREAD_LIMIT=1
ls pages/*.png | while read f; do b=$(basename $f .png); [ -s ocr/$b.txt ] || echo $b; done | \
  xargs -P 3 -I{} sh -c 'nice -n 10 tesseract pages/{}.png ocr/{} -l eng --psm 3 >/dev/null 2>&1'
echo PASSDONE
EOF
chmod +x code/ocr_par.sh; nohup code/ocr_par.sh > ocr/ocr.log 2>&1 & echo ok

# output:
ok
Bash: cd ~/work/pixelart-studies/books/imhof; sleep 90; for i in 008 009 010 011 012 013; do echo "=== $i"; cat ocr/p-$i.txt 2>/dev/null; done
cd ~/work/pixelart-studies/books/imhof; sleep 90; for i in 008 009 010 011 012 013; do echo "=== $i"; cat ocr/p-$i.txt 2>/dev/null; done

# output:
=== 008

2. General appearance of the land surface in nature ............. 000 c ee eeees 75
3. The use of terrain models ... 1.6... 0... cece cece eee nn ence 76
4. Variations in the surface forms of models .............. 000 0ccceeeeecees 76
5. Basic factors affecting the ability to see spatial depth and solidity ............ 78
6. Which of the spatial depth of solidity effects can be used in map design? ....... 79
7. The map is not only a picture. The differences between maps and pictures ..... 79
8. The forms and their dimensions should be capable of comprehension and
measurement. The fiction of the “contour blanket”... 02.0... 000.00 e eee 80

9. Further fictitious indirect methods of representation including combined
techMiques ©... 0... ccc eee eee nent een e teens 80

10. Anexperiment 2.1... . ec cece cnet teen ene 81
11. Conflict and interplay between both approaches to representation.
The progress of the direct technique .... 01.0.0... cee ccc cee eee 83

12. Dualism and individuality of cartographic representation ...............00- 84
13. The generalization and the interplay of the graphic elements................ 85
14. Different circumstances. Different forms. The achievements to be sought after.. 86
Chapter 6
Spot Heights and Soundings... 0.6.0. eee eet e eens 87

1. CONCEPts 2. ee eee ee eee ee eee ee eee eee 87
2. The cartographic significance of spot heights ...............0 020 eee eee eee 88
3. Units of measurement .. 1... 6. eee ete 88
4. Datum Levels 2.0... 0. eee cee eee etn e eens 89
5. The nature of spot height accuracies ....... 0.0.0... cece eee eee ees 92
6. The number and density of spotheights .... 20... 0.0... eee 93
7. Selection of spot heights— general... 0.0.0... 2. ee ees 94
8. Some special CaseS 6... eee eee teen eens 95
@) PASSES ©... ee ee eee teeters 95
b) Tops of church steeples and other high points ...............--00200 08> 95
c) Glaciers andinlandice ............ cece eects 95
d) Streams and rivers ... 6... ee eee eee tenes 96
€) Lakes 1... ee ec eeteeeen ener e tenes 96
1, Lake water level «1.0.0... eee eet 96

2. Spot heights of points on lake bottoms, with reference to sea-level ....... 97
3. Lake depths or deepest points .. 0... 0... eee ee eee 97
f) OceamS 1.6. ee ee eee eee 97

9. Graphic problems ....... 6.06 eee eee eee nent tenes 97
a) Symbolizing the position ofa point ... 0... .. 0... cee cee ee eee 97
b) Positioning of the spot height value .. 1.1... ke eee 97
c) Styles of numerals for the values .. 1.2... ce eee 99
d) Minimum type sizes for sheet maps ......... 0.0... eee eee 99
e) Differentiation of the form of numerals according to position
or nature of measured heights ... 0.0... .. cee ee eee 99
f) Grading of type sizes according to the importance of the points ............ 99
g) Colors of spot heightsand depths ....... 0.0... 0. e ccc cece eee 100

10. Combination of the various height and depth data and the style of their symbols . 101
=== 009
XI Table of Contents

11. Assigning height value
Bash: cd (tmp); curl -s -o t.png -w "%{http_code} %{content_type}\n" "https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/14/8580/5780.jpeg"; fil
cd (tmp); curl -s -o t.png -w "%{http_code} %{content_type}\n" "https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/14/8580/5780.jpeg"; file t.png

# output:
200 image/jpeg
t.png: JPEG image data, JFIF standard 1.01, aspect ratio, density 1x1, segment length 16, baseline, precision 8, 256x256, components 3
Bash: cd ~/work/pixelart-studies/books/imhof; ls pages | wc -l; ls ocr/*.txt | wc -l
cd ~/work/pixelart-studies/books/imhof; ls pages | wc -l; ls ocr/*.txt | wc -l

# output:
300
      31
Bash: cd ~/work/pixelart-studies/books/imhof; for i in $(seq -w 14 30); do f=ocr/p-0$i.txt; [ -f $f ] && echo "$i: $(head -c 80 $f | tr '\n' ' ')"; done
cd ~/work/pixelart-studies/books/imhof; for i in $(seq -w 14 30); do f=ocr/p-0$i.txt; [ -f $f ] && echo "$i: $(head -c 80 $f | tr '\n' ' ')"; done

# output:
14: XVII Table of Contents  11. Shaded hachures and hypsometric tints ..........-- 0
15: CHAPTER 1  Historical Developments  One of the oldest maps which survives today 
16: XVITI Table of Contents  11. Shaded hachures and hypsometrictints ......... 2...
17: 1  -al Developments  Idest maps which survives today is the representation of no
18: XVili Table of Contents  11. Shaded hachures and hypsometrictints ...... 2.0.0..
19: CHAPTER 1  Historical Developments  One of the oldest maps which survives today 
20: 2 Historical Developments  a manuscript Ptolemy atlas, stemming from the year 14
21: Historical Developments 3  dual symbols or rows of symbols. In this way the fish
22: 4 Historical Developments  As a rule, the earliest printed maps — those of the
23: Historical Developments 5  denser lines of the copper engraving permitted a cons
24: 6 Historical Developments  — = Dflafterbach  a —————  UTR "  : Cette
25: Historical Developments 7  1 ‘ . . Aas 258 ee aN As mae ede ad ICC ESF | ARR g
26: 8 Historical Developments  levelling. Copper engraving reached its highest stage
27: Historical Developments 9  Let us turn again to the cartographic forms of terrai
28: 
29: 
30:
Bash: cd (tmp); curl -s "https://data.geo.admin.ch/api/stac/v0.9/collections/ch.swisstopo.swissalti3d/items?bbox=8.00,46.57,8.01,46.58&limit=5" | python3 -c " import json,sys;
cd (tmp); curl -s "https://data.geo.admin.ch/api/stac/v0.9/collections/ch.swisstopo.swissalti3d/items?bbox=8.00,46.57,8.01,46.58&limit=5" | python3 -c "
import json,sys; d=json.load(sys.stdin)
for f in d['features']:
  print(f['id']); [print('  ',k, v['href']) for k,v in f['assets'].items() if '_2_' in k or k.endswith('2_2056_5728.tif')]
" | head -20

# output:
swissalti3d_2019_2643-1157
   swissalti3d_2019_2643-1157_2_2056_5728.tif https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2019_2643-1157/swissalti3d_2019_2643-1157_2_2056_5728.tif
   swissalti3d_2019_2643-1157_2_2056_5728.xyz.zip https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2019_2643-1157/swissalti3d_2019_2643-1157_2_2056_5728.xyz.zip
swissalti3d_2019_2643-1158
   swissalti3d_2019_2643-1158_2_2056_5728.tif https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2019_2643-1158/swissalti3d_2019_2643-1158_2_2056_5728.tif
   swissalti3d_2019_2643-1158_2_2056_5728.xyz.zip https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2019_2643-1158/swissalti3d_2019_2643-1158_2_2056_5728.xyz.zip
swissalti3d_2024_2643-1157
   swissalti3d_2024_2643-1157_2_2056_5728.tif https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2024_2643-1157/swissalti3d_2024_2643-1157_2_2056_5728.tif
   swissalti3d_2024_2643-1157_2_2056_5728.xyz.zip https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2024_2643-1157/swissalti3d_2024_2643-1157_2_2056_5728.xyz.zip
swissalti3d_2024_2643-1158
   swissalti3d_2024_2643-1158_2_2056_5728.tif https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2024_2643-1158/swissalti3d_2024_2643-1158_2_2056_5728.tif
   swissalti3d_2024_2643-1158_2_2056_5728.xyz.zip https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2024_2643-1158/swissalti3d_2024_2643-1158_2_2056_5728.xyz.zip
swissalti3d_2025_2643-1157
   swissalti3d_2025_2643-1157_2_2056_5728.tif https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2025_2643-1157/swissalti3d_2025_2643-1157_2_2056_5728.tif
   swissalti3d_2025_2643-1157_2_2056_5728.xyz.zip https://data.geo.admin.ch/ch.swisstopo.swissalti3d/swissalti3d_2025_2643-1157/swissalti3d_2025_2643-1157_2_2056_5728.xyz.zip
Bash: cd (tmp); curl -s "https://wmts.geo.admin.ch/EPSG/2056/1.0.0/WMTSCapabilities.xml" -o caps.xml; ls -la caps.xml; python3 - <<'EOF' import re t=open('(tmp)
cd (tmp); curl -s "https://wmts.geo.admin.ch/EPSG/2056/1.0.0/WMTSCapabilities.xml" -o caps.xml; ls -la caps.xml; python3 - <<'EOF'
import re
t=open('(tmp)).read()
i=t.find('<TileMatrixSet>'); 
# print tile matrix set 2056_26 or similar
for m in re.finditer(r'<ows:Identifier>(2056_\d+)</ows:Identifier>',t): print(m.group(1))
j=t.find('<ows:Identifier>2056_28</ows:Identifier>')
seg=t[j:j+20000]
for m in re.finditer(r'<ows:Identifier>(\d+)</ows:Identifier>\s*<ScaleDenominator>([\d.]+)</ScaleDenominator>\s*<TopLeftCorner>([\d. ]+)</TopLeftCorner>\s*<TileWidth>(\d+)',seg):
    print(m.groups(), float(m.group(2))*0.00028)
for lay in ['pixelkarte-farbe','swissalti3d-reliefschattierung','pixelkarte-grau','landeskarte']:
    for m in re.finditer(r'<ows:Identifier>(ch\.swisstopo\.[^<]*%s[^<]*)</ows:Identifier>'%lay,t): print(m.group(1))
EOF

# output:
-rw-r--r--  1 user  wheel  1827238 Sep 26 13:37 caps.xml
2056_18
2056_19
2056_20
2056_21
2056_23
2056_24
2056_25
2056_26
2056_27
2056_28
('0', '14285714.285714287', '2420000.0 1350000.0', '256') 4000.0
('1', '13392857.142857144', '2420000.0 1350000.0', '256') 3750.0
('2', '12500000.000000002', '2420000.0 1350000.0', '256') 3500.0
('3', '11607142.857142858', '2420000.0 1350000.0', '256') 3250.0
('4', '10714285.714285715', '2420000.0 1350000.0', '256') 3000.0
('5', '9821428.571428573', '2420000.0 1350000.0', '256') 2750.0
('6', '8928571.42857143', '2420000.0 1350000.0', '256') 2500.0
('7', '8035714.285714286', '2420000.0 1350000.0', '256') 2250.0
('8', '7142857.142857144', '2420000.0 1350000.0', '256') 2000.0
('9', '6250000.000000001', '2420000.0 1350000.0', '256') 1750.0
('10', '5357142.857142857', '2420000.0 1350000.0', '256') 1500.0
('11', '4464285.714285715', '2420000.0 1350000.0', '256') 1250.0
('12', '3571428.571428572', '2420000.0 1350000.0', '256') 1000.0
('13', '2678571.4285714286', '2420000.0 1350000.0', '256') 750.0
('14', '2321428.571428572', '2420000.0 1350000.0', '256') 650.0
('15', '1785714.285714286', '2420000.0 1350000.0', '256') 500.0
('16', '892857.142857143', '2420000.0 1350000.0', '256') 250.0
('17', '357142.85714285716', '2420000.0 1350000.0', '256') 100.0
('18', '178571.42857142858', '2420000.0 1350000.0', '256') 50.0
('19', '71428.57142857143', '2420000.0 1350000.0', '256') 20.0
('20', '35714.28571428572', '2420000.0 1350000.0', '256') 10.0
('21', '17857.14285714286', '2420000.0 1350000.0', '256') 5.0
('22', '8928.57142857143', '2420000.0 1350000.0', '256') 2.5
('23', '7142.857142857143', '2420000.0 1350000.0', '256') 2.0
('24', '5357.142857142858', '2420000.0 1350000.0', '256') 1.5
('25', '3571.4285714285716', '2420000.0 1350000.0', '256') 1.0
('26', '1785.7142857142858', '2420000.0 1350000.0', '256') 0.5
('27', '892.8571428571429', '2420000.0 1350000.0', '256') 0.25
('28', '357.14285714285717', '2420000.0 1350000.0', '256') 0.09999999999999999
ch.swisstopo.pixelkarte-farbe
ch.swisstopo.pixelkarte-farbe
ch.swisstopo.pixelkarte-farbe-pk1000.noscale
ch.swisstopo.pixelkarte-farbe-pk1000.noscale
ch.swisstopo.pixelkarte-farbe-pk100.noscale
ch.swisstopo.pixelkarte-farbe-pk100.noscale
ch.swisstopo.pixelkarte-farbe-pk200.noscale
ch.swisstopo.pixelkarte-farbe-pk200.noscale
ch.swisstopo.pixelkarte-farbe-pk25.noscale
ch.swisstopo.pixelkarte-farbe-pk25.noscale
ch.swisstopo.pixelkarte-farbe-pk500.noscale
ch.swisstopo.pixelkarte-farbe-pk500.noscale
ch.swisstopo.pixelkarte-farbe-pk50.noscale
ch.swisstopo.pixelkarte-farbe-pk50.noscale
ch.swisstopo.pixelkarte-farbe-winter
ch.swisstopo.pixelkarte-farbe-winter
ch.swisstopo.swissalti3d-reliefschattierung
ch.swisstopo.swissalti3d-reliefschattierung
ch.swisstopo.swissalti3d-reliefschattierung_monodirektional
ch.swisstopo.swissalti3d-reliefschattierung_monodirektional
ch.swisstopo.pixelkarte-grau
ch.swisstopo.pixelkarte-grau
ch.swisstopo.pixelkarte-grau-pk1000.noscale
ch.swisstopo.pixelka
Bash: mkdir -p ~/work/pixelart-studies/books/imhof/refs/swiss; cat > ~/work/pixelart-studies/books/imhof/code/swissfetch.py <<'EOF' """Fetch swisstopo WMTS (LV95 / EPSG:2056) tiles for a
mkdir -p ~/work/pixelart-studies/books/imhof/refs/swiss; cat > ~/work/pixelart-studies/books/imhof/code/swissfetch.py <<'EOF'
"""Fetch swisstopo WMTS (LV95 / EPSG:2056) tiles for a bbox and mosaic them.
usage: python swissfetch.py LAYER LEVEL E0 N0 E1 N1 out.png [ext]
levels: 22=2.5 m/px 23=2 24=1.5 25=1 26=0.5 m/px (see WMTS caps)."""
import sys, io, os, urllib.request, math
from PIL import Image
RES = {16: 250, 17: 100, 18: 50, 19: 20, 20: 10, 21: 5, 22: 2.5, 23: 2, 24: 1.5, 25: 1, 26: 0.5, 27: 0.25}
def fetch(layer, lvl, E0, N0, E1, N1, out, ext='jpeg', cache='refs/swiss/tiles'):
    r = RES[lvl]; T = 256 * r
    x0, x1 = int((E0 - 2420000) // T), int((E1 - 2420000) // T)
    y0, y1 = int((1350000 - N1) // T), int((1350000 - N0) // T)
    W, H = (x1 - x0 + 1) * 256, (y1 - y0 + 1) * 256
    mos = Image.new('RGB', (W, H), (255, 0, 255))
    os.makedirs(cache, exist_ok=True)
    for ty in range(y0, y1 + 1):
        for tx in range(x0, x1 + 1):
            fn = f'{cache}/{layer}_{lvl}_{tx}_{ty}.{ext}'
            if not os.path.exists(fn):
                url = f'https://wmts.geo.admin.ch/1.0.0/{layer}/default/current/2056/{lvl}/{tx}/{ty}.{ext}'
                try:
                    data = urllib.request.urlopen(url, timeout=30).read(); open(fn, 'wb').write(data)
                except Exception as e:
                    print('fail', url, e); continue
            mos.paste(Image.open(fn).convert('RGB'), ((tx - x0) * 256, (ty - y0) * 256))
    # crop to bbox exactly
    ox = (E0 - 2420000 - x0 * T) / r; oy = (1350000 - N1 - y0 * T) / r
    mos = mos.crop((round(ox), round(oy), round(ox + (E1 - E0) / r), round(oy + (N1 - N0) / r)))
    mos.save(out); return mos
if __name__ == '__main__':
    a = sys.argv
    fetch(a[1], int(a[2]), *map(float, a[3:7]), a[7], *(a[8:9]))
EOF
cd ~/work/pixelart-studies/books/imhof; python3 code/swissfetch.py ch.swisstopo.pixelkarte-farbe-pk25.noscale 25 2642500 1157000 2643500 1158000 refs/swiss/test_eiger_pk25.png && python3 -c "from PIL import Image; print(Image.open('refs/swiss/test_eiger_pk25.png').size)"

# output:
(1000, 1000)
Read: books/imhof/refs/swiss/test_eiger_pk25.png

books/imhof/refs/swiss/test_eiger_pk25.png 3. books/imhof/refs/swiss/test_eiger_pk25.png

Bash: cd ~/work/pixelart-studies/books/imhof; ls pages | wc -l; ls ocr/*.txt | wc -l; for f in ocr/p-*.txt; do h=$(head -c 60 $f | tr '\n' ' '); echo "$(basename $f .txt) $h"; done | sed
cd ~/work/pixelart-studies/books/imhof; ls pages | wc -l; ls ocr/*.txt | wc -l; for f in ocr/p-*.txt; do h=$(head -c 60 $f | tr '\n' ' '); echo "$(basename $f .txt) $h"; done | sed -n 30,200p | awk 'length($0)>8' | head -120

# output:
360
      44
p-029 Historical Developments 11  In France, Switzerland and to a 
p-030 12 Historical Developments  Soon after, in 1730, there appea
p-031 Historical Developments 13  plane table and tacheometry were
p-032 14 Historical Developments  References: 8, 9, 10, 11, 12, 13
p-033 CHAPTER 2  The Topographic Foundations  Topographic surveys 
p-034 16 The Topographic Foundations  mined ground control is dens
p-035 A. Methods of topographic survey 17  There are differing opi
p-036 18 The Topographic Foundations  allows the coordinates of a 
p-037 B. Accuracy in surveying terrain surfaces 19  Spot heights, 
p-038 20 The Topographic Foundations  The arithmetic mean A of the
p-039 B. Accuracy in surveying terrain surfaces 21  1. Mean errors
p-040 22 The Topographic Foundations  3. Mean errors of ground poi
Bash: cd ~/work/pixelart-studies/books/imhof; cat ocr/p-0{19,20,21,22,23,24,25,26,27,28,29,30,31,32}.txt | grep -v '^\s*$' | cut -c1-200
cd ~/work/pixelart-studies/books/imhof; cat ocr/p-0{19,20,21,22,23,24,25,26,27,28,29,30,31,32}.txt | grep -v '^\s*$' | cut -c1-200

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

Preview (first 2KB):
CHAPTER 1
Historical Developments
One of the oldest maps which survives today is the representation of northern Mesopotamia,
scratched into an earthenware plate, dating from about 2400-2200 BC. Already this map
shows mountains; portrayed from the side, as they would be seen when looking up from a
valley. Such lateral portrayals appear, again, in the maps of medieval monks, and in Porto-
lan charts and in copies of lost Ptolemaeic and Roman maps. They were generously em-
ployed during the Renaissance, when cartography began to flourish, and they prevailed in
many subsequent maps up to the end of the 18th century. Even today we still occasionally
come across this easily understood graphic form. Thus, a method of representation, corre-
sponding directly to the natural appearance of the terrain, to the rise and fall of the profile of
the land, was maintained throughout the centuries. In these early days, there existed neither
the requirement nor the technical capability for depicting mountains more precisely and in
plan form. In their figurative details, maps were pictures of scenes, as were other representa-
tions of the landscape. They may even have been the earliest landscape sketches, with little
pictures of cities, castles, monasteries, forests and mountains shown juxtaposed or even
overlapping upon a planimetric representation of the Earth’s surface. To begin with, moun-
tains on the oldest maps were merely hinted at, or included as watershed symbols or as
decorative filling for empty spaces. Despite such representational constraints, remarkable
variations and developments can be detected in the early cartographic portrayals of moun-
tains; not only in the individual symbols themselves but also in their arrangement. In the
maps of medieval monks we find every conceivable style and variation of the Gothic era;
with heart-shaped, lancet-shaped, leaf-shaped, and arch-shaped mountain symbols. In 1910
...
</persisted-output>
Read: ~/.claude/projects/-Users-user-work/0566fefe-e974-459d-8d5d-e71202e4c72f/tool-results/bd8ssycn7.txt
Bash: cd ~/work/pixelart-studies/books/imhof; cat > code/view.py <<'EOF' """view a page (or crop) downscaled for reading. usage: view.py PAGE [x0 y0 x1 y1] [width]""" import sys from PIL
cd ~/work/pixelart-studies/books/imhof; cat > code/view.py <<'EOF'
"""view a page (or crop) downscaled for reading. usage: view.py PAGE [x0 y0 x1 y1] [width]"""
import sys
from PIL import Image
p = int(sys.argv[1]); im = Image.open(f'pages/p-{p:03d}.png').convert('L')
a = sys.argv[2:]
if len(a) >= 4: im = im.crop(tuple(int(v) for v in a[:4])); a = a[4:]
w = int(a[0]) if a else 1000
if im.width > w: im = im.resize((w, round(im.height * w / im.width)), Image.LANCZOS)
im.save('(tmp)); print(im.size)
EOF
python3 code/view.py 30 0 0 2100 2000 1100

# output:
(1100, 1048)
Read: (tmp)

(tmp) 4. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/view.py 30 0 1900 2100 3400 1100
cd ~/work/pixelart-studies/books/imhof; python3 code/view.py 30 0 1900 2100 3400 1100

# output:
(1100, 786)
Read: (tmp)

(tmp) 5. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt | wc -l; cat > NOTEBOOK.md <<'EOF' # Imhof apprenticeship: working notebook Working thread, newest at bottom. Page refs: "p.N"
cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt | wc -l; cat > NOTEBOOK.md <<'EOF'
# Imhof apprenticeship: working notebook

Working thread, newest at bottom. Page refs: "p.N" = printed page of the 1982 English edition;
"f-NNN" = file page index (pages/p-NNN.png). Early on f = p + 18.

## 2026-09-26

**Source quality.** The PDF is a Digital Library of India scan: 405 pages, every page a 1-bit CCITT
bilevel image at 300 dpi. Line art (hachures, rock drawing, contours) survives perfectly. Every
halftone (shading plates, photographs) and every colour plate is thresholded to black/white blotches:
the shading plates are effectively destroyed. So: for relief shading and colour the book's TEXT and
diagrams carry the teaching; for the look of the Swiss shading and rock drawing I use swisstopo's
official maps (the living descendant of Imhof's school; he led their design) fetched from the public
WMTS, plus swissALTI3D 2 m DEM of the same ground (code/swissfetch.py).

**Studio reading done:** deer-elk POST, aerial POST, master-copy POST, observation TUTORIAL (part),
rock LESSONS (all), snowpeak NOTEBOOK (head). Habits taken: the board (reference | mine, same scale),
print index maps as text, notan, box-downsample references to pixel size, fresh critics, one truth
at a time, keep the 8x comparison as a test.

### Ch.1 History (p.1-14)

- molehill profiles -> planimetric (Gyger 1667, first relief map, painted, hidden as military secret).
- Lehmann 1799: slope hachures, "the steeper, the darker", strokes down the fall line, broken into
  sections whose ends lie on (imaginary) contours; thickness ~ slope.
- French/Swiss: left (NW) illumination -> fine strokes on lit slopes, heavy on shaded: the shadow
  hachure (Dufour map 1842-64, Fig.14): "finest and clearest map of any high mountain region".
- Lithography (1840s) -> colour, shading tones, hypsometric tints; Swiss "Reliefkarten" from ~1870:
  contours + rock drawing + oblique shading + "aerial perspective colour tones".
EOF
echo ok

# output:
52
ok
Bash: cd ~/work/pixelart-studies/books/imhof; cat > code/spread.py <<'EOF' """N pages side by side at given width each. usage: spread.py out.png W p1 p2 ...""" import sys from PIL import
cd ~/work/pixelart-studies/books/imhof; cat > code/spread.py <<'EOF'
"""N pages side by side at given width each. usage: spread.py out.png W p1 p2 ..."""
import sys
from PIL import Image
out, W = sys.argv[1], int(sys.argv[2]); ps = [int(x) for x in sys.argv[3:]]
ims = []
for p in ps:
    im = Image.open(f'pages/p-{p:03d}.png').convert('L'); ims.append(im.resize((W, round(im.height * W / im.width)), Image.LANCZOS))
H = max(i.height for i in ims); s = Image.new('L', (W * len(ims) + 10 * (len(ims) - 1), H), 128)
for k, i in enumerate(ims): s.paste(i, (k * (W + 10), 0))
s.save(out)
EOF
python3 code/spread.py (tmp) 560 58 59 60 61 62

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 6. (tmp)

Bash: cd ~/work/voxsim-data/places; cat enchant/REQUEST-from-lane-place-enchant.md | head -40; ls enchant/pack enchant/src enchant/lane | head; du -sh enchant yakima skykomish
cd ~/work/voxsim-data/places; cat enchant/REQUEST-from-lane-place-enchant.md | head -40; ls enchant/pack enchant/src enchant/lane | head; du -sh enchant yakima skykomish

# output:
# From lane-place-enchant to lane-place-data (2026-09-26)

Not a spec, just what I'll ask of the importer for the Enchantments. Ignore
anything that fights your design; tell me in README.md and I'll adapt.

## Footprints I'd like (WGS84; your packs are UTM 10N, convert as you like)

1. **enchant_upper** (the main world): 3.0 km E-W x 2.0 km N-S,
   lon -120.834 .. -120.794, lat 47.4665 .. 47.4845.
   Dragontail's summit ridge (W edge) and Aasgard Pass, Isolation, Tranquil,
   Brisingamen lakelets, Snow Creek Glacier, Little Annapurna (S), Inspiration,
   Crystal Lake, Perfection (E edge). 6144 x 4096 columns at 0.5 m.
2. **enchant_iso** (my fast iteration world): 1.0 km square centred on
   47.4768, -120.8165 (Isolation + Tranquil + Aasgard top + the glacier
   under Dragontail's east face). 2048 x 2048.

Relief: ground runs from ~2040 m (Crystal Lake) to 2695 m (Dragontail), so
~1320 voxels of relief in (1); ~2250-2600 m (~700 voxels) in (2). Any
vertical offset / base elevation is fine as long as I can read the real
elevation back (I key vegetation and snow to real metres).

## Layers I'll use if you have them

- 1 m lidar ground (bare earth), hydroflattened lake surfaces + lake outline
  polygons (OSM has every named tarn here; NHD 1702 Wenatchee subregion too).
- Glacier / perennial snow outlines (OSM `natural=glacier`, NHD ice masses).
- Geology: it's all Mount Stuart batholith tonalite/granodiorite in this
  footprint (Tabor et al. 1987, Wenatchee 1:100k). One rock is fine.
- Land cover at 10 m (bare / sparse trees / snow) as a hint for tree density.

Everything finer (boulders, talus, soils, vegetation, snow, the 1700
translation) is mine.
enchant/lane:
iso
upper

enchant/pack:
cc
chm
elev
esrc
evt
1.0G    enchant
1.7G    yakima
5.7G    skykomish
Bash: cat (tmp) cd ~/work/voxsim-data/places; ls enchant/pack/elev | head; ls ench
cat (tmp) cd ~/work/voxsim-data/places; ls enchant/pack/elev | head; ls enchant/lane/iso enchant/lane/upper | head -20; ls yakima/pack skykomish/pack

# output:
405
 57M    pages

[exited with code 0]
658_5256.zst
658_5257.zst
658_5258.zst
658_5259.zst
658_5260.zst
658_5261.zst
658_5262.zst
658_5263.zst
658_5264.zst
658_5265.zst
enchant/lane/iso:
ao.u8
class.u8
cover.f32
detail.u8
meta.json
milk.u8
nrm.u8
plants.json
surface.f32
water.f32

enchant/lane/upper:
ao.u8
class.u8
cover.f32
meta.json
plants.json
surface.f32
water.f32
skykomish/pack:
cc
chm
elev
esrc
evt
geol
geol_legend.json
lc
place.json
soil
soil_legend.json
streamda
water
wdepth
wsurf

yakima/pack:
cc
chm
elev
esrc
evt
geol
geol_legend.json
lc
place.json
soil
soil_legend.json
streamda
water
wdepth
wsurf
Bash: cd ~/work/voxsim-data/places; cat yakima/pack/place.json | head -40; cat enchant/lane/iso/meta.json | head -30; cat README.md | head -60
cd ~/work/voxsim-data/places; cat yakima/pack/place.json | head -40; cat enchant/lane/iso/meta.json | head -30; cat README.md | head -60

# output:
{
 "slug": "yakima",
 "name": "Yakima River Canyon (Ellensburg to Selah)",
 "crs": "EPSG:26910",
 "vertical": "NAVD88 metres",
 "tile_m": 1000,
 "bbox_lonlat": [
  -120.58,
  46.65,
  -120.4,
  46.96
 ],
 "tiles": [
  [
   684,
   5169
  ],
  [
   684,
   5170
  ],
  [
   684,
   5171
  ],
  [
   684,
   5172
  ],
  [
   684,
   5173
  ],
  [
   684,
   5174
  ],
  [
   684,
   5175
{
 "name": "enchant_iso",
 "nx": 2048,
 "ny": 2048,
 "vox": 0.5,
 "e0": 663638.0,
 "n0": 5259968.0,
 "base_m": 2273.0,
 "nz": 864,
 "bedrock": "granite",
 "lat": 47.477,
 "paint_style": "alpine_granite"
}# voxsim places: real Washington land for voxsim worlds

One line per place when it is ready (lane place-data appends; the place lanes watch this file).
Load any place with `--place SLUG[@SPOT | @E,N (UTM 10N m) | @LAT,LON]`:

    vox-app --place enchant@colchuck_lake --offline            # viewer (vox-play takes the same flag)
    vox-app --place yakima@reds --headless --offline --ticks 60
    # vox-studio / vox-reel: [worlds.X] place = "skykomish@index"   (see crates/vox-studio/framings/places.toml)
    # tools not plumbed with a flag: VOX_PLACE=sanjuan@lime_kiln

Without `--size` the world is 2048x2048 columns (1.024 km square, 0.5 m voxels) and its height is fitted
to the land in that window (mountain windows are ~1100-2000 voxels tall). Code: branch lane-place-data
(crates/vox-place, crates/vox-geo/src/place.rs). Data layout, sources, licences, caveats: NOTES.md in
~/work/voxsim-wt/place-data/crates/vox-place/NOTES.md. Spots per place: pack/place.json "spots".

Pack per place: `SLUG/pack/` = place.json + 1 km tiles (EPSG:26910, NAVD88 m): elev (1 m), esrc, water, wdepth,
streamda (1 m), geol (2 m, legend in place.json), lc (ESA WorldCover 10 m), chm (Meta/WRI canopy height 1 m),
evt + cc (LANDFIRE LF2024 30 m). Vectors: SLUG/hydro.gpkg (NHDPlus HR), SLUG/geology.gpkg (WA DNR). Raw downloads: SLUG/src/.

**Branch status (2026-09-26):** lane-place-data has master merged through 71d368a (Tommy's worldgen cache). Place worlds go
through that cache: the key holds the place selection, the window corner and the pack stamp (place.json size+mtime), and
GEN_HASH now covers vox-place, so a different spot or a rebuilt pack never loads a stale world. Checked on Modal:
generated, cached and `--no-cache` enchant@inspiration_lake worlds give identical agent counts (24462). Place lanes can merge
lane-place-data now.

## Where the data lives (S3 bucket `civearth-files`, us-east-1)

Raw sources are in S3 under `voxsim/data/<source>/` (copied from the public origin by a Modal job,
places/tools/s3/to_s3.py, sizes checked against the local copies). Locally we keep what the importer and the
place lanes read day to day: the packs (`SLUG/pack/`), the per-place vectors (`SLUG/hydro.gpkg`,
`SLUG/geology.gpkg`, `SLUG/soils.gpkg`), historical maps (`SLUG/src/histtopo`, `sanjuan/src/tsheets`),
orthophotos (`SLUG/ortho`) and shoreline obliques (`SLUG/obliques`).

| S3 prefix | What | Local cop
Bash: grep -n -i "zst\|dtype\|float\|header" ~/work/voxsim-wt/place-data/crates/vox-place/NOTES.md | head -20; ls -la ~/work/voxsim-data/places/enchant/pack/elev | head -3; python3 -c "i
grep -n -i "zst\|dtype\|float\|header" ~/work/voxsim-wt/place-data/crates/vox-place/NOTES.md | head -20; ls -la ~/work/voxsim-data/places/enchant/pack/elev | head -3; python3 -c "import zstandard; print('zstd ok')"

# output:
total 1173184
drwxr-xr-x  194 user  staff     6208 Sep 26 09:00 .
drwxr-xr-x   17 user  staff      544 Sep 26 12:24 ..
zstd ok
Bash: ls ~/work/voxsim-data/places/tools/; grep -rn "zst" ~/work/voxsim-data/places/tools/*.py | head -10
ls ~/work/voxsim-data/places/tools/; grep -rn "zst" ~/work/voxsim-data/places/tools/*.py | head -10

# output:
__pycache__
after.sh
after2.sh
after3.sh
fetch.sh
look.py
mkurls.py
obliques.py
ortho.py
pack.py
packall.sh
pair.py
places.py
resume.sh
s3
soils.py
subpack.py
tnm.py
tnm2.py
~/work/voxsim-data/places/tools/pack.py:6:Per place: places/<slug>/pack/place.json + pack/<layer>/<Ekm>_<Nkm>.zst, each tile the
~/work/voxsim-data/places/tools/pack.py:16:import zstandard
~/work/voxsim-data/places/tools/pack.py:44:    return f"{PL}/{slug}/pack/{layer}/{e}_{n}.zst"
~/work/voxsim-data/places/tools/pack.py:51:    data = zstandard.ZstdCompressor(level=6).compress(arr.tobytes())
~/work/voxsim-data/places/tools/pack.py:63:    raw = zstandard.ZstdDecompressor().decompress(open(p, "rb").read())
~/work/voxsim-data/places/tools/subpack.py:24:            src = f"{PL}/{slug}/pack/{layer}/{a}_{b}.zst"
Bash: sed -n 1,80p ~/work/voxsim-data/places/tools/pack.py
sed -n 1,80p ~/work/voxsim-data/places/tools/pack.py

# output:
"""Build a place pack: aligned 1 km tiles (EPSG:26910, NAVD88 m) that the voxsim
importer (crates/vox-place) reads.

    .venv/bin/python places/tools/pack.py <slug> [layer ...]     (layers: elev water geol lc soil)

Per place: places/<slug>/pack/place.json + pack/<layer>/<Ekm>_<Nkm>.zst, each tile the
raw little-endian array (north-up rows) of the 1 km square whose SW corner is
(Ekm*1000, Nkm*1000). Resumable: finished tiles are skipped (rename-on-complete).
"""
import glob, json, math, os, re, sys, time
from multiprocessing import Pool
import numpy as np
import rasterio
from rasterio.warp import reproject, Resampling, transform as wtransform, transform_bounds
from rasterio.transform import from_origin
import zstandard

sys.path.insert(0, os.path.dirname(__file__))
from places import PLACES

ROOT = os.path.expanduser("~/work/voxsim-data")
PL = f"{ROOT}/places"
CRS = "EPSG:26910"
TILE = 1000
LAYERS = {
    # name: (res m, dtype, nodata)
    "elev": (1.0, "float32", float("nan")),
    "esrc": (1.0, "uint8", 0),
    "water": (1.0, "uint8", 0),
    "wdepth": (1.0, "uint8", 0),
    "streamda": (1.0, "float32", 0.0),
    "wsurf": (1.0, "float32", float("nan")),
    "geol": (2.0, "uint16", 0),
    "lc": (10.0, "uint8", 0),
    "soil": (2.0, "uint16", 0),
    "chm": (1.0, "uint8", 0),
    # LANDFIRE is 30 m; stored at 1000/34 = 29.4 m so a tile holds whole pixels
    "evt": (1000 / 34, "uint16", 0),
    "cc": (1000 / 34, "uint8", 0),
}


def tile_path(slug, layer, e, n):
    return f"{PL}/{slug}/pack/{layer}/{e}_{n}.zst"


def write_tile(slug, layer, e, n, arr):
    p = tile_path(slug, layer, e, n)
    os.makedirs(os.path.dirname(p), exist_ok=True)
    arr = np.ascontiguousarray(arr.astype(LAYERS[layer][1]))
    data = zstandard.ZstdCompressor(level=6).compress(arr.tobytes())
    with open(p + ".part", "wb") as f:
        f.write(data)
    os.replace(p + ".part", p)


def read_tile(slug, layer, e, n):
    p = tile_path(slug, layer, e, n)
    if not os.path.exists(p):
        return None
    res, dt, _ = LAYERS[layer]
    k = int(round(TILE / res))
    raw = zstandard.ZstdDecompressor().decompress(open(p, "rb").read())
    return np.frombuffer(raw, dtype=dt).reshape(k, k)


def tile_transform(e, n, res):
    return from_origin(e * TILE, (n + 1) * TILE, res, res)


# ----------------------------------------------------------------- extent


def utm_rect(bbox):
    lo0, la0, lo1, la1 = bbox
    xs, ys = wtransform("EPSG:4326", CRS, [lo0, lo1, lo0, lo1], [la0, la0, la1, la1])
    return min(xs), min(ys), max(xs), max(ys)


def place_tiles(slug):
Bash: python3 -c "import rasterio; print(rasterio.__version__)"; python3 -c "import tifffile; print('tifffile')"; python3 -c "import numba; print('numba', numba.__version__)"; python3 -c
python3 -c "import rasterio; print(rasterio.__version__)"; python3 -c "import tifffile; print('tifffile')"; python3 -c "import numba; print('numba', numba.__version__)"; python3 -c "import skimage; print(skimage.__version__)"; python3 -c "import cv2; print(cv2.__version__)"

# output:
1.4.3
tifffile
numba 0.61.2
0.25.2
4.10.0
Bash: cat > ~/work/pixelart-studies/books/imhof/code/dem.py <<'EOF' """DEM access for the Imhof study. wa(slug, E0, N0, E1, N1) -> 1 m float32 array (north-up) from the voxsim place
cat > ~/work/pixelart-studies/books/imhof/code/dem.py <<'EOF'
"""DEM access for the Imhof study.
wa(slug, E0, N0, E1, N1)      -> 1 m float32 array (north-up) from the voxsim place packs (UTM 10N)
swiss(E0, N0, E1, N1)         -> 2 m float32 array from swissALTI3D (LV95), cached tiles
"""
import os, json, urllib.request
import numpy as np, zstandard
PL = os.path.expanduser('~/work/voxsim-data/places')
HERE = os.path.dirname(os.path.abspath(__file__))
CACHE = os.path.join(HERE, '..', 'refs', 'swiss', 'dem')

def _wa_tile(slug, e, n):
    p = f'{PL}/{slug}/pack/elev/{e}_{n}.zst'
    if not os.path.exists(p): return None
    raw = zstandard.ZstdDecompressor().decompress(open(p, 'rb').read())
    return np.frombuffer(raw, np.float32).reshape(1000, 1000)

def wa(slug, E0, N0, E1, N1):
    E0, N0, E1, N1 = map(int, (E0, N0, E1, N1))
    out = np.full((N1 - N0, E1 - E0), np.nan, np.float32)
    for e in range(E0 // 1000, (E1 - 1) // 1000 + 1):
        for n in range(N0 // 1000, (N1 - 1) // 1000 + 1):
            t = _wa_tile(slug, e, n)
            if t is None: continue
            # tile rows north-up: row 0 = N (n+1)*1000
            xa, xb = max(E0, e * 1000), min(E1, (e + 1) * 1000)
            ya, yb = max(N0, n * 1000), min(N1, (n + 1) * 1000)
            out[N1 - yb:N1 - ya, xa - E0:xb - E0] = t[(n + 1) * 1000 - yb:(n + 1) * 1000 - ya, xa - e * 1000:xb - e * 1000]
    return out

def _swiss_tile(e, n):
    os.makedirs(CACHE, exist_ok=True)
    fn = f'{CACHE}/{e}_{n}.tif'
    if not os.path.exists(fn):
        url = f'https://data.geo.admin.ch/api/stac/v0.9/collections/ch.swisstopo.swissalti3d/items?bbox={0},{0},{0},{0}'
        # find newest item id for this km tile
        q = urllib.request.urlopen(f'https://data.geo.admin.ch/api/stac/v0.9/collections/ch.swisstopo.swissalti3d/items?limit=100&bbox=' +
            ','.join(map(str, _lv95_to_wgs_bbox(e, n))), timeout=60).read()
        items = [f for f in json.loads(q)['features'] if f['id'].endswith(f'_{e}-{n}')]
        if not items: return None
        it = sorted(items, key=lambda f: f['id'])[-1]
        href = [a['href'] for k, a in it['assets'].items() if k.endswith('_2_2056_5728.tif')][0]
        data = urllib.request.urlopen(href, timeout=120).read(); open(fn + '.part', 'wb').write(data); os.replace(fn + '.part', fn)
    import tifffile
    return tifffile.imread(fn).astype(np.float32)  # 500x500, north-up

def _lv95_to_wgs_bbox(e, n):
    from pyproj import Transformer
    t = Transformer.from_crs('EPSG:2056', 'EPSG:4326', always_xy=True)
    lo0, la0 = t.transform(e * 1000 + 300, n * 1000 + 300); lo1, la1 = t.transform(e * 1000 + 700, n * 1000 + 700)
    return lo0, la0, lo1, la1

def swiss(E0, N0, E1, N1):
    """2 m grid, cells aligned to 2 m. rows north-up."""
    E0, N0, E1, N1 = map(int, (E0, N0, E1, N1))
    out = np.full(((N1 - N0) // 2, (E1 - E0) // 2), np.nan, np.float32)
    for e in range(E0 // 1000, (E1 - 1) // 1000 + 1):
        for n in range(N0 // 1000, (N1 - 1) // 1000 + 1):
            t = _swiss_tile(e, n)
            if t is None: continue
            xa, xb = max(E0, e * 1000), min(E1, (e + 1) * 1000)
            ya, yb = max(N0, n * 1000), min(N1, (n + 1) * 1000)
            out[(N1 - yb) // 2:(N1 - ya) // 2, (xa - E0) // 2:(xb - E0) // 2] = \
                t[((n + 1) * 1000 - yb) // 2:((n + 1) * 1000 - ya) // 2, (xa - e * 1000) // 2:(xb - e * 1000) // 2]
    return out
EOF
cd ~/work/pixelart-studies/books/imhof/code && python3 -c "
import dem, numpy as np
z=dem.swiss(2642500,1157000,2643500,1158000); print(z.shape, np.nanmin(z), np.nanmax(z))
w=dem.wa('enchant',663638,5259968,664662,5260992); print(w.shape, np.nanmin(w), np.nanmax(w))
"

# output:
(500, 500) 3089.4219 3768.7998
(1024, 1024) 2279.4043 2679.849
Bash: cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt | wc -l
cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt | wc -l

# output:
73
Bash: cd ~/work/pixelart-studies/books/imhof; for i in $(seq 33 62); do cat ocr/p-0$i.txt; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-160 > (tmp) wc -l /tm
cd ~/work/pixelart-studies/books/imhof; for i in $(seq 33 62); do cat ocr/p-0$i.txt; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-160 > (tmp) wc -l (tmp) sed -n '1,400p' (tmp) | grep -n -i -E "edge|generaliz|natur|styliz|form|skelet|break" | head -60

# output:
978 (tmp)
2:Topographic surveys form the basis of all terrain cartography or, for that matter, any
8:survey methods. A few points of information, however, may not be out of place; for even
10:map design, for instance, require some knowledge of survey methods, and above all of the
13:Questions concerning the generalization of contour lines, for example, can only be discussed
16:All,recent topographic surveys present the form of the earth’s surface through spot-heights,
17:certain edge or major form lines, but primarily by contours. We will return in later chapters
29:reference to these points, the contour lines are drawn directly from nature. A good topo-
30:grapher chooses, in the first instance, points which lie at breaks in the slope and points of
41:The detailed formation of the contour pattern was accomplished by visual interpolation.
49:the topographer’s gift for observation and understanding and experience of land forms. Such
52:characteristic “‘signature” of the maker.
60:table method in that the survey data are not kept in graphic form, but initially noted in field
71:weather; the former, however, gives rise to more reliable, accurate, and above all, more
98:Further information on air-photography and its qualities are discussed in Chapter 3.
120:Photogrammetry gives us the entire extent of all terrain lines, terrain edges, stream lines,
140:changes our knowledge of the earth’s surface in a fundamental way. Terrestrial photogram-
146:spot heights, by the planimetry of certain edge lines (drainage lines; edges of rocks, etc.), but,
150:Spot heights, edge lines and contour lines will be treated separately within this question,
173:measurement l is defined (transcribed according to its significance) by the formula:
178:substitute or approximation formula for the calculation of m,. This is
181:This formula is arrived at by way of certain knowledge (not discussed here) of the theory of
191:These relationships are expressed by the following formula:
192:For combined measurement and calculation operations, analogous formulae are operative
272:uniformly in error in such a fashion, no disadvantages result from it in regional use. A
285:2. The accuracy of edge lines
286:Edge lines, such as drainage lines, shorelines, terrace edges, rock edges, edges of ditches,
291:In photogrammetric surveys, edge lines generally achieve almost drawing accuracy stand-
295:Using point-by-point survey with plane table or tacheometer, the edge lines are plotted by
296:visual estimation which may then, of course, lead to large errors in form and, in large-scale
307:rule, extremely complex surfaces which cannot be expressed by mathematical formulae.
309:mentally to the scale of the map to be studied. The other surface is formed by the contour
311:these two areas do not match each other exactly. We wish to be able to judge the nature and
316:bands of contour lines form a lattice with intermediate spaces. Between each two consecu-
333:but also for nature 
Bash: sed -n 325,400p (tmp) echo ----; sed -n 900,978p (tmp)
sed -n 325,400p (tmp) echo ----; sed -n 900,978p (tmp)

# output:
map of the test area being used. This contour line image is then compared with the contours
Such a method has proven to be very costly, so that up to now it has been used only
occasionally for methodical experiments. (We are not referring here to investigations of
historical maps in which older maps are checked in this manner by comparing them with
corresponding modern, more accurate surveys.) .
The labor and cost involved are not the only disadvantages of such techniques of testing.
One special difficulty appears in what follows: The differences between the image to be
checked and the control image should not be evaluated quantitatively, for dimension only,
but also for nature and quality. This is illustrated with the help of Figure 15.
24 The Topographic Foundations
~~~ ~~~ a false contourA-A
nee, To... another false contour B-B
Figure 15. Two different false contours and a control contour.
This figure shows the same section of a contour line three times. The control cont
is assumed to be free of error in shape and position (within drawing accuracy).
Although containing errors, the contour A-A fits well to the main form of the co
K, with little deviation although all the small details are omitted.
The incorrect contour B-B, on the other hand, shows considerable deviation f{
broad form K-K, but is very accurate in the smaller details.
Which, then, is more important? Which of the two virtues is more valuable? Whi
transgressions should be more sharply condemned? This is a matter of judgment f
case to another, which can be answered only with due consideration of the main pur
which the map will be used. As a rule, the more true-to-form arrangement is better :
the hiker, the mountaineer, the soldier, and above all the geomorphologist, while
with good positional accuracy is better for the construction engineer and the survey
In contrast to most geodetic considerations, concepts of error and quality in tl
graphic-cartographic field can be ambiguous, not always being satisfied by mea:
quantities. The critical examination depends on quantities and qualities of various ty
is therefore not free of a certain degree of subjectivity.
This arises also from a further characteristic of many cartographic contour lines.
Many contour lines, particularly in boulder strewn areas, highly dissected regior
regions, Karren, glacier surfaces, areas of dunes, etc., would appear confusingly co
the highest possible accuracies of technical survey and drawing were applied. In
stances the contour line images are often slightly simplified during survey. On wh
then, should checks be made in cases like these? Should one employ the most o
precise form possible for comparison; or its more subjective, simplified form, adju
cartographic clarity? If the concept of a “‘check”” means the same as the determin
quality or of usefulness, then it is only reasonable to select the simplified form. The
of a mathematical problem is either right or wrong, but the solution of a problem inc
phy is, 
Bash: cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt | wc -l; for i in $(seq 62 76); do f=ocr/p-0$i.txt; [ -f $f ] && cat $f; done | grep -v '^\s*$' | awk 'length($0)>20' | cut -c1
cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt | wc -l; for i in $(seq 62 76); do f=ocr/p-0$i.txt; [ -f $f ] && cat $f; done | grep -v '^\s*$' | awk 'length($0)>20' | cut -c1-160

# output:
77
Further Basic Principles and Guidance
In this chapter, several additional skills and fields of knowledge which are of great value,
perhaps even indispensable, to a map compiler, and especially to someone concerned with
rendering the form of the terrain, will be considered. These are the study of topography in
both terrain and map, a training in drawing, practice in the interpretation of aerial photos and
knowledge of geography and geomorphology.
Even the theory of colors and the knowledge of graphic reproduction are among the basic
facts required by any mapmaker. These will be discussed in later chapters.
A. The study of topography in terrain and maps
On account of his profession, the cartographer is a sedentary being. His work chains him to
the drawing board, but his inspiration is the landscape and thus he should become
acquainted with its characteristic forms and colors not only from books, but from nature
itself, and he should also be able to find his way around in the graphic thickets of the map he
uses just as he can in unfamiliar terrain. He should be able to read maps perfectly. Even with
the most confusing contour map, he should be able to identify the terrain forms easily and
confidently. He should not allow himself to be misled by deficiencies in survey or by poorly
generalized images. At least once in his early years, every cartographer, every topographer,
every geographer, and every geologist should construct a terrain model based upon an
interesting contour plan. He should construct a series of corresponding cross-sections, he
should draw block diagrams and sketches of a number of landscapes. He should pursue the
interpretations of maps and landscape views just as earnestly as one does today with regard
to the flood of scientific literature concerning aerial photography. Thus, the great import-
ance of map interpretation is emphasized but this text will deal with it no further since it is
covered in detail in the author’s book ‘‘Gelande und Karte”, which appeared in 1950 and
44 Further Basic Principles and Guidance
B. On landscape drawing
A good cartographer should not only grasp landscape, in a map-drawing sense, but also
through occasionally sketching from nature.
Drawing demands careful observation. Observation is a conscious viewing directed
toward certain objects. “It makes a considerable difference if one views something with a
sketching pen in hand than without the pen in hand.” (Paul Valery).
Certainly, a serious sketcher is seldom satisfied with his results, in the face of nature. He
wrestles tirelessly with form and expression. Despite or, perhaps, just as a result of this
conflict, landscape sketching brings great satisfaction. It provides and also increases the
interest in the activity of drawing maps. It develops judgement and fosters a taste for all
The observation and sketching of land forms is one of the best introductions to their
genesis and their morphology. Sketching forces us into objective and graphic abstra
Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 65 66
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 65 66

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 7. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 70 71
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 70 71

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 8. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF' ### Ch.2 Topographic foundations (p.15-42) -- mostly survey accuracy; what matters to us: - Fig.15 (p.24): two w
cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF'

### Ch.2 Topographic foundations (p.15-42) -- mostly survey accuracy; what matters to us:

- Fig.15 (p.24): two wrong contours. A-A keeps the broad form, drops details; B-B keeps details,
  misplaces the broad form. "As a rule, the more true-to-form arrangement is better" (hiker,
  mountaineer, geomorphologist). Solution of a cartographic problem is "only good or bad".
- p.41-42 "Stylized representation of land forms": the old instruction books taught classical
  geometric solids (cones, caps, saddles) -- correct as a first stage, but the second stage (natural,
  irregular forms) was omitted. Sins: over-rounded "garland" contours; or, when corrected, "pointed,
  sharply angled lines everywhere with an equal [lack of understanding]"; false terracing; caterpillar
  ridges; lobed flanks; pattern-like conical peaks.

### Ch.3 Further principles (p.43-56)

- B. On landscape drawing (p.44-48, Figs 24/25 Muertschenstock photo | Imhof's 1964 pen sketch):
  * "sketching means leaving things out! Leaving out, however, does not mean sketching inaccurately.
    Uncertain scribbling, formless or distorted hachuring, dot patterns, lines of scree dots betray
    inability." -- see the wood in spite of the trees; generalize with confidence.
  * contours/silhouettes/edge lines sharp, attending to overlap and distance; NEAR parts bolder than far.
  * hachuring for grass and scree slopes in the direction of steepest gradient, only exceptionally across.
  * illuminated edges: a shaded area bordering a light area gets NO heavy boundary line.
  * the photo: "jumble of light and dark patches whose tonal values often depend on ... things which
    have no relation to form"; cast shadows obscure relationships; far areas fuse by accident of tone.
    "Views of nature ... contain no lines. The drawn line is a human invention, a useful abstraction."
  * viewpoint not too close (superabundance of detail); lines first, tone only after.
  * backgrounds/sky with harder, lighter pencils than foreground.
  * Fig.25 itself: bedded limestone walls = strokes along the beds (ledge lines), scree/grass slopes =
    long fall-line lines, far ridge = sparse vertical lines; shadow side = dense short hatching.
- C.5 photo vs map: "Similar things appear the same. Important things are emphasized; unimportant things
  suppressed." Convention = "collective subjectivity". Fig.27 a|b: contours alone vs skeletal rock
  framework + shading tones, 1:10,000, Muertschenstock (a good test site: swisstopo has it).
EOF
for i in $(seq 73 95); do f=ocr/p-0$i.txt; [ -f $f ] && cat $f; done | grep -v '^\s*$' | awk 'length($0)>20' | cut -c1-160 | sed -n 1,400p

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

Preview (first 2KB):
54 Further Basic Principles and Guidance
Since the surface of the relief is not level, the flying height or the level of the eye above
ground varies to a certain extent. On the other hand, as the variable distance H is very large
in comparison with A, it is sufficient to assume a constant mean value for H (corresponding
roughly to the flying height above the lowest contour line). Distance B represents the
normal distance between the pupils of the eyes, about 6.3 centimeters. The best range of
vision, viewing distance or eye level H comes to about 30 to 50 centimeters in anaglyphic
image viewing. The remaining lines and symbols on the terrain can then be placed with
relative ease in their proper places, in the red (successively shifted) contour line network.
When the unchanged green and the red images, shifted to the right from contour to contour
by the amount x, are placed on top of one another, they give, when viewed through the
anaglyphic giasses (ieft jens red, right lens green), an approximately correct three-dimen-
sional effect. If the viewing distance is increased, the mountains appear to grow in height; if
decreased, they become flatter.
Example: A map or anaglyphic model has a scale of 1:25,000 and a contour interval of 20
meters, which corresponds to an interval A = 0.8 millimeters in a stereo model at 1:25,000.
The inter-pupillary distance B is 6.3 cm, the mean viewing distance H = 40 cm. Hence,
x= = -A= “2 -0.8 mm =0.126 mm
The three-dimensional impression of anaglyphic maps constructed in this manner is slightly
distorted. In this technique, in contrast to that with centrally perspective images, as the
terrain comes closer to the eye with increased elevation, no enlargement of the image takes
place. On the other hand, the eye, nevertheless, automatically interprets each stereo image
as having central perspective and thus the higher, or closer surfaces of the terrain appear
...
</persisted-output>
Bash: sed -n 55,400p ~/.claude/projects/-Users-user-work/0566fefe-e974-459d-8d5d-e71202e4c72f/tool-results/b8ic2gusw.txt
sed -n 55,400p ~/.claude/projects/-Users-user-work/0566fefe-e974-459d-8d5d-e71202e4c72f/tool-results/b8ic2gusw.txt

# output:
ago. The topographer has to reproduce, as accurately as possible, the objective form of the
earth’s surface as obtained from measurements without allowing himself to be influenced in
the process by any, perhaps disputed, theories of form. Such contradictions of concepts arise
from different basic assumptions and generalizations which are too extensive.
As explained earlier, many large-scale topographic maps — new as well as old — contain
poorly formed contour lines or unrealistic, pattern-like hachured and/or shaded images.
Such deficiencies were caused by inadequate accuracy in the survey on which the maps were
based and a lack of understanding of form on the part of many map makers. Without doubt,
geomorphological training could have led to significantly better results in circumstances like
The question must be asked whether or not geomorphological guidance is both necessary
and useful for modern topographic surveys as well.
One thing is certain: that, in general, today’s photogrammetric surveys render more
accurate and characteristic contour lines than ever a good geomorphologically influenced
plane table survey could have done. Within the scope of modern, high quality mapping at
large scale, there is little room for the application of geomorphological knowledge. This
changes, however, with decreasing scale or with refinement of the forms being represented.
Rocky and karst regions, moraine landscapes, dune areas, volcanic regions, various forms of
recent erosion and deposition, glacier surfaces, etc. demonstrate very finely detailed forms.
Here graphic simplifications are required, often even in the largest-scale maps. Moreover,
the smaller the scale, and the finer the articulation of the relief, the more this situation is
likely to arise. The generalized form is no longer identical to the original form. The short-
comings brought about by every such change in form, however, can be significantly eased
and retarded, from the point of view of scale dependency, if the generalization of form is
guided by an understanding of geomorphological principles. A conscious emphasis of such
geomorphological characteristics in no way reduces the metric value of the map in such
cases. The point is to carry out the unavoidable simplifications, in such a way that typical
characteristics are least affected. Often, especially in very flat terrain, the carefully consid-
ered simplification of contour lines may prove to be useful.
What has been said here with respect to contour lines applies, with even fewer reservations, to
hachuring and shading, which cannot be picked up in a rigorously geometric manner.
56 Further Basic Principles and Guidance
Examples of morphologically influenced transformations are found in later chapters in
There are several good textbooks on geomorphology available today, some of which
apear in the bibliography at the end of this chapter.
witroc actory instruction in geomorphology should not be presented to topographers and
cartog-.phers in
Bash: sed -n 400,700p ~/.claude/projects/-Users-user-work/0566fefe-e974-459d-8d5d-e71202e4c72f/tool-results/b8ic2gusw.txt
sed -n 400,700p ~/.claude/projects/-Users-user-work/0566fefe-e974-459d-8d5d-e71202e4c72f/tool-results/b8ic2gusw.txt

# output:
(Bash completed with no output)
Bash: cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt|wc -l; for i in $(seq 83 95); do f=ocr/p-0$i.txt; [ -f $f ] && cat $f; done | grep -v '^\s*$' | awk 'length($0)>20' | cut -c1-1
cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt|wc -l; for i in $(seq 83 95); do f=ocr/p-0$i.txt; [ -f $f ] && cat $f; done | grep -v '^\s*$' | awk 'length($0)>20' | cut -c1-160 | grep -n "" | sed -n '1,500p' | grep -v "^$" | tail -n +1 | head -400

# output:
89
1:64 The Theory of Colors
2:that one can also mix orange (as a mixture of red and yellow) with a suitable amount of grey
3:to produce brown tones. Similarly, olive mixtures are obtained from grey and green. Thus,
4:the color solid described does contain all the required colors.
5:The designation of each color — that is, its position on the surface of or inside such a
6:double-cone color-solid can be made in the following manner:
7:Each color is composed, as we have seen, of a pigmented component, a white component
8:and a black component whose sum is always 9 or 10. The pigmented part, or hue, is
9:characterized by its position on the color circle or on the base circle of the double cone. This
10:base circle is divided into 360 parts or degrees, just as is done normally in a sexagesimal
11:graduation. The initial position 0 corresponds to the hue yellow, 120 to red, and 240 to blue.
12:In this way, any hue lying between these can be designated by a digit representing one of
13:these degrees. For example, the hue green has a designation of 300.
14:Any color mixed with white and black within the color solid is characterized by the three
15:components of the sum 9 or 10.
16:Hue300  —5 parts Hue 105 —I part
17:White — 3 parts White — 3 parts
18:Black — 2 parts Black — 5 parts
19:Total 10 parts Total 9 parts
20:A very comprehensive collection of color charts for this Ostwald system of classification was
21:published by Amilius Miiller (Chromos- Verlag, Winterthur, Switzerland) (220).
22:Of more recent origin are the color charts of Alfred Hickethier, published in 1943 and
23:1952 (62, 104). The color classification on which they are based was developed as early as
24:1924 by Max Becke in Vienna. It is simpler than the Ostwald system, both in construction
25:and in color designation. The fu/l colors, yellow and red and blue, yield black when over-
26:printed on each other. A separate black component, or black additive, required by Ostwald,
27:is therefore not necessary for producing desired colors or color mixtures. The Becke-
28:Hickethier color solid (Plate 1, Figures 4, 5 and 6) is cube-shaped and consists of ten square
29:slabs set one above the other. Each of these carries, so to speak, a color chart. Each chart is
30:subdivided in the same way by ten horizontal blue strips which become increasingly lighter
31:running from bottom to top (and also from front to rear), and by ten increasingly lighter red
32:strips from right to left, resulting in a chess board arrangement of 100 small individual
33:squares. The lightening of the color is done by the percentage screening process. The ten
34:strips are numbered 0, 1, 2, 3, 4, 5, 6, 7, 8, 9. Strip 0 of the red row is white, strip 9 is solid
35:red. The same applies for the blue strips. By this superimposition of red and blue strips,
36:10 x 10 = 100 varying color fields appear beginning with the white of the paper, passing
37:through all the red and blue sectors, and their combinations, all the way 
Bash: cd ~/work/pixelart-studies/books/imhof; for i in $(seq 86 94); do f=ocr/p-0$i.txt; [ -f $f ] && cat $f; done | grep -v '^\s*$' | awk 'length($0)>20' | cut -c1-160 | sed -n '30,400p
cd ~/work/pixelart-studies/books/imhof; for i in $(seq 86 94); do f=ocr/p-0$i.txt; [ -f $f ] && cat $f; done | grep -v '^\s*$' | awk 'length($0)>20' | cut -c1-160 | sed -n '30,400p'

# output:
purer and richer the colors, the more easily can they be differentiated in lines and symbols.
The greater the black content of the bright color, the more legible the shape, but the poorer
the differentiation of their colors.
Red, brown, blue, green and other strongly colored lines have decidedly weaker effects
than black or near-black lines of similar gauge (Plate 2, Figure 12).
Light colors — yellow, for example — are never used for lines and symbols, since they
provide too little contrast with the white or colored paper base.
Printing colors are, as a ruJe, transparent colors. The lighter the color, the more it is
influenced by the underlying area tone. Yellow-printed lines appear green on a light blue
background, orange colored on a light red background (Plate 2, Figure 11), while on a dark
blue background they cannot be distinguished.
Closely spaced lines and a dense pattern of symbols, such as tree symbols and scree
patterns, have the same effect as hatching or screening. They mix with the underlying tone
and alter it (Plate 2, Figure 12). But even wide-mesh patterns of lines can influence the
underlying tones, causing them to appear colder or warmer, more opaque or transparent.
Neighboring areas of similar color are more easily differentiated if they are enclosed by
outlines (Plate 2, Figures 13 and 14), but this does not mean that outlining is desirable in
Bash: sleep 280; ls ~/work/pixelart-studies/books/imhof/ocr/*.txt | wc -l
sleep 280; ls ~/work/pixelart-studies/books/imhof/ocr/*.txt | wc -l

# output:
<tool_use_error>Blocked: sleep 280 followed by: ls ~/work/pixelart-studies/books/imhof/ocr/*.txt wc -l. 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>
Bash: cd ~/work/pixelart-studies/books/imhof; for i in 90 91 92 93 94 95 96; do head -c 50 ocr/p-0$i.txt 2>/dev/null| tr '\n' ' '; echo " <- $i"; done; python3 code/spread.py /tmp/claude
cd ~/work/pixelart-studies/books/imhof; for i in 90 91 92 93 94 95 96; do head -c 50 ocr/p-0$i.txt 2>/dev/null| tr '\n' ' '; echo " <- $i"; done; python3 code/spread.py (tmp) 900 87 88

# output:
<- 90
 <- 91
 <- 92
 <- 93
 <- 94
 <- 95
 <- 96
Read: (tmp)

(tmp) 9. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 89 90
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 89 90

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 10. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 91 92
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 91 92

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 11. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 93 94
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 93 94

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 12. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF' ### Ch.4 Theory of colours (p.57-74) - Psychological: perception is RELATIVE; colour constancy under changing li
cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF'

### Ch.4 Theory of colours (p.57-74)

- Psychological: perception is RELATIVE; colour constancy under changing light (p.60) -> the palette
  is the light (studio's master-copy finding, stated by Imhof). Surround shifts hue/value (Plate 2).
- Depth experiments (p.60-61, Fig.33): rings darkening to the centre read as a hole; lightening to
  the centre read as far (tunnel); spectral rings read flat. Brightness alone cues near/far; colours
  graded by intensity, brightness and contrast "imitating their appearance in nature when viewed in a
  landscape" (foreground -> background) induce high/low: AERIAL PERSPECTIVE.
- Printing: each plate gives only 5-6 gradations incl. paper and solid; long ramps come from
  overprinting 2-3 plates (Plate 2 Fig.4: 16 steps light blue + dark blue + black). A ramp = a few
  inks x few screens. (Our palette ramps are the same economy.)
- Map inks (p.68): black (rock drawing, culture), strong blue (water), red-brown (contours, rock),
  green (vegetation), GREY or grey-brown for shading tones, YELLOW for the light side of illuminated
  slopes; + dark brown/brown-violet rock, light blue low land, light red high land.
- Harmony (p.70-72): subdued colours more pleasing than pure; intensity inverse to area ("the purer
  and richer a colour, the smaller its area should be"); yellow light produces blue or violet shadows;
  graded one-hue series (distance, light, haze) please because we are accustomed to them.
- SIX RULES of composition (p.72-73):
  1 pure strong colours unbearable over large adjacent areas; sparing, on dull backgrounds, they sing.
    Extremes occupy small areas in nature -> give strong colours only to extremes.
  2 light bright colours mixed with white next to each other: unpleasant over large areas.
  3 large backgrounds muted, greyish, neutral; grey the most versatile colour.
  4 large enclosed areas of different colours make the picture fall apart; interweave the colours of
    one into the other "carpet-fashion", theme colours scattered like islands.
  5 one uniform basic colour mood: landscape colours are unified by sunlight (e.g. lowland green made
    by overprinting the land's yellow with the sea's blue).
  6 steady, gradual de-emphasis; continual softening of area tones; aerial perspective gradation helps.
  + "Uninterrupted noisy clamor impresses no one. Activity set against a background of subdued calm
    strength produces a deeply expressive melody." "A clear map is beautiful as a rule."
EOF
python3 code/spread.py (tmp) 900 95 96

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 13. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 97 98
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 97 98

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 14. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 99 100
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 99 100

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 15. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 101 102
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 101 102

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 16. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 103 104
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 103 104

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 17. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 105 106
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 105 106

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 18. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF' ### Ch.5 The problem (p.75-86) - Requirements (p.75): a) measurable form/position, b) clear, c) simple graphic f
cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF'

### Ch.5 The problem (p.75-86)

- Requirements (p.75): a) measurable form/position, b) clear, c) simple graphic framework: well
  generalized, "the shape and character of forms retained to the greatest degree possible despite
  generalization", d) elements balanced, e) economical.
- Figs 28-32 (p.77): the same kind of model at 1:5000 (little form seen from above), 1:25,000 rocky
  ridges (vertical cliff steps shrink together in plan -> almost impossible in plan view), 1:100,000
  (clear rise and fall: most expressive), 1:1M (detailed but very flat), 1:10M (flat block).
  "without supplementary imaginary modes of expression and ... drastic simplification and
  exaggeration of form, no image could be produced which has any value."
  -> OUR SCALES: close 0.1 m/px, landscape, iso 30 deg, region ~1 m/px. A 320-640 px frame at 1 m/px is
  320-640 m: Imhof's cases 1-2 (1:5000 - 1:25,000). Our 30 deg iso un-shrinks the cliffs he fights.
- Depth cues list (p.78): 13 cues; #11 "linear ... surface structures when they have a relationship
  with the form (flow lines, furrowing ...) so that bending of the normal structure permits recognition
  of undulations" = why form-following strokes work.
- p.79: for plan views "by far the best and often the only indicator of form is the effect of light
  shining obliquely". Line and hachure don't occur in nature; imitate natural effects "as far as is
  possible with the graphic elements available".
- Fig.33 (p.82, halftones lost; text p.81-83): one cone in a circular groove, 8 ways: 1 slope shading
  (steeper darker: cone and groove same tone), 2 slope hachures, 3 contours (cone or crater?), 4/5
  hypsometric higher-darker / higher-lighter (a pipe / a tunnel), 6 OBLIQUE SHADING (one SEES the form,
  no key needed), 7 shadow hachuring (oblique + "higher darker" -> FALSE/contradictory light, shadow
  reversal at flat->sunny transitions), 8 the same false light as tones. -> exercise E1.
- Direct (natural impression) vs indirect (symbolic, measurable) elements: only their interplay
  satisfies all requirements (p.83-85). Figs 34/35: a triangle is flat; the same triangle inside a
  roof plan is a slope: skeletal lines alone evoke 3-D by memory.
- p.86 goals: accuracy for the scale, metric info, good characterization of forms, the most
  naturalistic forms and colours, clarity, simplicity, "a beauty peculiar to the map itself".
  "All theory, however, is grey."
EOF
python3 code/contact.py 106 128 (tmp)

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 19. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 124 125
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 124 125

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 20. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 126 127
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 126 127

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 21. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 128 129
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 128 129

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 22. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF' ### Ch.6 Spot heights (p.87-104): skimmed; nothing for a painter beyond "select the characteristic points: summi
cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF'

### Ch.6 Spot heights (p.87-104): skimmed; nothing for a painter beyond "select the characteristic
points: summits, passes, valley floors, lake levels, breaks".

### Ch.7 Skeletal lines (p.105-110)

- skeletal lines = ground plan of watersheds, drainage, breaks of slope, terrace/plateau edges, ridges,
  moraine and dune crests, crater rims, incised beds, upper edges of steep glaciers. "As a rule, sharp
  edges are neither very long nor very continuous."
- NEGATIVE lines (gullies, concave) branch upward like a leafless tree crown, gradient increasing
  upward; more coherent and consistent than POSITIVE lines (crests), which are "more erratic and
  broken up". Patterns (Figs 43-46): radiating (flat strata), feather, gridiron (dipping strata:
  long parallel valleys + short twisted links). Fig.47: flows/glacier tongues: crest lines vague,
  the MARGIN (negative) lines sharp.
- Fig.48/49: the same contours without | with drainage: the drainage makes the shapes readable.
  Streams in sharp valleys must be drawn even if dry; dotted lines for scree gullies.
- p.108 AGAINST drawn crest lines in general maps (Brandstaetter): a crest is where surfaces meet, not
  an object; crest lines "depict the forms too boldly and too diagrammatically", add to the jungle of
  lines. "The careful representation of a rocky area, with sharp contrast between light and shade,
  brings out the ridge much more clearly than would the crest line." -> in pixels: no outline on
  ridges; the ridge is the seam between a lit and a shaded facet.
- Fig.50 skeletal sketch map (only < 1:100,000): crests strong, streams thicken downstream, stream
  heads never touch the ridge lines.
EOF
python3 code/spread.py (tmp) 900 130 131

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 23. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 132 133
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 132 133

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 24. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 134 135
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 134 135

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 25. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 136 137
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 136 137

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 26. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 138 139
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 138 139

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 27. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 140 141
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 140 141

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 28. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 142 143
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 142 143

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 29. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 144 145
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 144 145

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 30. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 146 147
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 146 147

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 31. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 148 149
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 148 149

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 32. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 150 151
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 150 151

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 33. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF' ### Ch.8 Contour lines (p.111-158) - Legibility law (p.113-114): line ~0.1 mm, gap ~0.4 mm ("very desirable ...
cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF'

### Ch.8 Contour lines (p.111-158)

- Legibility law (p.113-114): line ~0.1 mm, gap ~0.4 mm ("very desirable ... spaces considerably
  wider than the lines"), k = 2 lines/mm on a 45 deg slope. Smallest interval A = M tan(a)/(1000 k).
  -> PIXELS: 1-px line wants >= 3-4 px gap: a structure-following stroke system is legible at a period
  of 4-5 px, crowded at 3, mush at 2.
- Ideal interval (Imhof 1957, p.116): A = n log n tan(a_max), n = sqrt(M/100 + 1). Non-linear:
  at LARGE scale intervals are larger than the smallest legible (terrain simple; extra lines = clutter,
  "fine detail drawing, the high density and overloading ... go against good perception", p.119);
  at SMALL scale smaller than theory allows (terrain finely articulated, flat): lines then spread,
  smoothed and displaced (steep bits flattened) -- Fig.56. -> as the view zooms out, structure density
  per pixel should RISE, not stay constant; up close draw sparser than you could.
- "In case of doubt ... round off the interval ... rather than overload a whole map" (p.118-119).
- Mixed-interval systems rejected (p.123): "All parts of the terrain, whether flat or steep, appear to be
  equally covered with contour lines ... any suggestion of shape, produced by the grouping and spacing
  of these contours, is lost." -> DENSITY VARIATION IS THE FORM SIGNAL: never normalize stroke density.
  "Nice theories do not help us ... if ... they ruin the image".
- Generalization (p.126-): "An image which has been reduced in size by photography ... may faithfully
  retain its detail but may have become illegible. ... details may have to be combined, deleted,
  emphasized and transformed." (= box-downsampling vs painting.)
- Figs 69/70 scree contours: exact contours zig-zag round every big block (but miss others just as
  large): "the individual rocks are of no interest to us"; generalized = surface form readable.
- Figs 73/74: generalizing = smooth here and there, open up and separate, eliminate small isolated
  indentations, KEEP those encompassed by several contours and long ridges crossing several contours.
- Fig.76 (Flims, 1:50k -> 1:500k): POOR = uniformly rounded (loses young sharp gullies vs old flat
  surfaces); GOOD = distinct edges crossing several contours retained. Also wrong: replacing all curves
  by corners. -> test: gaussian-blurred DEM = "poor generalization".
- EIGHT RULES of generalization (p.132): 1 surfaces, not lines: never draw one line without its
  neighbours; 2 details not reflected in neighbouring contours are smoothed out (single lines take
  their own course only on flat ground or characteristic hummocky ground: karst, glacier, dunes, scree);
  3 neighbours never touch (except cliffs, clints); 4 neither gently wavy nor jagged overall;
  5 first transfer crests and breaks of slope as guides; 6 a form crossing several contours is kept in
  all or dropped in all; 7 keep prominent parts (they make aspect and profile); hollows as meaningful
  as heights; broaden the larger NEGATIVE form at the expense of smaller positive ones; streams widen
  and push the valley walls apart; 8 move lines only as much as legibility needs.
  "Inaccurate, incompetent drawing should never be excused on the grounds that it is presumed
  generalization."
- Figs 77-85: peak / gully+fan / limestone ribs: poorly generalized (rounded blobs) vs well (the same
  simplicity, but crests stay crests, the gully stays sharply cut, the ribs stay angular).
EOF
python3 code/spread.py (tmp) 900 152 153

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 34. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 154 155
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 154 155

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 35. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 156 157
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 156 157

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 36. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 158 159
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 158 159

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 37. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 160 161
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 160 161

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 38. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 162 163
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 162 163

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 39. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 164 165
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 164 165

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 40. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 166 167
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 166 167

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 41. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 168 169
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 168 169

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 42. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 170 171
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 170 171

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 43. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 172 173
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 172 173

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 44. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF' - Line weight (p.142-144): black lines darken ~3x as much as brown of the same gauge; balance colour and weight
cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF'

- Line weight (p.142-144): black lines darken ~3x as much as brown of the same gauge; balance colour and
  weight so lines stay homogeneous; line thickness can follow slope (dense groups finer). Dashes
  a:b:c = 1:(8-12):(4-6); dots a:c = 1:(1-1.5).
- p.143: contour colour by lit/shaded side is "an amusing game": steps; "SHAPE LIES IN THE TRANSITION".
- G. (p.145-148) "vertical illumination" = conventional "the steeper the darker", not optics.
  Fig.102: darkening curves S(slope): 1-cos a (theory), 1/2(1-cos a), contours (flat until 60 deg then
  steep), hachure systems (Lehmann line 6 rises ~linearly to black at ~45-50 deg).
  FORM-LINE EFFECT (p.146): "Objects made up of parallel layers of constant thickness display this
  internal structure as a series of lines ... The smaller the angle between the inner layer and the
  outer surface, the more spread out are the lines." Density = slope. Long straight lines tolerate
  wider spacing than twisting ones.
  p.148: "horizontal lines are not common to the natural landscape ... Erosion and deposition always
  work in the direction of greatest slope. Along with the edge line, the LINE OF SLOPE is a
  characteristic producer of the naturalistic image." Horizontal shading models form "too soft, too
  rounded and too indefinite". -> EXCEPT where the rock itself is layered (CRB flows!): there the
  horizontal lines are real (form-line effect on the strata).
- H. shaded contours: Fig.105 thickening on shaded slopes; Fig.106 slight local thickening at small
  sharp landforms (moraine ridges, ravines, dunes) on the shadow side: "recommended for frequent
  systematic but economical use". Figs 107-112 Tanaka / illuminated contours: mid-grey flat tone,
  contours WHITE on lit slopes, BLACK on shaded; width ~ |cos(angle to light)|; transitions tapered
  (Fig.111) or pearl-string (112). Deficiency: stepped look, hard transitions; best on favourable
  forms. -> this is exactly the pixel "lit lip over dark underline" ledge mark (basalt benches).
EOF
python3 code/spread.py (tmp) 900 174 175

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 45. (tmp)

Now Chapter 9, shading — the heart.

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 176 177
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 176 177

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 46. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt | wc -l; for i in $(seq 178 200); do cat ocr/p-$i.txt 2>/dev/null; done | grep -v '^\s*$' | awk 'length($0)>20' | cut -c1-170 >
cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt | wc -l; for i in $(seq 178 200); do cat ocr/p-$i.txt 2>/dev/null; done | grep -v '^\s*$' | awk 'length($0)>20' | cut -c1-170 > (tmp) wc -l (tmp)

# output:
231
     608 (tmp)
Read: (tmp)
Bash: cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF' ### Ch.9 Shading and shadows (p.159-212) -- THE CORE - Three kinds (p.159): 1 slope shading "the steeper the da
cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF'

### Ch.9 Shading and shadows (p.159-212)  -- THE CORE

- Three kinds (p.159): 1 slope shading "the steeper the darker"; 2 oblique hill shading (light and
  shadow under oblique light); 3 combined. Figs 118-120 on simple models (halftones mostly lost).
- B. Slope shading (p.162-166): imaginary; five gradations suffice; max tone in print 50% black; flat
  = white. Sins: on narrow ridges/valleys tones crowd -> "formless and vague"; makeshift light bands
  along ridge axes (like highlights on polished furniture); stylized terraces, lobes, knob peaks.
  Slope shading shows slope but not ASPECT: "completely rules out the depiction of any true relief-like
  image".
- C.1 "Light reveals all!" (p.166). Oblique shading is also imaginary: nature seldom offers it in
  mappable form (vegetation carpet, sun, haze); snow or bare rock under good sun comes closest (Figs
  124-126). Always assume DIFFUSED oblique light (softness, transparency of shadows) (p.168).
- C.2: train the eye on real models; "One must be able to draw the natural forms".
- C.3 THE PROCEDURE (p.169-170), rules a-l:
  a sketch all edge and skeletal lines first (framework).
  b choose main azimuth after studying the whole area; NOT parallel to the dominant ridge/valley
    direction: cross it diagonally at an acute angle; generally from NW, W or SW; mark local deviations.
  c cover all significant HORIZONTAL surfaces with a medium grey.
  d shade steep slopes facing away from the light, then lighter tones on flatter / slightly-turned
    slopes.
  e work outward "around", "over", "through" the ridges and hollows; slopes tangent to the light get
    intermediate tones by gradient. Only steep slopes directly facing the light end up tone-free.
  f local light changes for inflections the main light misses.
  g start light; build contrast gradually.
  h TRANSITIONS: "The form depends upon these transitions. Sharp ridges require a sudden and crisp
    change from light to dark, while rounded landforms require softer shading transitions."
    An even slope of uniform gradient = ONE homogeneous tone. "Dark bands ... drawn lovingly along the
    shaded sides of terrain ridges ... are meaningless and contrary to the form."
  i large forms badly placed to the light: bring out their SECONDARY forms; "The three-dimensional
    impression is not evoked by any individual grey tone, but rather by a spontaneous visualization of
    the entire scheme of shading."
  j plane surface: one base tone, but may be pushed lighter/darker toward one side for contrast
    against neighbouring surfaces ("gives the forms more life").
  k small inflections precise, but larger relationships EMPHASIZED.
  l check in a MIRROR (flip the image).
- C.4 flat areas get a light-medium tone; without it lit slopes are indistinguishable from flats; it
  "binds and subdues" and lets small coloured features sing.
- C.5 CAST SHADOWS omitted from functional maps ("no formal relationship with the local terrain");
  fine for works of art.  C.6 REFLECTED LIGHT omitted (errors of interpretation), though artists and
  snow scenes love it.  C.7 HIGHLIGHTS omitted (distort).
  -> For us (a picture, not a map) these three are CHOICES, not laws: Imhof omits them for legibility
  of form; at landscape/iso we may keep cast shadow (it is the time of day) but must keep it from
  "obscuring interrelationships" (Ch.3 photo critique).
- C.8 AERIAL PERSPECTIVE (p.172-173): with distance dark tones lighten, light tones subdue, contrast
  falls. In a map HIGH = foreground: "sharpen and increase the contrasts of light and shadow towards
  higher altitudes, but subdue them somewhat in the low lying regions." Only with large elevation
  differences, with discretion; overdone -> slopes look concave, valleys dim.
  -> in our oblique views the true distance does this; in the iso/region top-down views the ELEVATION
  version applies (Imhof's own rule).
- C.9 LIGHT DIRECTION + LOCAL ADJUSTMENT (p.173-177): "expressionless" = curved surface with uniform tone.
  Light tangential to gentle curvature -> overemphasis; frontal -> flattened. W light on the Perron
  model: N-S ranges bold, E-W chains dissolve into patches. Let the light "wander" along slopes and
  around masses, <= 30 deg from main direction, never wildly different on adjacent parallel slopes.
  Fig.131 radial ridges, star forms (c/d unavoidable weak spots on slightly curved ground), the
  Churfirsten ridge curving through the light: where the ridge's shadow side flips, work up the
  crest breaks and SECONDARY forms. Fig.130: 3 model photos (NW, S, WSW light) vs Imhof's drawn
  shading (6), which unifies them. "Better no relief drawing than a poor one."
  Vertical light angle adapted: flat terrain < 20 deg; steep shaded slopes > 45 deg (or they black out);
  steep lit slopes: flatter light so the steepest are lightest.
- C.10 untenable theories: Wiechel 1878 H = cos e with fixed NW 45 deg light (flat = mid tone):
  "quite meaningless" -- because diffuse light and local adaptation matter; "Three-dimensional effects
  do not result from the degree of local shading but rather from the INTERPLAY of tones."
  (NB: Wiechel's formula is exactly GIS Lambert hillshade. Imhof's objection = our studio's
  "a render quantized is not a painting".)
- C.11 NW light: from right-handed writing/drawing habit. South light (true sun in N hemisphere)
  inverts relief for most viewers. Recommendation: WEST (or SW) light, like late afternoon sun
  (p.183).
EOF
python3 code/spread.py (tmp) 900 194 195

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 47. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 -c " from PIL import Image, ImageFilter im=Image.open('pages/p-193.png').convert('L') b=im.filter(ImageFilter.GaussianBlur(4)).resiz
cd ~/work/pixelart-studies/books/imhof; python3 -c "
from PIL import Image, ImageFilter
im=Image.open('pages/p-193.png').convert('L')
b=im.filter(ImageFilter.GaussianBlur(4)).resize((im.width//3, im.height//3), Image.LANCZOS)
b.save('(tmp)); print(b.size)
"

# output:
(704, 1154)
Read: (tmp)

(tmp) 48. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 700 196 197 198
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 700 196 197 198

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 49. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; for i in $(seq 200 230); do cat ocr/p-$i.txt 2>/dev/null; done | grep -v '^\s*$' | awk 'length($0)>20' | cut -c1-170 > (tmp)
cd ~/work/pixelart-studies/books/imhof; for i in $(seq 200 230); do cat ocr/p-$i.txt 2>/dev/null; done | grep -v '^\s*$' | awk 'length($0)>20' | cut -c1-170 > (tmp) wc -l (tmp)

# output:
1041 (tmp)
Read: (tmp)
Bash: cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF' - Fig.131,1/2 (p.179): star of 8 ridges under one light: ridges parallel to the light are expressionless (a, b)
cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF'

- Fig.131,1/2 (p.179): star of 8 ridges under one light: ridges parallel to the light are
  expressionless (a, b); fine arrows = local light bends; fixed version: every ridge has a lit and a dark
  side. -> EXERCISE: star model, fixed light vs Imhof's wandering light.
- Fig.132 (p.184, halftone -> threshold): Toedi-Klausen, 1:200k, shading graded by aerial perspective.
- C.12 LEONARDO (p.185): "between light and dark there exists a hybrid area ... a bright shadow or a dim
  light. This you must seek." The steep lit and steep dark slopes are easy; "The critical part is what
  lies between the two" -- half and quarter shadows, glancing light. "One and the same grey tone ...
  can appear in one place as a shadow and in another as light." Printing that collapses tones into
  "solid or non-existent ... destroys every form in the image."
  -> PIXEL WARNING: a 4-6 step ramp quantized hard does exactly this. The middle steps must carry the
  transitions; dither lives in the transitions (observation student's "dither only where a value
  falls between two colours" is the same rule).
- C.13 FOUR DIFFICULT CASES (p.186): a lit-facing slopes are lit all over -> use small light changes to
  catch every gentle curve; b steep lit / gentle dark scarplands: keep the steep lit slopes light, don't
  make the gentle dark ones too dark, sharpen small bends, darken neighbouring flats; c a small steep
  cone beside a broad eminence looks too low (impression of height depends on the SIZE of the light and
  dark areas): overemphasize the small one; d a small counter-slope inside a big shadow: subdue its
  light or it reads as a steep ridge. "it is the overall impression of form which is important and not
  a strict adherence to some geometric law of shading."
- C.14 accuracy: shading must reflect even the smallest bend of the contours (not just overall shapes).
- C.15 small details (karst, glacier tongues, lava, slides): oblique shading brings out bumps of ~1 mm
  (Fig.221 Khumbu). Symbols are "uniforms rather than individual items of clothing".
- C.16 EMPHASIS ON LARGE FORMS (p.187): 1 aerial perspective by elevation (sharpen toward summits,
  soften toward lowlands); 2 on the LIGHT side of a major watershed lighten all local shadows; on the
  SHADOW side strengthen them and subdue local lights with a weak overall shadow "not strong enough to
  destroy the detailed modulations". = masses first, then marks within the mass (rock student's round
  2 finding, stated by Imhof).
- C.17 GENERALIZATION of shading (p.188, Fig.133 Saentis 1:200k->1:15M): photographic reduction fails;
  first smooth out gullies, niches, projections, terraces; then whole valleys and ridges merge; narrow
  but important valleys stressed by tone; at small scales tones follow ELEVATION and relative relief,
  not slope; exaggerate some heights; keep the local CHARACTER (Alpine sharp ridge; Norwegian flat
  glaciated tops; volcanic vs other cones). Fig.134: poor (ravines devour the tableland; stylized
  rounding; ridges like water droplets with mast-like peaks) vs good (abruptly incised tableland kept;
  soft and crisp forms together; parallelism of ridges stressed).
- C.18 SHADING COLOUR (p.190-192): near shadows grey; far landscape shadows grey to pure blue.
  "select grey-blue, or blue-violet-grey shadow colors. Neutral grey is too lifeless while pure blue
  is too bright". Yellow/pink/red/red-violet can't model form. Large scales take stronger tones.
- C.19 glaciers/snow: don't use weaker shading on snow (ridges read as plateaus); "shading tones should
  be used for the delineation of FORM alone and not to suggest the material". Snow shadow pure blue in
  colour maps, of similar strength to the main relief tone.
- D. Combined shading (p.194-195): the hachure legacy: oblique light + "steeper darker", flats white.
  Clear in high mountains; contradictions on slopes of changing gradient; symmetric hills look
  asymmetric (lit slopes contrast less with white flats). Darkening steps on the lit side is contrary
  to nature (Fig.119,7 vs 8).
- E. Technique (p.196-201): shading is built over a dense framework of guide lines (contours, streams,
  rock ridges, crest lines; Fig.137). PENCIL beats airbrush: "Sharp and irregularly shaped terrain is
  always given too soft an impression by the air-brush ... Form is depicted more truly and more
  characteristically by the positive strokes of the pencil"; airbrush for flats, overall tones, aerial
  perspective tones. Stroke direction: "On rounded land forms ... follow the direction of the contour
  with the pencil, while on sharp edged features ridge and slope directions are to be preferred."
  Watercolour: build up from the LOWER levels up toward the ridges (else you fight aerial perspective).
  "the art of 'scraping out' is just as important as the art of drawing in". Grey-toned paper: start
  from the flat-land mid tone; darken shadows, scrape out lights.
  -> PIXELS: our canvas is the grey paper; the flat mid step is the base; lit and dark are both
  departures from it. Airbrush = smooth Lambert render (too soft on sharp terrain); pencil = placed
  strokes and planes.
EOF
sed -n 520,1041p (tmp) | head -400

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

Preview (first 2KB):
ing of the sunny slopes. Dependent upon the nature and additional processing to be done on
the film (image carrier) the basic grey tone is produced by printing or copying on to the film
or by wiping on a hue such as a graphite coating, for example. An increase of shade is then
achieved on such film with lead or charcoal pencil, or by spraying on black watercolor with
the airbrush. Light areas, on the other hand, are applied with white chalk, white pencil, or as
a pen or brush drawing with white watercolor or tempera color. Every artist and every
draftsman is familiar with these time-honored drawing techniques. A famous example is the
silver point engraving — ‘Praying Hands’ by Albrecht Diirer.
Lightening can also be brought about by scraping and rubbing away the grey color or
graphite layer so that the white paper or, in other cases, the transparent film shows through.
However, it is not easy to produce any desired intermediate light tone by scraping without
leaving scratches or streaks behind. Relief shading images of this type are seldom satisfac-

8. The uniform impression and good photographic and
reproductive quality of shaded originals
In the process of shading, if pencil tones, black watercolor, black ink, coatings of grey
graphite or color tones, white chalk, white opaque paint etc. are combined, the work may, in
some cases, be speeded up but often the following drawbacks may be encountered. ,
The interpretation of shapes in the relief original is more difficult. Also it is never certain
that the camera lens will pick up the subtle differences of shade of the various color media to
the same extent as does the human eye. Yellowish-white and bluish-white, reddish-grey and
bluish-grey have different effects on the photographic plate. One should never combine
pencil tones, watercolor tints, gouache paint etc. without first running control tests with the
Unintentional highlights and reflections, which may develop when working with hard
...
</persisted-output>
Bash: grep -n -i -E "computer|Yoeli|Brassel|electronic|advantages|disadvantage|photograph" ~/.claude/projects/-Users-user-work/0566fefe-e974-459d-8d5d-e71202e4c72f/tool-results/b
grep -n -i -E "computer|Yoeli|Brassel|electronic|advantages|disadvantage|photograph" ~/.claude/projects/-Users-user-work/0566fefe-e974-459d-8d5d-e71202e4c72f/tool-results/bksmvgokm.txt | head -40; wc -l ~/.claude/projects/-Users-user-work/0566fefe-e974-459d-8d5d-e71202e4c72f/tool-results/bksmvgokm.txt

# output:
13:8. The uniform impression and good photographic and
21:bluish-grey have different effects on the photographic plate. One should never combine
24:pencils on smooth paper, must be avoided in photographic work.
40:each one supplementing the others, is carried out by photographic separation. This can be
66:transferred photographically to its own printing plate.
67:A method like this may be simpler technically than photographic separation from a single
73:F. Practical considerations: the advantages and
74:disadvantages of shading and shadow tones
113:F. Practical considerations: the advantages and disadvantages of shading and shadow tones 203
175:tal areas. This middle tone, its advantages and disadvantages, were discussed above in
191:produced by the photography of models (see Section H below). However, it is not only the
206:H. Hill shaded images by model photography
207:For decades attempts have been made to use the topographic model and its photographic
213:by photography, producing photographs of suitably illuminated model surfaces in as close to
220:consists of preparing a model, photographing it and fitting the photographic image into the
275:H. Hill shaded images by model photography 207
276:3. Photography of models
277:Shaded originals can be produced by photographing accurate models which faithfully por-
285:Photographs like these require special, and certainly not simple equipment.
286:The photograph normally provides a centrally perspective view of the shading, whereas
288:of the photographic image where high points in the relief and shaded ridges appear to be
290:the center. In order to reduce these distorting effects relief models are photographed from a
292:distance, the light rays are almost parallel, the deformation in the photographic image is
294:In photography with such long range cameras, exposure times of one hour or more are
297:graphic device with a parabolic concave reflector for orthogonic model photography (216).
299:illumination, parallel to the directional axis of photography can be achieved with a light
316:useful to reduce the vertical scale, and then, after photography, to work out the desired
320:4. Advantages and disadvantages of shading by photography
321:This photographic shading process may be capable of further development. In most cases at
325:or again if the model is constructed to be used as such and the photography is a by-
341:Another failing of ‘photographic’ shading is the poor adaptation of light direction to the
343:‘lifeless’ or ‘expressionless’ areas in the relief. Often photographs of relief models may
348:contrast, often required in the map image. Photographic shading suffers most of all from a
349:chaotic confusion of individual forms. To overcome this disadvantage the model and even
355:Those who have experience of this process state that “photographic shading is economical
357:that “photographic shading can only be used with very extensive manual retouching work.”
363:shorten considerably the ti
Bash: sed -n 320,400p ~/.claude/projects/-Users-user-work/0566fefe-e974-459d-8d5d-e71202e4c72f/tool-results/bksmvgokm.txt; cd ~/work/pixelart-studies/books/imhof; for i in $(seq
sed -n 320,400p ~/.claude/projects/-Users-user-work/0566fefe-e974-459d-8d5d-e71202e4c72f/tool-results/bksmvgokm.txt; cd ~/work/pixelart-studies/books/imhof; for i in $(seq 228 232); do cat ocr/p-$i.txt; done | grep -v '^\s*$' | cut -c1-170 | head -120

# output:

4. Advantages and disadvantages of shading by photography
This photographic shading process may be capable of further development. In most cases at
present it is unable to satisfy the high demands made upon it. Because of the relatively
complicated apparatus it is economical only when much work is to be carried out, and only
if the models to be constructed can serve other purposes than the production of one map,
or again if the model is constructed to be used as such and the photography is a by-
product of this procedure. Normally the models are not sufficiently accurate. So far they
have been produced at the scale of the map to be shaded, having been designed to suit the
relief image of this particular map. It is, however, a truism that one can never model with the
accuracy and detail with which one can draw, since the material just does not permit it. All
models, produced mechanically, are quite inadequate in their portrayal of small rugged rock
formations, steep steps, karst formations, etc. Instead they show flattened-out profiles in
areas of marked ridge lines. In cartography, on the one hand, great stress is placed on the
trueness of scale on scribed sheets, while on the other there is a blithe acceptance of models
made for maps whose plaster forms far from satisfy these accuracy demands.
In large and medium scale topographic maps the shaded image should be everywhere
within 0.2 mm of contour bends and angles, rocky ridges, moraine crests etc. For the
reasons mentioned, however, this cannot be achieved without thorough and intensive
Further discrepancies between the map image and the hill shading produced photo-
graphically may often develop from the differences in projection, mentioned above, for
which there may not have been complete compensation.
Another failing of ‘photographic’ shading is the poor adaptation of light direction to the
land forms. As was discussed in more detail above, parallel light gives rise to numerous
‘lifeless’ or ‘expressionless’ areas in the relief. Often photographs of relief models may
appear extremely impressive to the casual observer, but closer examination would reveal the
arbitrary quality of the shaded forms. The impressions of images may not always be the
There is another even greater failing. Mechanically produced models and their lighting
effects do not take account of the generalization of relief forms, the increase or reduction of
contrast, often required in the map image. Photographic shading suffers most of all from a
chaotic confusion of individual forms. To overcome this disadvantage the model and even
the base map contours themselves would have to be generalized to the specifications of the
required shading image, and this would have to be done for each scale group. This proce-
dure, however, would be much more laborious, much more time-consuming and difficult
than the drawing and generalization of shading done by hand; and even with these efforts
the result would not match the quality of the dra
Bash: cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF' - H. Model photography (p.205-208): "Mechanically produced models and their lighting effects do not take accoun
cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF'

- H. Model photography (p.205-208): "Mechanically produced models and their lighting effects do not take
  account of the generalization ... Photographic shading suffers most of all from a chaotic confusion of
  individual forms." Parallel light -> expressionless areas. Hand shading within 0.2 mm of contour bends.
- J. COMPUTER SHADING (p.209-212) Yoeli (his student), Brassel, Huegli: cosine of light vs facet normal.
  Imhof's three difficulties: (1) parallel light makes acute forms vanish and obtuse ones look acute;
  cure is "extremely delicate adaptation of the assumed light direction to the local topographical
  requirements ... probably beyond the formulation of a programming logic"; (2) simultaneous contrast:
  "a flat area interrupting a dark slope ... will appear too bright ... could be interpreted as a rising
  slope"; the cartographer corrects it; (3) generalization: "The computer program is 'blind'";
  "a cartographic relief at small scales is not just a geometrical reduction of the natural forms but
  the creation of a new relief depiction". His suggestions: (A) print a second shading with the light
  turned 10-15 deg and combine them; (B) treat the computed shading as a working base to be retouched
  by hand (local darkening/lightening of individual forms).
  -> THIS IS MY PROGRAM for code: (1) local light adaptation from the landform's own orientation,
  (2) a simultaneous-contrast / flat-in-shadow correction, (3) generalization by landform, not blur.
EOF
python3 code/spread.py (tmp) 900 231 232

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 50. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 233 234
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 233 234

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 51. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; for i in $(seq 235 252); do cat ocr/p-$i.txt 2>/dev/null; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-170 | head -330
cd ~/work/pixelart-studies/books/imhof; for i in $(seq 235 252); do cat ocr/p-$i.txt 2>/dev/null; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-170 | head -330

# output:
(Bash completed with no output)
Bash: cd ~/work/pixelart-studies/books/imhof; ls ocr | tail -3; ls ocr/*.txt | wc -l; tail -2 ocr/ocr.log; pgrep -fl tesseract | head
cd ~/work/pixelart-studies/books/imhof; ls ocr | tail -3; ls ocr/*.txt | wc -l; tail -2 ocr/ocr.log; pgrep -fl tesseract | head

# output:
p-228.txt
p-229.txt
p-230.txt
     231
PASSDONE

The OCR pass finished before all pages existed; rerun for the rest.

Bash: cd ~/work/pixelart-studies/books/imhof; nohup code/ocr_par.sh > ocr/ocr2.log 2>&1 & echo ok
cd ~/work/pixelart-studies/books/imhof; nohup code/ocr_par.sh > ocr/ocr2.log 2>&1 & echo ok

# output:
ok

Meanwhile, the pages directly.

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 235 236
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 235 236

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 52. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 237 238
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 237 238

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 53. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 239 240
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 239 240

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 54. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 241 242
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 241 242

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 55. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/contact.py 243 253 (tmp)
cd ~/work/pixelart-studies/books/imhof; python3 code/contact.py 243 253 (tmp)

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 56. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt | wc -l; for i in $(seq 243 251); do cat ocr/p-$i.txt 2>/dev/null; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-170 |
cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt | wc -l; for i in $(seq 243 251); do cat ocr/p-$i.txt 2>/dev/null; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-170 | head -300

# output:
246
226 Hachures and Other Related Techniques
. ~ . ~ fe att Uri ot = s .
al .— SS e ee sega etna Z sed pe
=e * " 1 mu Wie 4 A . <a =j 7
o4 Saye 7S wie we = St nt vue <
‘ J ZEN Uf nsee . SS “tia Say .
—— i, : $ . wa S Sok Qi th 3%, hee
Figure 152. General mountain hachuring, scale 1:900,000, enlarged to 1:450,000. Area: Central Switzerland.
From Stieler’s Handatlas, 1891.
With a few exceptions, old single color woodcuts and copper engravings were printed in
black and this was also applied to old landscape maps. This practice remained in most of the
single color copper engraved hachured maps of last century. The classical forms of the Swiss
Dufourkarte, the Deutschen Reichskarte (Map of the German Reich) at 1: 100,000, the
Osterreichischen Spezialkarte (Austrian Special Map) at 1:75,000, the Carta topografica
d’Italia (Topographic Map of Italy) at 1: 100,000, and the Carte de France (Map of France)
at 1:80,000 were all printed in black.
It is a violation of historical accuracy, and of graphic sensitivity, to produce facsimile
reproductions of old wood-cut and copper engraved maps in brown or in colors other than
black, a practice which, unfortunately, often occurs. The black hachure has stronger,
sharper, truer, and, above all, a more three-dimensionally expressive effect. The lighter the
printing color, the weaker is the light-dark contrast. Brown hachures do not fully exploit all
gradation possibilities; they frequently appear too weak, flat and fuzzy, andare often lost inthe
confusion of other map symbols.
In spite of this, one does not wish to lose the opportunity of employing brown, grey OF
similar colors for hachures in new mapsvsince they are necessary in order to achieve a clear,
visual separation of the different cartographic elements. Black text and symbols stand out
from non-black hachuring more effectively than from hachures printed in black. This advan-
tage often carries more weight than that of the better three-dimensional effect of black
A saturated neutral brown is suitable for hachures, since this distinguishes itself not only
from the black of text and symbols, but also from the blue of drainage lines and from the red
of roads and railway lines. The weaker the brown tone, the flatter and less vivid is the relief,
but the remaining elements contained in the map become more legible. The stronger the
Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 253 254
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 253 254

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 57. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 255 256
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 255 256

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 58. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 257 258
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 257 258

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 59. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 259 260
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 259 260

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 60. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 261 262
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 261 262

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 61. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 263 264
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 263 264

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 62. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF' ### Ch.10 Hachures (p.213-234) - Slope hachure FIVE RULES (p.214-218): 1 dense zones of small parts of slope lin
cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF'

### Ch.10 Hachures (p.213-234)

- Slope hachure FIVE RULES (p.214-218): 1 dense zones of small parts of slope lines, direction = steepest
  gradient (perpendicular to contours); 2 arranged in horizontal ROWS (sections) to keep density
  constant; 3 length = horizontal equivalent between (assumed) contours (l = k cot a); 4 tone "the
  steeper the darker" by stroke THICKNESS, (thickness + gap) constant; Lehmann black:white = a : 45-a
  (0 deg 0:9 ... 45 deg 9:0); Mueffling a:60-a; Austrian a:80-a capped at 48 deg (6:4, never black:
  lighter, suited alpine terrain); 5 same number of hachures per cm everywhere, chosen per map.
  Consecutive rows not connected, offset. Density per cm: 13-40 /cm; finer for smaller scale.
  Coarse patterns: harder, woodcut-like, clearer, more transparent; fine: smoother, flatter.
  -> PIXELS: 1 stroke + >=1 gap = period 2 is the max density; stroke weight can only be 1 or 2 px;
  so hachure tone in pixels is carried by LENGTH/GAP (dashes) and by stroke/space ratio with period
  3-5 px. Lehmann's "constant pitch, varying weight" is the pixel artist's constant-pitch dither.
- p.221 draftsman must pull hachures out of one row into the neighbour at sharp dips; "Congestion,
  knots of strokes, clogging ... indicate poor work."
- p.222-223 misrepresentations: dark steep areas abutting on ridges need white separation strips ->
  widened flat ridges; rigid geometry can't follow fine forms; unrealistic terracing; "The evil became
  standardized, the standard procedure became doctrine, the doctrine became the ideal."
  Figs 149 (slope) / 150 (shadow hachuring) Uetliberg: 150 = light side fine, shadow side bold; no
  white strips needed "since ... good detailed form is obtained through the change from light to
  shaded slopes". Fig.150 is a master-copy candidate (clean line art, survived the scan).
- D. General mountain hachuring small scale (Figs 151-153): freer; stroke rows drop; broad strokes.
- E. colours: black hachures truest; brown for separation from black text.
- H. Horizontal hachures (form lines), I. Tanaka planes, K. Eckert dots: skimmed.

### Ch.11 ROCK DRAWING (p.235-)

- A: no single form suits all rocks and scales; "Generally speaking, there are no ideal solutions to
  the problem of steep rocks." Find the best possible for each situation.
- B.1 (p.236-237, Fig.159 glacial alpine landscape in oblique pen): U valleys, cirques ("Kare") eaten
  back, sharp residual crests, horn peaks ("Karling"). Fig.159 is itself a model of oblique rock
  drawing: all slopes are fall-line strokes, dense on shadow sides; crests are where stroke fields
  meet; valley floor near-empty.
- B.2 GEOLOGICAL STRUCTURE (p.238-243), Figs 160-169 (Imhof's pen sketches, perfectly preserved):
  160 homogeneous soft (flysch): uniform slopes, roof ridges -> few long fall lines.
  161 GRANITE: large masses, sharp needle spires, even coarse surfaces -> long vertical flutings,
      strokes following the steep joints, spires as clustered vertical wedges. (Enchantments!)
  162 gneiss: pyramidal, sub-parallel wedge-shaped projecting rocks, sharp roof ridges.
  163 crystalline strata inclined: asymmetric roofs, splintered layered slopes, jagged dip ridges.
  164 steep/vertical structure perpendicular to the wall: narrow deep parallel runnels, chimneys;
      ribs needle-like at the top (Aiguilles).
  165 HORIZONTAL BEDS: broad table summits, truncated peaks, banded stepped slopes; resistant beds
      vertical walls, soft beds indented; walls have vertical joint strokes between horizontal
      ledge lines; talus fans below each band. (YAKIMA CRB!)
  166 inclined beds: pulpit peaks, ledges at the layer ends.  167/168 vertical slabs: serrated.
  169 folded: the strata lines themselves drawn as bands.
  "The external forms of rocky walls ... clearly betray their composition and their internal
  structure."
- B.3 gullies (p.243-246): every irregularity on a steep wet slope becomes a channel; gullies with
  steep back walls and narrow outlets; subsidiary ridges between them run from main ridge down to
  triangular frontal facets (residuals of the old surface): Fig.170 "group of normal forms".
  Resistant rock -> gullies less incised. Fig.171 beds parallel to the wall -> wide shallow gullies,
  slab faces; 172 beds perpendicular -> precipitous gullies, buttresses; 173 horizontal beds -> hard
  beds form cliff with narrow clefts, soft beds benches; undermining -> bastions; 174 oblique beds
  deflect gullies.
- B.4 (p.246): rock gullies elongated, scoured, seldom stepped; steepest in upper reaches; ridges
  notched; contour twist greatest half-way down; "the debris mantle forms the local base level".
  Plan view: branching like a POPLAR (acute, near-parallel), not an apple tree (broad river systems).
- B.5 debris mantle (p.247, Fig.175): gully -> debris cone like an hourglass; 30-40 deg; fines up top,
  big blocks run out to the valley floor or foot of the opposite slope; "The limits of the cliff-base
  are sharply defined in most cases as they are covered by scree cones." Small gully often at the foot
  of the cliff where scree begins.
EOF
python3 code/spread.py (tmp) 900 265 266

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 63. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 267 268
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 267 268

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 64. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 269 270
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 269 270

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 65. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 271 272
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 271 272

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 66. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 273 274
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 273 274

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 67. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 275 276
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 275 276

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 68. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 277 278
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 277 278

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 69. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; mkdir -p refs/plates; python3 -c " from PIL import Image im=Image.open('pages/p-277.png').convert('L'); print(im.size) im.save('refs/plates/
cd ~/work/pixelart-studies/books/imhof; mkdir -p refs/plates; python3 -c "
from PIL import Image
im=Image.open('pages/p-277.png').convert('L'); print(im.size)
im.save('refs/plates/fig178_full.png')
im.crop((0,1000,im.width,2600)).resize((1200,int(1600*1200/im.width)),Image.LANCZOS).save('(tmp))
"

# output:
(2050, 3364)
Read: (tmp)

(tmp) 70. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; for i in $(seq 279 300); do cat ocr/p-$i.txt 2>/dev/null; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-170 | head -400; ls ocr/*.t
cd ~/work/pixelart-studies/books/imhof; for i in $(seq 279 300); do cat ocr/p-$i.txt 2>/dev/null; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-170 | head -400; ls ocr/*.txt|wc -l

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

Preview (first 2KB):
depicted by a few characteristic crest lines and slope hachures. However, on the other hand,
flatter, broader areas, schistose (slate-like) surfaces for example, are best expressed with
horizontal strokes. In limestone cliffs with their marked steplike structure, the two types of
hachure should be used alternately.
If horizontal and slope hachures are located immediately adjacent to each other, side by
side, or one above the other, the result can be confusing. A change in stroke direction is only
justified when the form of the area demands it. To avoid such difficulties, many rock
cartographers limit themselves to the more easily produced horizontal hachures, although in
neglecting the slope hachures for rock depiction the cartographers put aside a very useful
Dipping strata or narrow bedding can often be expressed in an excellent manner through
the appropriate use of slope hachures, the area hachure then becoming a structural drawing.
Only someone very experienced in drawing rocks can allow himself the use of other stroke
directions. Cross-hachures, however, (i.e. the superimposition of dense hachures in different
directions) should be avoided if at all possible. Usually they are too dense, too heavy and too
much Jike woven material. If cross-hatching must exist, then it should lie not at right angles
but diagonally, and one stroke direction should always dominate over the other by means of
d) Three-dimensional shading effects: As explained above, shading effects should be evoked
by variation of stroke width. Normally, in such situations, northwest illumination is
assumed, and adapted to the individual forms. With every small bend in the stroke, an
extremely accurate, simultaneous change in stroke width should be introduced since a consis-
tent interplay between surface direction and shading effect must be carried down to the last
detail. Crooked and bent strokes without corresponding variations of stroke width are
...
</persisted-output>
Read: ~/.claude/projects/-Users-user-work/0566fefe-e974-459d-8d5d-e71202e4c72f/tool-results/beip3j0p0.txt
Bash: cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF' - C. FORM ANALYSIS (p.251-252) -- the analysis before drawing: 1 DEMARCATION lines (limits of rock against scre
cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF'

- C. FORM ANALYSIS (p.251-252) -- the analysis before drawing: 1 DEMARCATION lines (limits of rock
  against scree, vegetation, snow reaching up into gullies); 2 RAVINE/GULLY lines (negative skeletal
  lines: from tops of debris/snow cones up into basins); 3 CREST lines (upper edges of cirque-like
  basins; main ridges thick, secondary finer). "The ravine lines ... and the crest lines ... divide
  what was formerly a confused, impenetrably complicated image into its principal regions, imparting
  form to the whole." 4 only THEN the finer internal-structure lines (strata, bedding, slides,
  folds): "secondary in significance ... subordinated to the overall portrayal of the topography."
  Negative lines elongated, always linked; positive lines irregular, crooked, broken, interrupted.
  The two meshes seldom touch except at basin mouths "in a sort of scissors-like fashion".
  "This method may seem extremely tedious. It is the method for beginners ... When skilled, the
  cartographer will find short cuts".
- D.1 rock contours: combing-out (Fig.176) flattens steps -> "more destructive than local
  condensation". Fig.177 Kleiner Drusenturm: OBLIQUE VIEW in cartographic rock drawing style (!)
  -- the model for our landscape/iso rock.
- D.2 skeletal lines (p.257): ledges, gully lines, cracks, clefts, ridge crests, step edges, slab edges,
  bedding planes; reinforce the edge lines lying on shaded slopes; increase light/heavy contrast
  upward (aerial perspective); ridges NOT smooth: "jagged natural appearance retaining their rough,
  often interrupted and twisted character".
- D.3 rock shading (p.257-258): large forms boldly (main lit side light, main shaded side dark,
  transitions medium); then every rib, gully, crack, layer "without distracting attention from the
  broader formations". Local light adjustment everywhere for small forms, esp. on main lit and main
  shaded sides ("otherwise lifeless"). STEEP SLOPES MORE STRONGLY SHADED ON BOTH SIDES than gentler
  rock. Aerial perspective: contrasts sharper high up; "When this ... is ignored, the foot of a
  cliff can easily look like the upper edge of the same cliff." Grey tone alone on small rock areas =
  "smudges or spots".
- D.5 SHADED ROCK HACHURING (p.259-263) -- "It is rock drawing."
  a hachures laid over the skeletal framework so their combined effect = tonal values of a
    (prepared or imagined) shaded image. Contours alone inadequate; need photos at different times of
    day, stereo, geology. Graphically freer than slope hachures; like a perspective pen sketch (177).
    "Uncertain drawing, indefinite arrangement of strokes in shapeless, incoherent miniature stroke
    fragments, or even unnatural, stylized, uncontrolled lines give away the beginner".
  b FIG.178 (p.260-261), a good / b poor:
    1 horizontal hachures, shading by stroke width; 2 slope hachures, rough strokes THICKENED AT THE
    TOP (sharp delineation of the step); 3 very narrow rock band = horizontal hachures, width-shaded;
    4 poor horizontal (shading doesn't follow the bends); 5 poor slope: top not emphasized ->
    "feathery"; 6 narrow layer as slope pecks -> "a pattern of pecks"; 7 cross-hatching = "cloth
    weaving, but not of rock", too dark; 8 thick outline -> "cut-up and wooden";
    9 GOOD interweaving: slope hachures for steep walls, horizontal for the ledges on soft layers;
    10 poor: no shading, tiny pecks, "confused, feathery, stippled"; 11 poor: rigid structural lines,
    cross-hatched, too dark; 12 GOOD steep scale-like buttresses: gullies smoothly and sharply drawn,
    running upward and meeting at acute angles; "Narrow, open, illuminated areas or edges conform to
    the shaded areas. RIDGE CRESTS ARE NOT DRAWN IN, the scale-like type of structure being brought
    out by the variation of light and shadow"; 13 poor: stylized feathery strokes, no area modulation;
    14 poor: ribs too hard, outlined; horizontal hachures on narrow steep rises = "leaning ladders";
    15 GOOD slab wall channelled by narrow gullies: whole surface a light medium tone of horizontal
    form lines; each gully "a crisp illuminated side paralleled by a well defined shadow";
    16 poor: form lines start mid-surface for no reason; 17 poor: shadow strokes for gullies with no
    illuminated strips on the opposite sides.
  c attitude (p.261-262): strokes follow horizontal features or slope; steep steps in bedded
    limestone = rows of short downslope strokes; flatter layers = horizontal; narrow steep slabs and
    crystalline pinnacles NOT ladders of horizontals: "a few characteristic crest lines and slope
    hachures"; schistose flat surfaces horizontal; alternate in step-like limestone; change direction
    only when the form demands; cross-hachures avoid (if needed diagonal, one direction dominant).
    Dipping strata via slope hachures -> "the area hachure then becoming a structural drawing".
  d 3-D shading by stroke WIDTH, NW light adapted to forms; "With every small bend in the stroke, an
    extremely accurate, simultaneous change in stroke width"; bent strokes without width change and
    straight strokes with width change are both wrong. Lit sides: "an extremely light distribution of
    fine hachures". EVERY tiny shaded section (fissure, gully, tooth, notch) "matched by a
    corresponding, light, unshaded surface on its opposite side" (erase the hachures there).
  e density nearly constant; tone by thickness not interval; overall "lightness and transparency.
    Rocks should not lie heavily in the map like lumps of coal".
  f "THE FORM LIES IN THE TRANSITION." "A firm outline round every surface element ruins the image
    ... as if constructed from hard wood." Skeletal lines are aids; seldom in the final image. "The
    impression of a sharp edge is produced not by means of ridge lines, but rather through the sharp
    change between adjacent light and shadow areas." Outline of a rock step: start each hachure
    crisply and firmly. Continuous outlines only in narrow sharp deep gullies. SAW-TOOTH CREST:
    "allowing very tiny shadows and light areas to cut across the edge of the ridge." No stylized
    twisted strokes; bedding cracks + some roughness are enough.
  g aerial perspective: contrast rises with elevation.
  h fit the small into the large: small ridges, gullies, steps, cracks, strata "so slightly that they
    become secondary to the effects of light and shade on the main surfaces".
  i generalization needs genesis knowledge; open out very steep cliffs (almost lines in plan) at the
    expense of neighbouring flatter rock; emphasize layering, hard/soft contrasts.
- 6 "steeper darker" rock hachures abandoned (walls meet at ridges indistinguishably).
- 7 colour: dark (black, dark brown, strong brown-violet, dark grey); weak tints fail.
- 9b "For relief maps, there is no better, or more natural a portrayal of rocks than by the
  combination of 'shaded' skeletal outlines or rock hachures with corresponding shading tones"
  (Plate 11, Fig.27b).
- 10 karren: light, contours ungeneralized; ROCHES MOUTONNEES (glacier-polished, Enchantments basin!):
  "the lightest, smoothest horizontal hachures possible" (Fig.188); grassy/scree/snow slope with
  protruding rocks: lightest horizontal hachures + occasional small isolated groups of slope
  hachures, thickened in the usual way (Fig.189) -- avoid "restlessness".
- 11 SMALL SCALES (p.272-273, Figs 190-192 Ortstock 1:50k -> 1:150k generalized vs photo-reduced):
  the space for hachures disappears; a simplified SHADED SKELETAL outline takes over; lit slopes can
  no longer bear hachures; the smallest shadows in gullies, cracks, notches = heavier generalized
  strokes. Under a magnifier: "a wood-cut ... with bold contrast effects in which the structure is
  composed of completely light and completely dark". "Drastic generalization and sharp contrasts".
  Combined with hill shading tones: "The rock drawing ... provides a final intense shadow tone,
  reduced to the smallest area in the form of lines." Remove all unnecessary strokes, esp. on lit
  slopes. -> THIS IS THE PIXEL SCALE RULE (region ~1 m/px).
- 12 training (p.273): draw rocks in nature at suitable distances, varied light; trace skeletal and
  structural lines over photos; copy good rock maps at successively smaller scales; then: skeletal
  image (hard pencil) -> shaded pencil tones -> transform tones into hachures -> rub away unnecessary
  strokes and ridge lines.
- E tools: scraper indispensable (lit edges, narrow lit areas, roughening hachures); stone engraving's
  grain suited the rough rock hachure better than smooth copper.
- F examples: 181-186 Bietschhorn (crystalline) / Vrenelisgaertli (bedded limestone); 190-192
  generalization; 193-198 old/new, 195 Rohn: skeletal lines too sharp -> stiff, wooden.
EOF
python3 code/spread.py (tmp) 900 280 281

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 71. (tmp)

Where's Fig 180 itself? Let me contact-sheet 278–300.

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/contact.py 280 300 (tmp)
cd ~/work/pixelart-studies/books/imhof; python3 code/contact.py 280 300 (tmp)

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 72. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 282 285
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 282 285

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 73. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 292 294
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 292 294

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 74. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt|wc -l; for i in $(seq 296 300); do cat ocr/p-$i.txt; done | grep -v '^\s*$' | cut -c1-170 | head -150
cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt|wc -l; for i in $(seq 296 300); do cat ocr/p-$i.txt; done | grep -v '^\s*$' | cut -c1-170 | head -150

# output:
324
G. Critical examination and application ol, — different methods of rock drawing 279
G. Critical examination and applicatior. .:{ the different
methods of rock drawing
Today, there is still much controversy over the various methods of representing rocks in
cartography. Poor art work and imprecise reproduction can bring a basically acceptable
method of portrayal into disrepute. The main difficulty, however, lies in the problem itself.
As was pointed out at the beginning, there is no entirely satisfactory means of portraying
steep cliffs and finely detailed rock areas in a cartographic plan view.
It would be hopeless to attempt, even with contours, to portray the tower of a Gothic
cathedral from directly above, with its buttresses, cornices, secondary towers and other
elements of the structure, and then to expect such a drawing to both provide an expressive
image of the tower with all its associated forms, and also to reveal their geometrical relation-
ships and architectural structure. The result would be a chaotic confusion of strokes and
lines. One cannot expect broken rock areas with steep cliffs to be any different, and hence
all attempts at plan view representation must be compromise. The only real question is
which of the various means of depiction best serve the map user.
The following remarks are presented in favor of rock contours over rock hachures.
Rock hachures and rock skeletal lines give only the plan view structure and not the
relationships of elevation and steepness of slope. Following this argument it is therefore
inconsistent and thus ‘illogical’ or ‘unscientific’ to use two processes different in nature, in
the same map, that is, to depict rock-free terrain by contours, but to dispense with these in
rock areas and produce modelling effects with hachures. The omission of contours today
would be even less appropriate, since photogrammetry makes this element available in rock
as well as in adjacent land.
Considerations such as these may be posed at the desk, and may be absolutely justified in
theory, but nonetheless the assumption is that, in fact, rocks can be depicted by con-
tours, but that this does not hold true for steep cliffed and complex rock areas. It has already
been indicated, more than once, that blurred, chaotic tangles of lines are produced under
these circumstances, and nothing of value can be carried out with such an image. The
inherent metrical values (heights and slopes) cannot be appreciated, cannot be deduced or
even constructed from an image such as this. Hence the so-called ‘logic’ becomes senseless,
the ‘scientific approach’ becomes a farce. The map is not a product of logic, but rather an
instrument of practical use. But very steep walls are an exception, someone will interpose. In
fact, these ‘exceptions’ are found throughout the limestone and granite mountains of the
world, and are also widespread in the tablelands of arid regions.
The matter becomes even more problematical, however, on closer inspecti
Bash: cd ~/work/pixelart-studies/books/imhof; for i in $(seq 301 340); do cat ocr/p-$i.txt 2>/dev/null; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-170 > (tmp)
cd ~/work/pixelart-studies/books/imhof; for i in $(seq 301 340); do cat ocr/p-$i.txt 2>/dev/null; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-170 > (tmp) wc -l (tmp)

# output:
773 (tmp)
Read: (tmp)
Bash: cd ~/work/pixelart-studies/books/imhof; for i in $(seq 341 380); do cat ocr/p-$i.txt 2>/dev/null; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-170 > (tmp)
cd ~/work/pixelart-studies/books/imhof; for i in $(seq 341 380); do cat ocr/p-$i.txt 2>/dev/null; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-170 > (tmp) wc -l (tmp) ls ocr/*.txt | wc -l

# output:
0 (tmp)
     329
Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 323 324
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 323 324

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 75. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 325 326
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 325 326

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 76. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF' - G. critical (p.279-282): no satisfying plan-view solution for steep cliffs (Gothic tower from above); contour
cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF'

- G. critical (p.279-282): no satisfying plan-view solution for steep cliffs (Gothic tower from above);
  contours can't characterize wild rock; rock hachures give overall image, clarity, character; some
  prefer contours "only because they are unable to produce a good rock hachure presentation ... foxes
  for whom the grapes hang too high!" Per scale: 1:50k rock hachures without contours "excellent";
  1:100k simplified rock hachures; with hill shading, "a crisp, finely detailed skeletal rock drawing"
  and the hachure fill can be omitted (Plates 11/12).

### Ch.12 Small landforms (p.283-294)

- TAPERING HACHURE (p.285-): exactly downslope; "drawn sharply and heavily at the upper edges of steep
  slopes, continue downward in a fine line and end in a point"; horizontal rows; too wide spacing =
  coarse unsettled, too dense = dull, blotchy. Tall slopes: split into several rows (strip per row).
  Width by light from upper left. "Crocodile teeth" (wide wedges) wrong.
  -> PIXEL MARK: a 1-px column 2-6 px long, darkest/2-wide at the top, fading to the lighter step at
  the tip: the scarp/cutbank/moraine edge stroke.
- dots for scree (p.291): fine dots high on steep upper slopes, big dots and blocks disordered at the
  foot; dots in lines down the slope; "one of the failings ... distribute dots too densely and too
  evenly" -> blackened maps. Dots don't touch contours. Lit side fine & open, shaded side dense &
  heavy (p.284).
- glaciers: freely arranged (not patterned) blue slope hachures for ice walls; crevasses short crisp
  lines tapered at both ends; systems crossing at shallow angles.
- landslides/torrent gullies "like fresh, open wounds": free tapering hachures, crisp at top.

### Ch.13 Area colours (p.295-324)

- p.296-298: nature's colours vary with season, hour, weather, distance; a map picks "the most
  generally representative or predominant aspect". Large scale: high-sun summer air photo as model,
  subdued; medium/small: "colours seen from ground level at corresponding viewing distances" --
  1:100,000 viewed at reading distance ~ landscape from 30-40 km: colours dominated by light and
  shade and aerial perspective (grey-blue veil); foreground rich contrast; far: everything light blue
  / blue-grey. The smaller the scale, the more general the colours.
- p.298-299: "The finer and more detailed the relief picture, the more sensitive it is to disturbance
  by a multi-colored mosaic of the surface cover" -> subdued ground tones; light yellowish-green for
  treeless, stronger blue-green for forest, light ochre/brown-red for rock and debris, all with aerial
  perspective gradation. VALUES matter more than hues ("correct and incorrect 'values'").
  Walensee 1:10,000 gouache (Plate 10, 1938) -- "free artistic interpretation of visual impressions
  gained during long walks".
- Hypsometric types: 1 contrasting (chaos: "what not to do"); 2 higher-lighter (shadow perspective,
  suits mountains with hachures); 3 higher-darker; 4 spectral green-yellow-brown (grass green read as
  vegetation; egg-yolk yellow destroyed atlases); 6 grey/violet tops compete with grey shading.
  TYPES 9/10 (p.306-307): with oblique shading: LIGHT scale; near=higher -> contrasts intensify
  upward, so the ground tone lightens upward: bottom greenish/grey-blue, blue-green, green,
  yellowish-green, greenish-yellow, yellow, light yellow, white (top). Type 10 = the same for slopes
  in strong shadow: at the TOP left/right (lit/shade) contrast strongest; at the BOTTOM they converge
  (veil). Modelled on "a color photograph of a sunny, summer landscape taken from an aircraft at a
  high oblique angle over Lake Lucerne". Yellow ground + grey-blue haze mixing.
  TYPE 11 (large/medium relief maps, p.307-308): middle steps somewhat stronger green, top grades
  through yellow to light PINK/orange/yellowish-brown (not white); ALL rock and scree get the reddish
  light tone (glaciers white). "Without the addition of pink, such maps would seem too cold and the
  yellow high areas unattractively dull. The pink tint of the uppermost sunny slopes harmonizes with
  the red added to the highest and strongest shadow areas." Distant peaks on serene evenings in soft
  pink; "Solely insensitive people apply this red too strongly ... falsely glowing alpine peaks seen
  in cheap landscape postcards." Valley floor = half shadow + its tint. Keep sequences LIGHT.
  TYPE 13 (small scale, p.309): white, light yellow, reddish-yellow, fuller brownish-yellow,
  yellowish-brown, greenish-brown/olive, brownish-green/olive, blue-green, greenish-blue strong,
  grey greenish-blue strong. Lightness gradation dominates hue; red placed in the MIDDLE.
EOF
python3 code/spread.py (tmp) 900 327 328

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 77. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/contact.py 330 405 (tmp)
cd ~/work/pixelart-studies/books/imhof; python3 code/contact.py 330 405 (tmp)

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 78. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt|wc -l; for i in $(seq 342 362); do cat ocr/p-$i.txt 2>/dev/null; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-170 > /
cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt|wc -l; for i in $(seq 342 362); do cat ocr/p-$i.txt 2>/dev/null; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-170 > (tmp) wc -l (tmp)

# output:
354
     285 (tmp)
Read: (tmp)
Bash: cd ~/work/pixelart-studies/books/imhof; ls ocr | awk -F'[-.]' '{print $2}' | sort -n | awk 'NR>1 && $1!=p+1 {print "gap after",p} {p=$1}' ; pgrep -f tesseract | wc -l
cd ~/work/pixelart-studies/books/imhof; ls ocr | awk -F'[-.]' '{print $2}' | sort -n | awk 'NR>1 && $1!=p+1 {print "gap after",p} {p=$1}' ; pgrep -f tesseract | wc -l

# output:
gap after 000
gap after log
       7
Bash: cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt | tail -3; for i in $(seq 351 362); do [ -f ocr/p-$i.txt ] && cat ocr/p-$i.txt; done | grep -v '^\s*$' | awk 'length($0)>25' |
cd ~/work/pixelart-studies/books/imhof; ls ocr/*.txt | tail -3; for i in $(seq 351 362); do [ -f ocr/p-$i.txt ] && cat ocr/p-$i.txt; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-170 | head -300

# output:
ocr/p-356.txt
ocr/p-357.txt
ocr/p-358.txt
Which types of portrayal or elements should be combined in the case at hand?
How should the individual element be depicted so that it provides strength of expression
not only for itself but also in the combined portrayal, and taking other elements into
account as far as possible?
Some elements give primarily metrical information, others serve mainly to provide expres-
sion and form for an image. One element allows small localized forms to be recognized and
can be seen only in small areas. This group includes contours. Another element provides a
good overall picture of large areas, e.g. hypsometric layer tints.
One should always combine those elements which are as different or as contrasting as
possible, both in the information which they contain and in their design charcteristics: in other
words, those symbols which supplernent each other well in what they represent and are
One should also consider the ways in which the colors of the combined elements may
harmonize. Things which contrast in information content should also be contrasting in color.
Those things which are related in their meaning should not have clashing colors. The most
important element, as far as the information is concerned, should stand out.
There is an infinite number of possibilities of interplaying the elements. Some are found
primarily in older maps, others in more recent maps. Many combinations are only suitable
for larger-scale, others only for smaller-scale, maps. Possibilities also depend on the repro-
duction process, the number of available printing color, etc.
There follow discussions on a number of particularly important situations which often
arise or are particularly useful.
a) Combinations for large and medium scale maps
Owing to their scale, these groups are characterized by regions of the terrain which are often
simple and homogeneous in places and have great differences in elevation relative to their
plan dimensions. This permits the use of equal contour interval systems. Cases 1~9, which
follow, belong to this group.

1. Contours and slope- or shading-hachures. This symbol, introduced by early mapmakers.
is still employed in the production of fine combined images but its employment is now 70
longer recommended. Contours present the metric form of terrain surface. Hachures, espe-
cially hachures constructed on the principle ‘the steeper, the darker’, attempt, in a less
effective manner, to do the same thing and so there is little sense in combining them.
Furthermore, it is difficult to get a satisfactory agreement between the two in their represen-
tation of form in newer, more accurate maps. Contours in modern large- and medium-scale
maps of mountain regions often twist and turn very sharply and suddenly. The stiff, reg-
ulated structures of slope and shadow hachures, however, are not sufficiently adaptable to
these twists and bends. If both elements must be in agreement, the only solution is to adjust
the forms to the
Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 356 357
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 356 357

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 79. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 354 355
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 354 355

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 80. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF' ### Ch.14 Interplay (p.325-362) - Same kind of patterns laid on each other destroy each other; DIFFERENT kinds r
cd ~/work/pixelart-studies/books/imhof; cat >> NOTEBOOK.md <<'EOF'

### Ch.14 Interplay (p.325-362)

- Same kind of patterns laid on each other destroy each other; DIFFERENT kinds reinforce (lines over
  flat tints good). If two line patterns must overlap: one open, large-meshed, strong; the other fine,
  dense, light.
- "POSTAGE STAMP CARTOGRAPHY" (p.327): tiny samples compress too much; what reads in a stamp-sized
  patch is unbearable over a sheet. -> WARNING for our 8x crop studies: always also judge the full frame.
- p.328: "When everyone at a conference shouts at the same time, no one can be understood." Vorarlberg
  map: pale blurry shading + dense black rock hachures = "a pale, lightly-colored pudding in which
  several lumps of coal are embedded". Topographers carry 1:5000 detail into 1:50k: "One cannot pick
  out the scree slope for the stones." Smaller scale -> surface cover subdued in favour of general form.
  "Cartography is the art of moderation ... the law of scale is in control."
- Forest tints disturb shading: lighten/open the forest, or print the forest plate WITH the relief
  (forest shaded too). -> forests in our region view must carry the terrain shading (aerial student's
  per-tree slope tone confirms).
- B.4: shading and contours must agree down to the finest detail (no "broad flat generalized" shading).
- B.5 skeletal rock drawing + detailed hill shading: excellent, if skeletal lines crisp, no extra
  hachures, strong brown/grey/violet. Fig.220 (Everest rock skeleton for 1:100k) + Fig.221 (its hill
  shading). "Rock hachures which are too thick are normally unsuccessful on the illuminated surfaces of
  steep rock slopes; they darken them so much that the intended modelling effects never materialize."
- B.7 LANDSCAPE PAINTING IN PLAN VIEW (Walensee 1938, 1:10,000 gouache 2 x 4.8 m): not a colour air
  photo (vague, flat, grey, momentary) but "the visual experiences of a landscape painter". Theme:
  shimmering green silvery-grey depths vs warm red-brown heights, woven together by the atmosphere;
  calm blue-green lake as contrast. Shadow tones India red, brown, grey, blue or deep green BY GROUND
  COLOUR; shadows lightened by reflected light but CAST SHADOWS STRONGLY DEEPENED; sunlit slope bright
  ochre / yellowish-red, in shadow deep red-violet. "the colored mosaic of ground cover influences the
  color of the depth effects but does not destroy them." Result: beautiful, "But the end result is not
  a map" -- shows how far naturalism can go.
  -> WE ARE MAKING PICTURES: Walensee is our genre; the map rules are the discipline under it.
- B.8 (Plate 11, 1:100k): shading tones not mouse-grey nor red-violet but HAZY BLUE-GREY; toward the
  upper reaches more intensive and REDDER (carmine), toward lower areas lighter and bluer. Blue haze
  restrained from climbing lit slopes (valleys, flats, low hills). Yellow tint graded like "negative"
  hill shading (lightened/removed in strong shade), lighter in valley floors; rocky lit slopes
  yellowish-red. "Shading tones should dominate the surface tones, they should not be allowed to be
  submerged by them. This is very important."
EOF
for i in $(seq 358 372); do [ -f ocr/p-$i.txt ] && cat ocr/p-$i.txt; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-170 | head -250

# output:
B. Combination of various elements of terrain representation 341
avoided except for glaciers. At scales smaller than J :500,000 rock skeletal line drawing is
omitted since here it would not represent the form, no matter how meticulous the depiction.
In this type of representation it is important to maintain a perfect reflection of form and
the greatest possible harmony in the interplay of colors between the linear and areal relief
elements. Contours and rock skeletal lines are designed to contain more metrical informa-
tion for the map and to support and reinforce the three-dimensional impression of color and
shading. Rock hachures should be sufficiently faint and restrained on the illuminated slopes
that they do not destroy the impression of three-dimensional form. Area tones should be
transparent so as not to obscure the line image.
Dense cones of contours in a red-brown color and dark brown rock outlines change the
impression of area colors. This can be observed by comparing Plates 11 and 12. The area
tints in both are identical, the differences in appearance being solely attributable to the
strong effects of the added line patterns in Plate 12. These influences should be considered
when selecting printing colors. A medium, dull, bluish-brown or brown-violet is better
suited for rock depiction than black, since such impure tones move them back from the
black text and match them in with the area tones more neatly in a balanced picture. Dense,
black patterns of lines and strokes would spoil the soft areal tones.
The type of representation described here was developed many decades ago, primarily in
Switzerland. It is often called the ‘Swiss style,’ but is in no way limited to Swiss or alpine
terrain. It is suitable for all regions having a generally rough, ruggedly undulating terrain. It
is also useful for flat, gullied table lands and for moderate mountainous and hilly terrain,
moraine landscapes, etc. Since it combines the advantages of a metrical line scheme with the
direct impression of naturalistic colors and shading tones, it is superior to all other types of
representation. It is, in other words, the ideal landscape relief map.
One has come across this type of map in a variety of interpretations in technique and
stages of development. Earlier, strong brownish, reddish or yellow-reddish ground-surface
and illuminating tones dominated the usually pale shadow-tones — often too much so — such
that the latter sank or were immersed in the dominant elements and were inaccurate, the
old, traditional combined shading being always used. Valley bottoms and flat land were
therefore lighter than the adjacent illuminated slopes. Maps such as these were often ruined
by too strong yellow and pink tints; also the hypsometric blue was delimited too crudely or
abruptly, or even omitted altogether. A direct three-dimensional impression can only be
produced by consistent oblique shading, by the priority of hill shading over the surface
toning, by the most prec
Bash: cd ~/work/pixelart-studies/books/imhof; for i in $(seq 370 378); do [ -f ocr/p-$i.txt ] && cat ocr/p-$i.txt; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-170 | sed -n '1,
cd ~/work/pixelart-studies/books/imhof; for i in $(seq 370 378); do [ -f ocr/p-$i.txt ] && cat ocr/p-$i.txt; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-170 | sed -n '1,400p' | grep -n -i -E "art|beaut|simpl|law|good maps" | head -40

# output:
(Bash completed with no output)
Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 374 375
cd ~/work/pixelart-studies/books/imhof; python3 code/spread.py (tmp) 900 374 375

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 81. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 -c " from pyproj import Transformer t=Transformer.from_crs('EPSG:4326','EPSG:2056',always_xy=True) for n,lo,la in [('muertschen
cd ~/work/pixelart-studies/books/imhof/code; python3 -c "
from pyproj import Transformer
t=Transformer.from_crs('EPSG:4326','EPSG:2056',always_xy=True)
for n,lo,la in [('muertschen',9.1486,47.0936),('bietsch',7.8514,46.3919),('vrenelis',8.9722,47.0106),('churfirsten',9.25,47.15),('eiger',8.0053,46.5775)]:
  print(n, [round(v) for v in t.transform(lo,la)])
"

# output:
muertschen [2729817, 1217258]
bietsch [2631749, 1137920]
vrenelis [2716608, 1207756]
churfirsten [2737369, 1223699]
eiger [2643437, 1158626]
Bash: cd ~/work/pixelart-studies/books/imhof; for L in ch.swisstopo.pixelkarte-farbe-pk25.noscale ch.swisstopo.pixelkarte-farbe-pk50.noscale ch.swisstopo.swissalti3d-reliefschattierung;
cd ~/work/pixelart-studies/books/imhof; for L in ch.swisstopo.pixelkarte-farbe-pk25.noscale ch.swisstopo.pixelkarte-farbe-pk50.noscale ch.swisstopo.swissalti3d-reliefschattierung; do e=jpeg; [ $L = ch.swisstopo.swissalti3d-reliefschattierung ] && e=png; python3 code/swissfetch.py $L 25 2728800 1216400 2730800 1218000 refs/swiss/muertschen_${L##*.}_1m.png $e; done; ls -la refs/swiss/*.png; du -sh refs/swiss

# output:
-rw-r--r--  1 user  staff  5167428 Sep 26 14:03 refs/swiss/muertschen_noscale_1m.png
-rw-r--r--  1 user  staff  3757258 Sep 26 14:04 refs/swiss/muertschen_swissalti3d-reliefschattierung_1m.png
-rw-r--r--  1 user  staff  1769943 Sep 26 13:37 refs/swiss/test_eiger_pk25.png
 21M    refs/swiss
Bash: cd ~/work/pixelart-studies/books/imhof; rm refs/swiss/muertschen_noscale_1m.png; python3 code/swissfetch.py ch.swisstopo.pixelkarte-farbe-pk25.noscale 25 2728800 1216400 2730800 12
cd ~/work/pixelart-studies/books/imhof; rm refs/swiss/muertschen_noscale_1m.png; python3 code/swissfetch.py ch.swisstopo.pixelkarte-farbe-pk25.noscale 25 2728800 1216400 2730800 1218000 refs/swiss/muertschen_lk25_1m.png; python3 code/swissfetch.py ch.swisstopo.pixelkarte-farbe-pk50.noscale 24 2728800 1216400 2730800 1218000 refs/swiss/muertschen_lk50_1.5m.png; python3 -c "
from PIL import Image
im=Image.open('refs/swiss/muertschen_lk25_1m.png'); print(im.size); im.crop((500,300,1300,1100)).save('(tmp))"

# output:
(2000, 1600)
Read: (tmp)

(tmp) 82. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 -c " from PIL import Image im=Image.open('refs/swiss/muertschen_lk50_1.5m.png'); print(im.size); im.save('(tmp))"
cd ~/work/pixelart-studies/books/imhof; python3 -c "
from PIL import Image
im=Image.open('refs/swiss/muertschen_lk50_1.5m.png'); print(im.size); im.save('(tmp))"

# output:
(1333, 1067)
Read: (tmp)

(tmp) 83. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/swissfetch.py ch.swisstopo.pixelkarte-farbe-pk25.noscale 25 2630700 1137000 2632700 1138800 refs/swiss/bietsch_lk25_1m.png; pyt
cd ~/work/pixelart-studies/books/imhof; python3 code/swissfetch.py ch.swisstopo.pixelkarte-farbe-pk25.noscale 25 2630700 1137000 2632700 1138800 refs/swiss/bietsch_lk25_1m.png; python3 code/swissfetch.py ch.swisstopo.pixelkarte-farbe-pk100.noscale 21 2620000 1128000 2644000 1146000 refs/swiss/bietsch_lk100_5m.png; python3 -c "
from PIL import Image
im=Image.open('refs/swiss/bietsch_lk25_1m.png'); print(im.size); im.resize((1000,900)).save('(tmp))"

# output:
Command did not complete within its 120s timeout and was moved to the background (ID: b0oxbw9bx). 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/library; directory changes made by the backgrounded command do not apply to subsequent commands.
Bash: for d in enchant yakima skykomish; do echo "== $d"; ls ~/work/voxsim-wt/place-$d/ | head -30; done; ls ~/work/pixelart-studies/elements/rock/refs | head -30
for d in enchant yakima skykomish; do echo "== $d"; ls ~/work/voxsim-wt/place-$d/ | head -30; done; ls ~/work/pixelart-studies/elements/rock/refs | head -30

# output:
== enchant
Cargo.lock
Cargo.toml
NOTES.md
REEL.md
RESULTS.md
SPEC.md
crates
docs
reels
renders
runs
studies
== yakima
Cargo.lock
Cargo.toml
NOTES.md
REEL.md
RESULTS.md
SPEC.md
crates
docs
reels
renders
runs
studies
target
tools
== skykomish
Cargo.lock
Cargo.toml
NOTES.md
REEL.md
RESULTS.md
SPEC.md
crates
docs
reels
renders
runs
studies
target
tools
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
Bash: for d in enchant yakima skykomish; do echo "== $d"; head -c 2500 ~/work/voxsim-wt/place-$d/RESULTS.md; echo; ls -t ~/work/voxsim-wt/place-$d/renders | head -12; done
for d in enchant yakima skykomish; do echo "== $d"; head -c 2500 ~/work/voxsim-wt/place-$d/RESULTS.md; echo; ls -t ~/work/voxsim-wt/place-$d/renders | head -12; done

# output:
== enchant
# lane-slide-shot: the landslide (and avalanche) coupling shot, results log

Branch lane-slide-shot (from master, with lane-reel 28eb53a merged in for vox-reel).
All builds/renders: `taskpolicy -b`, RAYON_NUM_THREADS=4, CARGO_BUILD_JOBS=2,
CARGO_TARGET_DIR=~/work/voxsim/target-slide-shot. Logs in runs/slide/.

## Why v1's slide never showed (diagnosis)

1. Wrong trigger: the slope's frame had no trigger_landslide option, the beat's
   text match fell through to "slump a small section" (~5 m3).
2. Invisible motion: geo's mass-flow solver takes voxels OUT of the world while
   they move (parcels) and lays them down when they stop. The painter only sees
   the world, so a slide painted as: slope quietly empties, deposit appears.
   Same for the avalanche (on top of snow-over-snow).
3. Material contrast: gravel scree sliding over gravel scree, or forest-floor
   soil over forest floor, is invisible at 0.2 m/px even when it runs 90 m.
4. Dry soil slides on these slopes stop after 2-4 m (landslide lambda 0.3);
   only the gravel ones ran far.

## What changed

- vox-play tools: `Cmd::Slide {x,y,r}` (geo `trigger_failure`: every connected
  loose column within r on ground >10 deg goes to its failure plane; debris flow
  if saturated, else landslide; physics decides the rest). Explorer key L.
  `Cmd::Soak {x,y,r}` (a cloudburst: loose cover in the disk to moisture 255 =
  geo's water table at the surface).
- vox-reel: `slide` / `soak` shot actions; `flowfx`: moving parcels drawn into
  the world for each painting (stacked per column = the solver's flow
  thickness; airborne at their position), then removed and change logs
  rewound; dust/powder puffs born from fast parcels (render-side). Voxels are
  handed to the paint mirror every frame while mass is moving.
- vox-reel `slides` subcommand: ranks trigger sites (volume, drop, green cover,
  big animals / people in the run-out), thumbnails, runs trials with stills and
  a physics outcome log.
- vox-core (integrator-owned, minimal, marked): `World::change_mark` /
  `rewind_changes` so the render overlay leaves no change-log entries.

## Trials

### Seed 5 scan (hi_pm, 14.3h), r = 24 voxels (12 m)

- Top-ranked sites are gravel scree over scree. Physics is great there
  (site (1204,184): 339 m3, runs 91 m, 9 ibex carried up to 56 m, 6 killed)
  but on camera it is grey on grey: invisible even with the overlay.
- Sheets: renders/reel/slide/slides-hi_pm (deleted after review; rerun
  `vox-reel slides reels/slide.toml --
enchant
studio
trees
paint
paint-before
== yakima
# lane-slide-shot: the landslide (and avalanche) coupling shot, results log

Branch lane-slide-shot (from master, with lane-reel 28eb53a merged in for vox-reel).
All builds/renders: `taskpolicy -b`, RAYON_NUM_THREADS=4, CARGO_BUILD_JOBS=2,
CARGO_TARGET_DIR=~/work/voxsim/target-slide-shot. Logs in runs/slide/.

## Why v1's slide never showed (diagnosis)

1. Wrong trigger: the slope's frame had no trigger_landslide option, the beat's
  
Bash: for d in enchant yakima skykomish; do echo "== $d"; find ~/work/voxsim-wt/place-$d/renders -name "*.png" -newer ~/work/voxsim-wt/place-$d/Cargo.toml 2>/dev/null | xargs ls -t 2>/de
for d in enchant yakima skykomish; do echo "== $d"; find ~/work/voxsim-wt/place-$d/renders -name "*.png" -newer ~/work/voxsim-wt/place-$d/Cargo.toml 2>/dev/null | xargs ls -t 2>/dev/null | head -8; done

# output:
== enchant
~/work/voxsim-wt/place-enchant/renders/enchant/i2/u_glacier_land.png
~/work/voxsim-wt/place-enchant/renders/enchant/i2/u_region_iso.png
~/work/voxsim-wt/place-enchant/renders/enchant/i1/u_glacier_low.png
~/work/voxsim-wt/place-enchant/renders/enchant/i1/u_glacier_land.png
~/work/voxsim-wt/place-enchant/renders/enchant/i1/u_region_iso.png
~/work/voxsim-wt/place-enchant/renders/enchant/g3/u_grove_low.png
~/work/voxsim-wt/place-enchant/renders/enchant/g3/u_grove_scene.png
~/work/voxsim-wt/place-enchant/renders/enchant/g3/u_grove_land.png
== yakima
~/work/voxsim-wt/place-yakima/renders/y/p46/y2_hero_amph2_eve.png
~/work/voxsim-wt/place-yakima/renders/y/p46/y2_hero_cliffband_eve.png
~/work/voxsim-wt/place-yakima/renders/y/p45/y2_hero_amph2_eve.png
~/work/voxsim-wt/place-yakima/renders/y/p45/y2_hero_cliffband_eve.png
~/work/voxsim-wt/place-yakima/renders/y/p44/y2_scene_bank.png
~/work/voxsim-wt/place-yakima/renders/y/p44/y2_scene_steppe.png
~/work/voxsim-wt/place-yakima/renders/y/p44/y2_land_upper.png
~/work/voxsim-wt/place-yakima/renders/y/p44/y2_land_amph.png
== skykomish
~/work/voxsim-wt/place-skykomish/renders/sky/i4u/i4_mt_index_far.png
~/work/voxsim-wt/place-skykomish/renders/sky/i4t/i4_mt_index_far.png
~/work/voxsim-wt/place-skykomish/renders/sky/i4t/i4_townwall_from_s.png
~/work/voxsim-wt/place-skykomish/renders/sky/i4t/i4_top.png
~/work/voxsim-wt/place-skykomish/renders/sky/i4s/i4_mt_index_low.png
~/work/voxsim-wt/place-skykomish/renders/sky/i4s/i4_river_low.png
~/work/voxsim-wt/place-skykomish/renders/sky/i4s/i4_bar_land.png
~/work/voxsim-wt/place-skykomish/renders/sky/i4s/i4_summit_view.png
Bash: cd (tmp); python3 -c " from PIL import Image fs=['~/work/voxsim-wt/place-enchant/renders/enchant/i2/u_region_iso.png','~/work/voxsim-wt/place-yakima/renders/y/p46/y2_hero
cd (tmp); python3 -c "
from PIL import Image
fs=['~/work/voxsim-wt/place-enchant/renders/enchant/i2/u_region_iso.png','~/work/voxsim-wt/place-yakima/renders/y/p46/y2_hero_cliffband_eve.png','~/work/voxsim-wt/place-skykomish/renders/sky/i4u/i4_mt_index_far.png','~/work/voxsim-wt/place-skykomish/renders/sky/i4t/i4_townwall_from_s.png']
for f in fs: print(f, Image.open(f).size)
"

# output:
~/work/voxsim-wt/place-enchant/renders/enchant/i2/u_region_iso.png (1920, 1080)
~/work/voxsim-wt/place-yakima/renders/y/p46/y2_hero_cliffband_eve.png (1920, 1080)
~/work/voxsim-wt/place-skykomish/renders/sky/i4u/i4_mt_index_far.png (1920, 1080)
~/work/voxsim-wt/place-skykomish/renders/sky/i4t/i4_townwall_from_s.png (1920, 1080)
Read: voxsim-wt/place-enchant/renders/enchant/i2/u_region_iso.png

voxsim-wt/place-enchant/renders/enchant/i2/u_region_iso.png 84. voxsim-wt/place-enchant/renders/enchant/i2/u_region_iso.png

Read: voxsim-wt/place-yakima/renders/y/p46/y2_hero_cliffband_eve.png

voxsim-wt/place-yakima/renders/y/p46/y2_hero_cliffband_eve.png 85. voxsim-wt/place-yakima/renders/y/p46/y2_hero_cliffband_eve.png

Read: voxsim-wt/place-skykomish/renders/sky/i4t/i4_townwall_from_s.png

voxsim-wt/place-skykomish/renders/sky/i4t/i4_townwall_from_s.png 86. voxsim-wt/place-skykomish/renders/sky/i4t/i4_townwall_from_s.png

Read: (tmp)

(tmp) 87. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 -c " from PIL import Image im=Image.open('refs/swiss/bietsch_lk25_1m.png').convert('RGB'); c=im.crop((900,700,1200,950)); c.resize((
cd ~/work/pixelart-studies/books/imhof; python3 -c "
from PIL import Image
im=Image.open('refs/swiss/bietsch_lk25_1m.png').convert('RGB'); c=im.crop((900,700,1200,950)); c.resize((900,750),Image.NEAREST).save('(tmp))"

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 88. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; wc -w NOTEBOOK.md; ls ocr/*.txt | wc -l
cd ~/work/pixelart-studies/books/imhof; wc -w NOTEBOOK.md; ls ocr/*.txt | wc -l

# output:
7639 NOTEBOOK.md
     405
Bash: cd ~/work/pixelart-studies/books/imhof; for i in $(seq -w 0 404); do f=ocr/p-$(printf %03d $((10#$i))).txt; echo "=== file page $((10#$i))"; cat $f; done > (tmp)
cd ~/work/pixelart-studies/books/imhof; for i in $(seq -w 0 404); do f=ocr/p-$(printf %03d $((10#$i))).txt; echo "=== file page $((10#$i))"; cat $f; done > (tmp) wc -c (tmp) cp (tmp) ~/work/pixelart-studies/library/text/imhof_cartographic_relief.txt; cp (tmp) ocr/ALL.txt; for i in $(seq 362 372); do cat ocr/p-$i.txt; done | grep -v '^\s*$' | awk 'length($0)>25' | cut -c1-160 | head -120

# output:
1092074 (tmp)
B. Combination of various elements of terrain representation 345
All these difficulties lead to considerable generalization in the concept and colors of the
map and the introduction of conventional symbols. However, carried to the extreme, these
conventionalized maps cannot be considered as examples for the combinations of elements
of terrain portrayal. They are thematic maps based on vegetation or agriculture patterns and
as such they are not the concern of this book.

16. Contours with equal vertical intervals, hachures and hypsometric tints. This combination
of three elements is of little use for many reasons. Contours with equal vertical intervals
belong to large and medium-scale maps, hypsometric tints to the smaller scales. Maps of
this type are untidy in design, the symbols employed working against one another. They are
also uneconomical to produce.
Only in exceptional cases can the method be usefully applied — such as in medium-scale

17. Contours with equal vertical intervals, shading tones and hypsometric tints. As we have
seen, the combination of contours with equal vertical intervals with shading tones is very
successful, but should be considered only for maps of large and medium scale. The addition
of fixed hypsometric tints is seldom recommended, however, since the resultant banded
effect would interfere with the detailed shading of small-scale maps to too great an extent.
In general, the same untidiness and problems of scale as mentioned above, under section
16, apply in the combination of these three elements.
In small-scale maps the corresponding ‘model’ is so flat and intricately detailed that a
superimposition of these three named graphic elements does not lead to a technique with
References: 105, 107, 114, 128, 129, 130, 132, 137, 139, 141, 144, 145, 150, 163, 185,
Observations on Map Reproduction Techniques
Although the study of map reproduction techniques does not fall within the theme of this
book, mention has been made in preceding chapters, where it appeared significant, of the
interplay between cartographic and reproduction aspects. These observations are sum-
marized and further developed in this chapter.
Cartography makes its own demands on reproduction technology, and in some ways it
provides unique problems for the printer.
First of all, in contrast to other multi-colored images, the cartographic image, and the
topographical map in particular, contains the very finest line, texture and symbolism, in
various colors and combined with multi-colored areal elements.
Secondly, there is seldom a complete original of a quality suitable for duplication avail-
able before reproduction begins. Much more often, for economic and technical reasons,
reproduction originals are produced for individual elements, separated by color and content.
This unusual characteristic of the image and of the reproduction originals is probably known
only in cartography. In most cases, it rules out the use of stan
Write: books/imhof/MODEL.md
# Imhof's model: *Cartographic Relief Presentation*, in its own terms

Eduard Imhof, *Kartographische Geländedarstellung* (de Gruyter 1965); English edition ed. H. J.
Steward (de Gruyter 1982). Page numbers are the printed pages of the 1982 edition ("p."). The copy in
the library is a 1-bit scan: every line figure survived intact, every halftone plate (the shading
figures, the photographs) is thresholded to blotches, and the colour plates are not in the scan at
all. So everything below about tone and colour is taken from the text, and I checked the look of it
against swisstopo's current maps, which descend directly from Imhof's school. A full OCR text is in
`ocr/ALL.txt` and `library/text/imhof_cartographic_relief.txt`.

This file is the book's model: its principles, rules, procedures and vocabulary, as Imhof states
them. My translations into pixels are in LESSONS.md.

---

## 0. The stance

- **The map is not a photograph** (p.44-52). A photograph is "a jumble of light and dark patches,
  whose tonal values often depend on all kinds of things which have no relation to form and
  position"; cast shadows obscure relationships; far areas fuse by accident of tone. A drawing makes
  similar things look the same, emphasizes the important and suppresses the unimportant (p.52).
  "Views of nature ... contain no lines. The drawn line is a human invention, a useful abstraction"
  (p.45).
- **Imitate the natural effects "as far as is possible with the graphic elements available"** (p.79).
  For showing form in plan, "by far the best and often the only indicator of form is the effect of
  light shining obliquely" (p.79).
- **Direct and indirect elements** (p.83-85). Direct: what works like nature (light and shade,
  aerial perspective, natural colour). Indirect: conventions you must learn (contours, hachures,
  "the steeper the darker", layer tints). Only their interplay satisfies every requirement.
- **Good or bad, not right or wrong** (p.24). "The solution of a mathematical problem is either right
  or wrong, but the solution of a problem in cartography is, within certain limits, only good or bad."
  Keeping the true form (A-A in Fig.15) is usually better than keeping every detail in the wrong
  place (B-B).
- **Sketching means leaving things out** (p.45). "Leaving out, however, does not mean sketching
  inaccurately. Uncertain scribbling, formless or distorted hachuring, dot patterns, lines of scree
  dots, etc. betray inability. We should be able to see the wood in spite of the trees."
- **Beauty and clarity coincide** (p.72, 359). "A clear map is beautiful as a rule." "Uninterrupted
  noisy clamor impresses no one. Activity set against a background of subdued calm strength produces
  a deeply expressive melody."
- **Cartography is the art of moderation** (p.328): "When everyone at a conference shouts at the
  same time, no one can be understood."

## 1. Vocabulary

| term | meaning (page) |
|---|---|
| **slope shading** | tone by "the steeper, the darker"; flats white; no aspect (p.162) |
| **oblique hill shading** | tone by light from an assumed oblique, **diffuse** light; flats get a mid tone (p.166-171) |
| **combined shading** | the hachure-map inheritance: oblique light on the mountains, "steeper darker" elsewhere, flats white (p.194) |
| **"vertical illumination"** | the false theory behind slope shading; "bored like a woodworm into ... cartographic writing for over one hundred years" (p.162) |
| **expressionless** | a curved surface that receives one uniform tone, so its curvature is invisible (p.173) |
| **local light adjustment** | letting the light "wander" along slopes and around masses, up to 30° from the main direction (p.174-175) |
| **aerial perspective** | with distance, darks lighten, lights dull, contrast falls, everything goes grey-blue; in a map *high = near* (p.172) |
| **skeletal lines** | the ground plan of the terrain's edges: crests, gully lines, breaks of slope, terrace and slab edges (p.105) |
| **positive / negative skeletal lines** | crests (erratic, broken, interrupted) / gullies (elongated, always linked, branching upward) (p.105, 252) |
| **demarcation lines** | where rock ends against scree, vegetation or snow (p.251) |
| **slope hachure** | Lehmann's strokes down the fall line, in rows between assumed contours, thickness by slope (p.214) |
| **shadow hachure** | the same strokes, thickness by oblique light (p.224) |
| **rock hachure** | free strokes that follow the rock's structure and together make the tone of a shaded image (p.259) |
| **tapering hachure** | a wedge stroke for a small steep slope: heavy at the top edge, fine point at the foot (p.285) |
| **form-line effect** | parallel layers exposed by a surface show as lines, spreading as the surface becomes parallel to them (p.146) |
| **generalization** | making a new, legible form for the scale, not reducing the old one (p.126-134, 188) |
| **postage-stamp cartography** | tiny samples drawn so dense that the same density over a whole sheet is unbearable (p.327) |
| **Swiss style** | contours + rock drawing + oblique shading + aerial-perspective colour tones: "the ideal landscape relief map" (p.341) |

## 2. The problem of scale (Ch.5, p.75-86)

- The same kind of land at different scales (Figs 28-32, p.77): at 1:5,000 little is seen from
  above; at 1:25,000 rocky ridges collapse ("vertical cliff steps shrink together in plan"); at
  1:100,000 the rise and fall is most expressive; at 1:1,000,000 and smaller the land is detailed but
  flat. "Without supplementary imaginary modes of expression and ... drastic simplification and
  exaggeration of form, no image could be produced which has any value."
- Depth cues (p.78): thirteen of them, of which the one that makes structure strokes work is no.11,
  surface structures related to the form, "so that bending of the normal structure permits
  recognition of undulations".
- The model experiment (Fig.33, p.81-83): one cone in a ring-shaped groove, drawn eight ways. Slope
  shading gives cone and groove the same tone; contours can't say cone or crater; layer tints make a
  pipe or a tunnel; **oblique shading alone makes you see the form without a key**; shadow hachuring
  and combined shading contradict themselves where flats meet sunny slopes.

## 3. Lines: contours, skeletal lines and their generalization (Ch.7-8)

- **Legibility** (p.113): a line about 0.1 mm wide wants a gap about 0.4 mm; about 2 lines per mm is
  the limit.
- **Density is the form signal.** Mixed-interval systems are rejected because "all parts of the
  terrain, whether flat or steep, appear to be equally covered ... any suggestion of shape, produced
  by the grouping and spacing of these contours, is lost" (p.123).
- **Large scales want sparser than possible, small scales denser than theory** (p.116-119): the ideal
  interval grows non-linearly with scale; at large scale "the high density and overloading ... go
  against good perception".
- **The eight rules of generalization** (p.132): (1) generalize surfaces, not lines; never one line
  without its neighbours; (2) smooth out details not echoed in neighbouring contours; (3) neighbours
  never touch except at cliffs; (4) neither gently wavy nor jagged everywhere; (5) first transfer the
  crests and breaks of slope as guides; (6) a form crossing several contours is kept in all or
  dropped in all; (7) keep the prominent parts; broaden the larger negative form at the expense of
  smaller positive ones; (8) move lines only as much as legibility needs. "Inaccurate, incompetent
  drawing should never be excused on the grounds that it is presumed generalization."
- **Poor vs good** (Figs 76-85): poor generalization rounds everything into blobs (young sharp gullies
  and old flat surfaces become alike) or replaces every curve by a corner; good generalization keeps
  crests as crests, gullies sharply cut, limestone ribs angular.
- **Skeletal lines** (Ch.7): negative lines branch upward like a leafless crown; positive lines are
  erratic; on rock the gullies branch like a **poplar** (acute, nearly parallel), not like an apple
  tree (p.246). Drawn crest lines are usually a mistake: "The careful representation of a rocky area,
  with sharp contrast between light and shade, brings out the ridge much more clearly than would the
  crest line" (p.108).
- **The line of slope is natural; the horizontal line is not** (p.148): "Erosion and deposition always
  work in the direction of greatest slope. Along with the edge line, the line of slope is a
  characteristic producer of the naturalistic image." (The exception is rock that is itself layered:
  the form-line effect, p.146.)
- **Illuminated contours** (Figs 107-112, p.150-154): on a mid-grey ground, contours white on the lit
  side and black on the shaded side, the width following the light; works on favourable forms,
  stepped look elsewhere. **Shape lies in the transition** (p.143).

## 4. Tone: shading and shadows (Ch.9, the core)

### 4.1 The procedure (p.169-170, rules a-l)

1. Sketch all edge and skeletal lines first: the framework.
2. Choose the main light azimuth after studying the whole area. **Never parallel to the dominant
   ridges and valleys**; cross them at an acute diagonal. Generally from NW, W or SW. Mark where local
   deviations will be needed.
3. Cover all significant **horizontal surfaces with a medium grey** (valley floors, terraces,
   plateaus). "This medium tone is vital."
4. Shade the steep slopes facing away from the light; then lighter tones on flatter slopes and those
   only slightly turned away.
5. Work outward "around", "over" and "through" the ridges and hollows; slopes that the light grazes
   get intermediate tones by gradient. At the end only steep slopes directly facing the light are
   free of tone.
6. Use small local changes in the light for inflections the main light misses.
7. Start light; develop the contrast gradually.
8. **The transitions.** "The form depends upon these transitions. Sharp ridges require a sudden and
   crisp change from light to dark, while rounded landforms require softer shading transitions." An
   even slope is one homogeneous tone. "The dark bands or strips, which are drawn lovingly along the
   shaded sides of terrain ridges ... are meaningless and contrary to the form."
9. Large forms badly placed to the light: bring out their **secondary forms**. "The three-dimensional
   impression is not evoked by any individual grey tone, but rather by a spontaneous visualization of
   the entire scheme of shading."
10. A plane keeps one base tone, but may be pushed lighter or darker toward one side for contrast with
    its neighbours ("gives the forms more life").
11. Draw every small inflection precisely, but emphasize the larger relationships.
12. Check the drawing in a **mirror**.

### 4.2 Rules about light (p.171-185)

- **Flats carry a mid tone** (p.171). Without it lit slopes can't be told from flats; it "binds and
  subdues and permits small colored features ... to be brought out".
- **Diffuse light always** (p.168): softness and transparency of shadows.
- **Cast shadows, reflected light and highlights are left out of a map** (p.171-172): cast shadows
  have "no formal relationship with the local terrain"; reflected light leads to errors of
  interpretation; highlights distort. (All three are allowed in "works of art", and his own Walensee
  painting uses deepened cast shadows and reflected light: p.337.)
- **Aerial perspective by elevation** (p.172-173, 187): high = near, so sharpen light/shadow contrast
  toward the summits and soften it toward the lowlands; only with large elevation differences, with
  discretion; overdone, even slopes look concave. It also prevents relief inversion.
- **Local light adjustment** (p.173-177, Figs 130-131): a single parallel light leaves some ridges
  bold and others expressionless (Perron's Swiss model under W light: N-S ranges bold, E-W chains
  "dissolve into many small patches"). Let the light wander, **at most 30°** from the main direction,
  never wildly different on adjacent parallel slopes. Where a curving ridge turns its shadow side over
  (Churfirsten, Fig.131,5-6), work up the crest breaks and secondary forms. The drawn shading (Fig.130,6)
  "unifies the multitude of forms appearing in the three model photographs". "Better no relief
  drawing than a poor one."
- **Vertical light angle adapted to the forms** (p.177): flat undulating land, under 20°; steep shaded
  slopes need more than 45° or they black out; steep lit slopes want a flatter light so the steepest
  are the lightest.
- **Against formulae** (p.177-178): Wiechel's 1878 H = cos ε with a fixed NW 45° light (flat = mid
  tone) — which is exactly today's GIS hillshade — is "quite meaningless": "Three-dimensional effects
  do not result from the degree of local shading but rather from the interplay of tones."
- **Light from the upper left** is a right-hander's writing and drawing habit; true south light makes
  most viewers see the relief inverted; **west or south-west light**, "not unlike the commonly
  observed effect of sunlight in late afternoon or evening", is his recommendation (p.178-183).
- **Leonardo** (p.185): "between light and dark there exists a hybrid area ... a bright shadow or a dim
  light. This you must seek." The steep dark and steep lit slopes are easy; "the critical part ... is
  what lies between the two". "One and the same grey tone ... can appear in one place as a shadow and
  in another as light." Tones that collapse into "solid or non-existent" destroy every form.
- **Four difficult cases** (p.186): (a) slopes facing the light are lit all over: catch every gentle
  curve with small light changes; (b) steep lit / gentle shaded scarplands: keep the steep lit slopes
  light and the gentle shaded ones not too dark, sharpen small bends, darken neighbouring flats; (c) a
  small steep cone beside a broad eminence looks too low, because "the impression of height is ...
  also dependent on the sizes of the areas": overemphasize the small; (d) a small counter-slope in a
  big shadow reads as a sharp ridge if lit to its form: subdue it. "It is the overall impression of
  form which is important and not a strict adherence to some geometric law of shading."
- **Emphasis on large landforms** (p.187): (1) grade by elevation (aerial perspective); (2) "On the light
  side of a major watershed one should lighten all local shadows to a certain degree. On the shaded
  side ... strengthen them, the local illuminated areas being subdued further by application of a weak
  overall shadow which is not strong enough to destroy the detailed modulations" (Fig.132).
- **Generalization of shading** (p.188-190, Figs 133-134): photographic reduction fails ("cluttered,
  poor in quality and lacking in overall effect"). First smooth out gullies, niches, projections,
  terraces; then merge valleys and ridges; stress narrow but important valleys by tone; at small
  scales tone follows **elevation and relative relief**, not slope; exaggerate some heights; **keep
  the local character** (Alpine sharp ridge, Norwegian flat glaciated tops, volcanic cones). Poor:
  ravines that "devour" a tableland; stylized rounding; ridges "like water droplets ... with peaks
  standing up like masts".
- **Shading colour** (p.190-192): near shadows are grey; distant landscape shadows grey to pure blue:
  "select grey-blue, or blue-violet-grey shadow colors. Neutral grey is too lifeless while pure blue is
  too bright." Yellow, pink, red, red-violet can't model form.
- **Snow and glaciers** (p.192-193): don't shade snow more weakly (ridges become plateaus); "the
  shading tones should be used for the delineation of form alone and not to suggest the material".
- **Combined shading** (p.194-195): clear in high mountains, contradictory elsewhere; darkening steps on
  the lit side is "contrary to the natural effects of obliquely illuminated models".
- **Tools** (p.196-201): shading is built on a dense framework of guide lines. The pencil beats the
  airbrush: "Sharp and irregularly shaped terrain is always given too soft an impression by the
  air-brush ... Form is depicted more truly and more characteristically by the positive strokes of the
  pencil"; the airbrush is for flats, overall tones and aerial-perspective tones. Stroke direction:
  "On rounded land forms ... follow the direction of the contour with the pencil, while on sharp edged
  features ridge and slope directions are to be preferred." Watercolour is built from the lower levels
  up toward the ridges. "The art of 'scraping out' is just as important as the art of drawing in."
  Grey paper: start from the flat mid tone, darken the shadows, scrape out the lights.
- **Model photography and the computer** (p.205-212): models and single parallel light give
  expressionless areas and "a chaotic confusion of individual forms", and don't generalize. Of computer
  shading (his student Yoeli, Brassel, Hügli) he names three difficulties: (1) acute forms vanish and
  obtuse ones look acute under one parallel light, and the fix, "extremely delicate adaptation of the
  assumed light direction to the local topographical requirements", is "probably beyond the
  formulation of a programming logic"; (2) simultaneous contrast: "a flat area interrupting a dark
  slope ... will appear too bright ... could be interpreted as a rising slope"; (3) generalization: "The
  computer program, however, is 'blind'". His two suggestions: combine a second shading with the light
  turned 10-15°, and treat the computed shading as a working base to retouch by hand.

## 5. Hachures (Ch.10)

- **Lehmann's five rules** (p.214-218): strokes down the fall line; in horizontal rows between assumed
  contours; stroke length = horizontal distance between those contours; tone by **stroke thickness**
  with the pitch constant (black:white = a : 45°−a, black at 45°); the same number of strokes per cm
  everywhere. Consecutive rows are offset, not joined.
- **Shadow hachures** (p.224): the same construction, thickness by oblique light. No white strips are
  needed on ridges, "since excellent results and the depiction of good, detailed form is obtained
  through the change from light to shaded slopes" (Fig.150).
- **The failures** (p.222-223): dark steep slopes meet on ridges and need white separation strips, which
  widen and flatten ridges and valleys; rigid rows can't follow fine forms; separated bands create
  false terracing. "The evil became standardized, the standard procedure became doctrine, the
  doctrine became the ideal."
- Coarse patterns look hard and woodcut-like but clearer and more transparent; fine ones smoother
  and flatter (p.221).

## 6. Rock drawing (Ch.11)

### 6.1 Rock shows its structure (p.236-250)

"The external forms of rocky walls and mountain ridges ... clearly betray their composition and their
internal structure" (p.238). Imhof's pen studies (Figs 159-175) are the book's most transferable
pictures:

| figure | rock | what the drawing does |
|---|---|---|
| 160 | homogeneous soft (flysch) | uniform slopes, roof ridges: a few long fall lines |
| **161** | **granite** | large masses, sharp needle spires: long vertical flutings following the joints, spires as clustered wedges |
| 162 | gneiss | pyramids; sub-parallel wedge-shaped rocks projecting everywhere |
| 163 | inclined crystalline strata | asymmetric roofs, splintered layered slopes, jagged dip ridges |
| 164 | steep structure across the wall | narrow deep runnels and chimneys; ribs needle-like at the top |
| **165** | **horizontal beds** | table summits, stepped slopes: vertical joint strokes on the hard beds between horizontal ledge lines; soft beds recessed; talus below each band |
| 166 | inclined beds | pulpit peaks, ledges at the layer ends |
| 167-168 | vertical slabs | serrated ridges, slab walls |
| 169 | folded beds | the strata drawn as bands |
| 170 | gullies in homogeneous rock | "group of normal forms": gullies with steep back walls and narrow outlets, subsidiary ridges from the main ridge down to triangular frontal facets |
| 171-174 | gullies vs bedding | beds parallel to the wall: wide shallow gullies; across the wall: precipitous gullies and buttresses; horizontal: hard beds make cliffs with clefts, soft beds benches, undermining makes bastions; oblique: gullies deflected |
| 175 | debris | gully -> debris cone like an hourglass, 30-40°; fines high, big blocks run out to the valley floor; "the limits of the cliff-base are sharply defined in most cases as they are covered by scree cones" |

Rock gullies are elongated, scoured, seldom stepped, steepest in their upper reaches; the contours
twist most half-way down; "the debris mantle forms the local base level" (p.246).

### 6.2 Form analysis before drawing (p.251-252)

1. **Demarcation lines**: the limits of rock against scree, vegetation and snow.
2. **Gully lines**: from the tops of the debris and snow cones up into the basins.
3. **Crest lines**: the upper edges of the cirque-like basins; main ridges thick, secondary finer.
4. Only then the finer lines of internal structure (strata, bedding, slides, folds): "secondary in
   significance ... subordinated to the overall portrayal of the topography".

"The ravine lines ... and the crest lines ... divide what was formerly a confused, impenetrably
complicated image into its principal regions, imparting form to the whole." The two meshes seldom touch,
except at basin mouths "in a sort of scissors-like fashion".

### 6.3 Rock shading (p.257-258)

Large forms first and boldly: main lit side light, main shaded side dark, transitions medium; then every
rib, gully, crack and layer "without distracting attention from the broader formations". Local light
adjustments everywhere, especially on the main lit and main shaded sides ("otherwise lifeless").
**Steep slopes more strongly shaded on both sides** than gentler rock. Aerial perspective: contrasts
sharper high up; "When this graphic technique is ignored, the foot of a cliff can easily look like the
upper edge of the same cliff." Grey tone alone on small rock areas looks like "smudges or spots".

### 6.4 Shaded rock hachuring (p.259-263) — "It *is* rock drawing."

- Hachures laid over the skeletal framework so that together they make the tones of a (prepared or
  imagined) shaded image. Freer than slope hachures, like a perspective pen sketch (Fig.177).
- **Fig.178, good and poor forms** (p.260-261):
  - good: (1) horizontal hachures shaded by stroke width; (2) slope hachures, rough, **thickened at
    the top** to mark the step; (3) a narrow band as width-shaded horizontals; (9) slope hachures for
    steep walls interwoven with horizontals for the ledges on soft layers; (12) steep scale-like
    buttresses: gullies smoothly and sharply drawn, meeting upward at acute angles, narrow open lit
    strips matching the shaded areas, **"Ridge crests are not drawn in, the scale-like type of
    structure being brought out by the variation of light and shadow"**; (15) a slab wall channelled by
    gullies: the whole wall a light medium tone of horizontal form lines, **each gully "a crisp
    illuminated side paralleled by a well defined shadow"**.
  - poor: (4) shading that doesn't follow the bends; (5) slope strokes with no emphasized top,
    "feathery"; (6) "a pattern of pecks"; (7) cross-hatching, "cloth weaving, but not of rock"; (8) a
    thick outline, "cut-up and wooden"; (10) "confused, feathery, stippled"; (11) rigid structure lines
    and cross-hatching, too dark; (13) stylized feathery strokes, no area modulation; (14) ribs outlined
    hard, horizontals on narrow steep rises like "leaning ladders"; (16) form lines that start in the
    middle of a surface for no reason; (17) shadow strokes for gullies with no lit strips opposite.
- **Stroke attitude** (p.261-262): follow horizontal features or the slope; steep steps in bedded rock
  = rows of short downslope strokes; flatter layers = horizontals; narrow steep slabs and crystalline
  pinnacles are *not* ladders of horizontals but "a few characteristic crest lines and slope
  hachures"; change direction only when the form demands; avoid cross-hatching (if it must be, diagonal,
  one direction dominant). Dipping strata drawn with slope hachures make "a structural drawing".
- **Shading by width** (p.262): with every bend of a stroke its width changes with the light. The lit
  sides get "an extremely light distribution of fine hachures". **Every tiny shaded section — fissure,
  gully, tooth, notch — is matched by a light, unshaded surface on its opposite side.**
- **Density** (p.262): constant; tone by thickness, not interval. Overall "lightness and
  transparency. Rocks should not lie heavily in the map like lumps of coal."
- **"The form lies in the transition."** (p.262-263) "A firm outline round every surface element ruins
  the image ... as if they were constructed from hard wood." Skeletal lines are aids and seldom appear
  finished. "The impression of a sharp edge is produced not by means of ridge lines, but rather
  through the sharp change between adjacent light and shadow areas." The edge of a rock step comes from
  **starting each hachure crisply and firmly**. Continuous outlines only in narrow sharp deep gullies.
  A saw-tooth crest: "allowing very tiny shadows and light areas to cut across the edge of the ridge."
  No stylized twisted strokes.
- Aerial perspective (contrast up with elevation); fit the small into the large (small forms "so
  slightly that they become secondary to the effects of light and shade on the main surfaces");
  open out very steep cliffs at the expense of neighbouring flatter rock (p.263).
- Colour: dark (black, dark brown, strong brown-violet, dark grey) (p.266).
- **Glacier-polished rock** (roches moutonnées): "the lightest, smoothest horizontal hachures
  possible" (Fig.188); **grassy, scree or snow slopes with protruding rocks**: lightest horizontals plus
  small isolated groups of slope hachures, to avoid "restlessness" (Fig.189) (p.270-271).
- **Small scales** (p.272-273, Figs 190-192): the space for hachures disappears; a simplified **shaded
  skeletal outline** takes over; lit slopes can no longer bear hachures; the smallest shadows in
  gullies, cracks and notches become heavier generalized strokes. Under a glass it looks like "a
  wood-cut ... composed of completely light and completely dark". Combined with hill shading, "the rock
  drawing ... provides a final intense shadow tone, reduced to the smallest area in the form of lines".
  Photographic reduction of the large-scale drawing (Fig.192) is a tangle; the generalized redrawing
  (Fig.191) keeps the bands and gullies.
- **Training** (p.273): draw rock from nature at suitable distances and lights; trace skeletal lines
  over photographs; copy good rock maps at successively smaller scales; then: skeletal image in hard
  pencil -> shaded pencil tones -> turn the tones into hachures -> rub away unnecessary strokes and
  ridge lines. The scraper is indispensable.

## 7. Small forms (Ch.12)

- **Tapering hachure** (p.285): exactly downslope, "sharply and heavily at the upper edges of steep
  slopes, continue downward in a fine line and end in a point"; rows; tall slopes split into several
  rows. "Crocodile teeth" are wrong.
- **Scree dots** (p.291-292): fine dots on the steep upper slope, large dots and small blocks in
  disorder at the foot; lines of scree run down the slope; the common failing is dots too dense and too
  even. Oblique light: lit side fine and open, shaded side dense and heavy (p.284).
- Glaciers: freely arranged slope hachures for ice walls; crevasses as short crisp lines tapered at both
  ends; systems crossing at shallow angles (p.292).

## 8. Colour (Ch.4, 13, 14)

- **Perception is relative** (p.60): colour constancy under changing light; surrounds shift hue and value.
- **Brightness alone makes depth** (p.60-61, Fig.33): rings darkening to the centre read as a hole,
  lightening to the centre as a far tunnel; graded like "their appearance in nature when viewed in a
  landscape", colours give high and low: aerial perspective.
- **Six rules of colour composition** (p.72-73): (1) pure strong colours unbearable over large areas;
  strong colours for small extremes; (2) light bright colours side by side over large areas are
  unpleasant; (3) large backgrounds muted, greyish; grey the most versatile; (4) interweave the colours
  "carpet-fashion", theme colours scattered like islands; (5) one basic colour mood, as sunlight unifies
  a landscape; (6) steady, gradual de-emphasis; aerial perspective helps. Yellow light produces blue or
  violet shadows (p.70).
- **Natural colours** (p.296-299): a map shows "the most generally representative or predominant
  aspect". Medium and small scales look most natural in the colours seen from the ground at the
  corresponding distance (1:100,000 ≈ a landscape at 30-40 km): dominated by light and shade and the
  grey-blue veil. "The finer and more detailed the relief picture, the more sensitive it is to
  disturbance by a multi-colored mosaic of the surface cover." **Values matter more than hues.**
- **Layer tints for relief maps** (p.306-310):
  - types 9/10: a light scale in which the ground tone lightens upward so that light/shadow contrast
    can grow upward: grey-blue, blue-green, green, yellowish green, greenish yellow, yellow, light
    yellow, white; the shadow-side scale (type 10) converges with the lit scale toward the bottom
    ("veiling effects of aerial perspective"), modelled on a sunny summer high-oblique air photo over
    Lake Lucerne;
  - type 11 (large and medium relief maps): greener middle steps; the top through yellow to a light
    **pink, orange or yellowish brown**, and **all rock and scree get that reddish light tone**
    (glaciers white). "Without the addition of pink, such maps would seem too cold." "Solely insensitive
    people apply this red too strongly, thus introducing the effects of falsely glowing alpine peaks
    seen in cheap landscape postcards";
  - type 13 (small scales): white, light yellow, reddish yellow, brownish yellow, yellowish brown,
    olive, brownish green, blue-green, strong greenish blue, grey greenish blue. Lightness dominates hue.
- **The Swiss style's colour recipe** (p.340-341, Plates 11 and 14): shading tones "not dull, mouse-grey
  and dirty looking, nor ... stylized red-violet, or red-brown, but rather a **hazy blue-grey**. Toward
  the upper reaches ... more intensive and redder (more carmine), but toward the lower areas lighter
  and bluer." The bright haze blue on valley floors and low hills, restrained from climbing the lit
  slopes. The yellow light tone graded like "negative" hill shading (lightened or removed in strong
  shade) and lighter on valley floors; rocky lit slopes a yellowish red. **"Shading tones should
  dominate the surface tones, they should not be allowed to be submerged by them. This is very
  important."** Plates: strong dull brown (rock skeleton, scree contours), blue (water), red-brown
  (contours), **blue-grey (main relief shading), grey-violet (supplementary shading)**, light blue
  (haze on shaded slopes and lakes), **yellow (light toning)**, **pink, extremely weak (rock and high
  regions)**, black (text).
- **Walensee, 1:10,000, gouache, 1938** (p.336-337, Plate 10): the landscape painting in plan view —
  our genre. Not a colour air photo ("vague, flat, grey, blurred ... a momentary phenomenon") but "the
  visual experiences of a landscape painter". Theme: shimmering green, silvery grey depths against warm
  red-brown heights, woven together by the atmosphere, with a calm blue-green lake. Shadow tones
  "India red, brown, grey, blue or deep green" according to the ground under them; shadows lightened by
  reflected light but **cast shadows strongly deepened**; a slope in sun "bright ochre or yellowish red,
  but in shadow ... a deep red violet". "The colored mosaic of ground cover influences the color of the
  depth effects but does not destroy them." "But the end result is not a map."

## 9. Interplay (Ch.14)

- Patterns of the same kind laid over each other destroy each other; different kinds (lines over flat
  tints) reinforce. If two line patterns must overlap, one open, large-meshed and strong, the other
  fine, dense and light (p.326).
- **Postage-stamp cartography** (p.327): what reads in a tiny sample is unbearable over a sheet.
- Dense black rock hachures in a pale shaded map: "a pale, lightly-colored pudding in which several
  lumps of coal are embedded" (p.328). Topographers carry 1:5,000 relationships into 1:50,000: "One
  cannot pick out the scree slope for the stones." The smaller the scale, the more surface cover is
  subdued in favour of general form.
- Forests disturb relief shading: lighten or open them, or print the forest *with* the relief (p.332).
- Contours and shading must agree "down to the finest detail" (p.335).
- Skeletal rock drawing with detailed hill shading "can lead to excellent results" if the skeleton is
  crisp, without extra hachures, in a strong brown, grey or violet (Figs 220-221). Thick rock hachures on
  lit steep slopes "darken them so much that the intended modelling effects never materialize" (p.336).
- **On art** (p.359): "in cartographical affairs, as in all graphic work, the greatest clarity, the
  greatest power of expression, balance and simplicity are concurrent with beauty."

## 10. What the model is, in one paragraph

Terrain is shown by an imagined oblique, diffuse light from the west-north-west, adjusted locally so
that no important form is expressionless, over a flat mid tone that lets lit slopes rise and shaded
slopes fall; contrast grows with height (aerial perspective) and the large landforms are stated first,
with the small forms subordinated inside them; the form lives in the transitions — crisp on sharp
ridges, soft on rounded ones — never in outlines. Rock is drawn by its structure: first the demarcation,
gully and crest skeleton, then strokes that follow the rock's own structure (fall line on steep walls,
horizontals on ledges and bedded rock), whose weight follows the same light, each shadow matched by a
light strip, edges made by crisp stroke starts, the whole light and transparent. Every scale is a new
drawing, generalized by knowing how the land formed, and keeping its character. Colour is the light and
the atmosphere: yellow in the sun, hazy blue-grey in shade, warmer and stronger up high, bluer and paler
down low, and the shading always dominates the surface colours.
Bash: cd ~/work/pixelart-studies/books/imhof/code; time python3 -c " import dem, numpy as np z=dem.swiss(2630700,1137000,2632700,1138800); print(z.shape, np.nanmin(z), np.nanmax(z), np.i
cd ~/work/pixelart-studies/books/imhof/code; time python3 -c "
import dem, numpy as np
z=dem.swiss(2630700,1137000,2632700,1138800); print(z.shape, np.nanmin(z), np.nanmax(z), np.isnan(z).sum())
np.save('../refs/swiss/bietsch_dem2m.npy', z)
"; du -sh ../refs/swiss/dem

# output:
(900, 1000) 2835.8472 3933.5476 0
python3 -c   0.89s user 0.67s system 13% cpu 11.909 total
9.0M    ../refs/swiss/dem
Write: books/imhof/code/imhof.py
"""Imhof's relief presentation, translated to pixels.

Every function takes a heightfield z (metres, rows = north->south, cols = west->east) and the cell
size in metres. Angles: azimuth in degrees clockwise from north (the direction the light COMES FROM),
elevation in degrees above the horizon.

Layers, in the order Imhof builds a relief drawing (MODEL.md 4.1):
  normals / slope / aspect      the geometry
  lambert()                     Wiechel 1878 = GIS hillshade (the baseline Imhof rejects, p.177)
  imhof_tone()                  oblique diffuse light over a flat mid tone, with
                                  - local light adjustment (<= 30 deg, p.174)
                                  - vertical angle adapted to slope (p.177)
                                  - large-form emphasis (p.187)
                                  - aerial perspective by elevation (p.172)
  quantize()                    tone -> palette steps, dither only in the transitions (Leonardo, p.185)
"""
import numpy as np
from scipy import ndimage as ndi

D2R = np.pi / 180.0


# ------------------------------------------------------------------ geometry
def gradient(z, cell):
    """dz/dx (east), dz/dy (north)."""
    gy, gx = np.gradient(z.astype(np.float64), cell)
    return gx, -gy          # rows run north->south, so north gradient is -d/drow


def normals(z, cell):
    gx, gy = gradient(z, cell)
    n = np.stack([-gx, -gy, np.ones_like(gx)], -1)
    return n / np.linalg.norm(n, axis=-1, keepdims=True)


def slope_deg(z, cell):
    gx, gy = gradient(z, cell)
    return np.degrees(np.arctan(np.hypot(gx, gy)))


def aspect_deg(z, cell):
    """direction the slope FACES (downhill), clockwise from north."""
    gx, gy = gradient(z, cell)
    return (np.degrees(np.arctan2(-gx, -gy)) + 360) % 360


def light_vec(az, el):
    az = np.asarray(az) * D2R; el = np.asarray(el) * D2R
    return np.stack([np.sin(az) * np.cos(el), np.cos(az) * np.cos(el), np.sin(el) * np.ones_like(az)], -1)


def smooth(z, sigma_px):
    return ndi.gaussian_filter(z, sigma_px, mode='nearest') if sigma_px > 0 else z


# ------------------------------------------------------------------ baselines
def lambert(z, cell, az=315, el=45):
    """Wiechel's H = cos(e), fixed parallel light. Flat ground = sin(el). (p.177: 'quite meaningless')"""
    n = normals(z, cell)
    return np.clip(n @ light_vec(az, el), 0, 1)


def slope_shading(z, cell, full_at=45):
    """'the steeper, the darker' (p.162): 1 = white on flats, 0 at full_at degrees and steeper."""
    return 1 - np.clip(slope_deg(z, cell) / full_at, 0, 1)


# ------------------------------------------------------------------ Imhof tone
DEFAULT = dict(
    az=292.5,            # WNW: 'west ... like late afternoon sun' (p.183); upper-left for a north-up view
    el=40.0,             # base light elevation
    local_dev=30.0,      # max local light deviation, degrees (p.175)
    landform_m=60.0,     # scale (m) at which 'the landform' is read for local light & emphasis
    el_flat=25.0,        # light elevation on gentle ground (p.177: < 20 deg for undulating land)
    el_steep_shade=55.0, # steep shaded slopes: higher light so they don't black out (p.177)
    el_steep_lit=30.0,   # steep lit slopes: flatter light so the steepest are lightest (p.177)
    diffuse=0.25,        # share of sky light: softness and transparency of shadows (p.168)
    big_w=0.35,          # large-form emphasis weight (p.187)
    big_m=250.0,         # scale (m) of the large forms (major watersheds)
    ap_low=0.55,         # aerial perspective: contrast at the lowest point relative to the highest (p.172)
    gamma_lit=1.0, gamma_shade=1.0,
)


def local_light_az(z, cell, az, dev, landform_m):
    """Per-pixel light azimuth. The landform's facing A (from a smoothed surface) is compared with the
    main light: d = angle between light and facing. d' = d - k sin(2d) pushes faces away from the
    expressionless 90 deg (light grazing along the slope) toward lit or shaded, by at most `dev`
    degrees, and the same way on neighbouring parallel slopes because A is read at landform scale."""
    zs = smooth(z, landform_m / cell / 2.0)
    A = aspect_deg(zs, cell)
    s = np.clip(slope_deg(zs, cell) / 15.0, 0, 1)           # no adjustment on flats
    d = ((A - az + 180) % 360) - 180                          # facing relative to light, (-180, 180]
    shift = dev * np.sin(2 * np.abs(d) * D2R) * s             # >0 means: rotate the light toward A
    return az + np.sign(d) * shift


def imhof_tone(z, cell, **kw):
    """Tone in [-1, 1]: 0 = the flat mid tone, >0 lighter than flat ground, <0 darker. Returns dict."""
    P = dict(DEFAULT, **kw)
    n = normals(z, cell)
    sl = slope_deg(z, cell)
    az = local_light_az(z, cell, P['az'], P['local_dev'], P['landform_m']) if P['local_dev'] > 0 \
        else np.full(z.shape, P['az'], float)
    # vertical angle adapted to the forms: first decide lit/shaded with the base light
    L0 = light_vec(az, P['el'])
    facing = np.sum(n * L0, -1) - np.sin(P['el'] * D2R)      # >0: faces the light more than flat ground
    steep = np.clip((sl - 10) / 40, 0, 1)
    el = np.where(facing >= 0,
                  P['el_flat'] + (P['el_steep_lit'] - P['el_flat']) * steep,
                  P['el_flat'] + (P['el_steep_shade'] - P['el_flat']) * steep)
    L = light_vec(az, el)
    direct = np.sum(n * L, -1)                                # may be negative (turned away)
    flat = np.sin(el * D2R)
    sky = n[..., 2]                                           # diffuse sky: up-facing is lighter
    I = (1 - P['diffuse']) * direct + P['diffuse'] * sky
    I_flat = (1 - P['diffuse']) * flat + P['diffuse']
    t = I - I_flat                                            # 0 on flats: the mid tone (p.171)
    t = np.where(t > 0, t / (1 - I_flat + 1e-6), t / (I_flat + 1e-6))   # map to [-1, 1]
    t = np.where(t > 0, t ** P['gamma_lit'], -(-t) ** P['gamma_shade'])
    # large-form emphasis: a weak overall light or shadow from the major landform (p.187)
    if P['big_w'] > 0:
        zb = smooth(z, P['big_m'] / cell / 2.0)
        nb = normals(zb, cell)
        Lb = light_vec(P['az'], P['el'])
        tb = (nb @ Lb) - np.sin(P['el'] * D2R)
        tb = np.clip(tb / 0.5, -1, 1)
        t = t + P['big_w'] * tb * (1 - 0.5 * np.abs(t))
    # aerial perspective by elevation: contrast grows with height (p.172, 187)
    if P['ap_low'] < 1:
        h = (z - np.nanmin(z)) / (np.nanmax(z) - np.nanmin(z) + 1e-6)
        t = t * (P['ap_low'] + (1 - P['ap_low']) * h)
    return dict(t=np.clip(t, -1, 1), az=az, el=el, slope=sl)


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


def quantize(v, nsteps, seam=0.35, pattern=BAYER4):
    """v in [0, nsteps-1] continuous. Flat inside a step; ordered dither only within +-seam/2 of a step
    boundary: planes stay planes, transitions get the half-tones (Leonardo's 'bright shadow or dim
    light', p.185), and nothing collapses to 'solid or non-existent'."""
    H, W = v.shape
    thr = np.tile(pattern, (H // pattern.shape[0] + 1, W // pattern.shape[1] + 1))[:H, :W]
    lo = np.floor(v); f = v - lo
    a = 0.5 - seam / 2
    f2 = np.clip((f - a) / max(seam, 1e-6), 0, 1)
    return np.clip(lo + (f2 > thr), 0, nsteps - 1).astype(np.int32)


def tone_to_steps(t, nsteps=7, mid=None, lit_top=None, shade_bottom=0.0):
    """Map tone [-1,1] onto ramp positions: flat = mid step, lit up to lit_top, shade down to bottom."""
    mid = (nsteps - 1) * 0.55 if mid is None else mid
    lit_top = nsteps - 1 if lit_top is None else lit_top
    return np.where(t >= 0, mid + t * (lit_top - mid), mid + t * (mid - shade_bottom))
Write: books/imhof/code/sheet.py
"""Labelled comparison sheets. panels: list of rows; each row a list of (image, label).
Images: PIL, HxW(x3) uint8 arrays, or float arrays in [0,1]. Each panel is scaled by nearest
neighbour to `scale` (or to height `h` for photos/plates)."""
import numpy as np
from PIL import Image, ImageDraw, ImageFont

BG = (236, 233, 226); INK = (40, 38, 36)
try:
    FONT = ImageFont.truetype('/System/Library/Fonts/Supplemental/Arial.ttf', 15)
    FONT_B = ImageFont.truetype('/System/Library/Fonts/Supplemental/Arial Bold.ttf', 19)
except Exception:
    FONT = FONT_B = ImageFont.load_default()


def to_pil(im):
    if isinstance(im, Image.Image):
        return im.convert('RGB')
    a = np.asarray(im)
    if a.dtype != np.uint8:
        a = (np.clip(a, 0, 1) * 255).astype(np.uint8)
    if a.ndim == 2:
        a = np.stack([a] * 3, -1)
    return Image.fromarray(a[..., :3])


def fit(im, scale=None, h=None, w=None, smooth=False):
    im = to_pil(im)
    if h is not None:
        s = h / im.height
    elif w is not None:
        s = w / im.width
    else:
        s = scale or 1
    size = (max(1, round(im.width * s)), max(1, round(im.height * s)))
    return im.resize(size, Image.LANCZOS if (smooth or s < 1) else Image.NEAREST)


def sheet(rows, title=None, note=None, pad=14, out=None):
    """rows: list of lists of (PIL image already scaled, label)."""
    lab_h = 22
    widths = [sum(p.width for p, _ in r) + pad * (len(r) + 1) for r in rows]
    heights = [max(p.height for p, _ in r) + lab_h + pad for r in rows]
    top = (34 if title else 0) + (20 * len(note.split('\n')) if note else 0) + pad
    W = max(widths); H = top + sum(heights) + pad
    S = Image.new('RGB', (W, H), BG); d = ImageDraw.Draw(S)
    y = pad
    if title:
        d.text((pad, y), title, fill=INK, font=FONT_B); y += 34
    if note:
        for line in note.split('\n'):
            d.text((pad, y), line, fill=INK, font=FONT); y += 20
    for r, hh in zip(rows, heights):
        x = pad
        for p, lab in r:
            d.text((x, y), lab, fill=INK, font=FONT)
            S.paste(p, (x, y + lab_h))
            x += p.width + pad
        y += hh
    if out:
        S.save(out)
    return S
Write: books/imhof/code/strokes.py
"""Stroke fields in pixels: hachures, rock strokes, contours, tapering hachures.

All strokes are rasterised as 1-px runs into an 'ink' map (0 = none, >0 = ink class), so a painter can
decide the colour of each class. Geometry lives in pixel coordinates of the heightfield grid
(x = col, y = row)."""
import numpy as np
from skimage import measure
from scipy import ndimage as ndi


def contour_lines(z, interval, offset=0.0):
    """list of (level, polyline Nx2 in (row, col))."""
    lo = np.floor((np.nanmin(z) - offset) / interval) * interval + offset
    out = []
    for lev in np.arange(lo + interval, np.nanmax(z), interval):
        for c in measure.find_contours(z, lev):
            out.append((lev, c))
    return out


def resample(poly, step):
    d = np.r_[0, np.cumsum(np.hypot(*np.diff(poly, axis=0).T))]
    if d[-1] < step:
        return poly[:1]
    s = np.arange(step / 2, d[-1], step)
    return np.stack([np.interp(s, d, poly[:, 0]), np.interp(s, d, poly[:, 1])], -1)


def sample(a, p):
    """bilinear sample of a at float (row, col) points."""
    return ndi.map_coordinates(a, [p[..., 0], p[..., 1]], order=1, mode='nearest')


def trace(gr, gc, start, length_px, stop=None, step=0.5):
    """follow the unit field (gr, gc) (row, col components) from start for length_px pixels."""
    pts = [start]; p = np.array(start, float); n = int(length_px / step)
    H, W = gr.shape
    for _ in range(n):
        v = np.array([sample(gr, p[None])[0], sample(gc, p[None])[0]])
        m = np.hypot(*v)
        if m < 1e-6:
            break
        p = p + step * v / m
        if not (0 <= p[0] < H - 1 and 0 <= p[1] < W - 1):
            break
        if stop is not None and stop(p):
            break
        pts.append(p.copy())
    return np.array(pts)


def raster_line(ink, pts, val, width=1, side=None):
    """rasterise a polyline (row, col) into ink with value val; width 2 adds the pixel on `side`
    (a (dr, dc) unit offset) along the first part."""
    H, W = ink.shape
    seen = set()
    for i, p in enumerate(pts):
        r, c = int(round(p[0])), int(round(p[1]))
        if (r, c) in seen or not (0 <= r < H and 0 <= c < W):
            continue
        seen.add((r, c))
        ink[r, c] = max(ink[r, c], val)
        if width >= 2 and side is not None:
            r2, c2 = r + side[0], c + side[1]
            if 0 <= r2 < H and 0 <= c2 < W:
                ink[r2, c2] = max(ink[r2, c2], val)
    return ink


def downhill_field(z, cell, smooth_px=1.0):
    zs = ndi.gaussian_filter(z, smooth_px) if smooth_px else z
    gr, gc = np.gradient(zs)          # gradient in row, col (uphill)
    return -gr, -gc                   # downhill


def slope_hachures(z, cell, interval, pitch_px, weight, min_slope=3.0, taper=True, jitter=0.0, seed=0):
    """Lehmann-type hachures in pixels. Rows of strokes start on the contours every `interval` m, spaced
    `pitch_px` along each contour; each stroke runs down the fall line to the next contour (Lehmann's
    rule 3: length = horizontal distance between contours). `weight` (HxW, 0..1) decides how much of the
    stroke is inked: the pixel form of stroke thickness. The top of the stroke is always inked first
    (p.259: 'thickening at the top'), so light strokes are short ticks under the upper edge.
    Returns ink map: 1 = stroke, 2 = heavy (the 2-px top of a heavy stroke)."""
    rng = np.random.default_rng(seed)
    H, W = z.shape
    ink = np.zeros((H, W), np.uint8)
    gr, gc = downhill_field(z, cell)
    slope = np.degrees(np.arctan(np.hypot(gr, gc) / cell))
    for lev, poly in contour_lines(z, interval):
        for p in resample(poly, pitch_px):
            if slope[int(p[0]), int(p[1])] < min_slope:
                continue
            p = p + rng.normal(0, jitter, 2) if jitter else p
            target = lev - interval
            L = interval / np.tan(np.radians(max(slope[int(p[0]), int(p[1])], 1))) / cell   # px to next contour
            L = min(L, 6 * pitch_px)
            zs_ = z
            pts = trace(gr, gc, p, L, stop=lambda q: sample(zs_, q[None])[0] < target)
            if len(pts) < 2:
                continue
            w = float(sample(weight, p[None])[0])
            n_ink = max(1, int(round(len(pts) * min(1.0, w * 1.4)))) if taper else len(pts)
            if w < 0.08:
                continue
            raster_line(ink, pts[:n_ink], 1)
            if w > 0.7:   # heavy: widen the top half on the side away from the light (set by caller via weight)
                d = pts[min(2, len(pts) - 1)] - pts[0]; d /= (np.hypot(*d) + 1e-9)
                side = (int(round(d[1])), int(round(-d[0])))
                raster_line(ink, pts[:max(1, n_ink // 2)], 2, width=2, side=side)
    return ink


def contour_ink(z, interval, index_every=0):
    H, W = z.shape
    ink = np.zeros((H, W), np.uint8)
    for lev, poly in contour_lines(z, interval):
        v = 2 if index_every and abs((lev / interval) % index_every) < 1e-6 else 1
        raster_line(ink, resample(poly, 0.5), v)
    return ink
Write: books/imhof/code/e1_cone.py
"""E1. Imhof's Fig.33 (p.81-83): one cone in a ring groove, eight ways, at pixel scale.
The book's halftone figure is destroyed in our scan; the text describes each panel exactly."""
import numpy as np, sys
sys.path.insert(0, __file__.rsplit('/', 1)[0])
import imhof as I, strokes as S, sheet as SH

N = 96; cell = 1.0
yy, xx = np.mgrid[0:N, 0:N] - N / 2 + 0.5
r = np.hypot(xx, yy)
z = np.clip(30 * (1 - r / 26), 0, None) - 8 * np.exp(-((r - 37) / 5.0) ** 2)

GREY = np.array([[38, 36, 44], [70, 68, 78], [104, 102, 110], [140, 138, 140], [176, 173, 168],
                 [210, 206, 196], [238, 234, 222]], np.uint8)          # 7 steps, cool dark -> warm light


def paint(v, n=7):
    return GREY[I.quantize(np.clip(v, 0, n - 1), n)]


def ink_on(ink, base=6, dark=0):
    img = np.full(ink.shape + (3,), GREY[base], np.uint8)
    img[ink > 0] = GREY[dark]
    return img


P = []
# 1 slope shading: steeper darker, flats white
P.append((paint(I.slope_shading(z, cell, 45) * 6), '1 slope shading'))
# 2 slope hachures: weight = slope
w = np.clip(I.slope_deg(z, cell) / 45, 0, 1)
P.append((ink_on(S.slope_hachures(z, cell, 3.0, 3, w)), '2 slope hachures'))
# 3 contours
P.append((ink_on(S.contour_ink(z, 3.0)), '3 contours'))
# 4 / 5 layer tints
h = (z - z.min()) / (z.max() - z.min())
P.append((paint((1 - h) * 6), '4 higher = darker'))
P.append((paint(h * 6), '5 higher = lighter'))
# 6 oblique shading (Imhof: flats mid tone, diffuse, WNW light)
t = I.imhof_tone(z, cell, local_dev=0, big_w=0, ap_low=1.0)['t']
P.append((paint(I.tone_to_steps(t, 7)), '6 oblique shading'))
# 7 shadow hachures: oblique light decides the weight, flats blank
t0 = I.imhof_tone(z, cell, local_dev=0, big_w=0, ap_low=1.0, diffuse=0.0)['t']
w7 = np.clip(0.45 - 0.9 * t0, 0, 1) * (I.slope_deg(z, cell) > 3)
P.append((ink_on(S.slope_hachures(z, cell, 3.0, 3, w7)), '7 shadow hachures'))
# 8 combined shading: oblique on slopes, flats white
comb = np.where(I.slope_deg(z, cell) < 3, 6, I.tone_to_steps(t, 7, mid=4.2))
P.append((paint(comb), '8 combined shading'))
# 9 Lambert (Wiechel)
P.append((paint(I.lambert(z, cell, 292.5, 40) * 6), '9 Lambert (GIS hillshade)'))

rows = [[(SH.fit(im, 3), lab) for im, lab in P[:5]], [(SH.fit(im, 3), lab) for im, lab in P[5:]]]
note = ("Fig.33 (p.81): a cone in a ring groove on flat ground, 96x96 px at 1 m/px, 3x. Only (6) shows the form without a key:\n"
        "(1) cone and groove the same tone; (3) cone or crater?; (4) a pipe; (5) a tunnel; (7)(8) the lit side of the groove is\n"
        "darker than the flat beside it: shadow reversal. (9) Lambert is (6) without diffuse light: the shadow side goes nearly black.")
SH.sheet(rows, 'E1  Imhof Fig.33 in pixels: one form, eight representations', note,
         out=sys.argv[1] if len(sys.argv) > 1 else '(tmp))
Bash: grep -n "^## \|^### " ~/work/pixelart-studies/elements/rock/LESSONS.md | sed -n 1,80p; wc -l ~/work/pixelart-studies/elements/rock/LESSONS.md
grep -n "^## \|^### " ~/work/pixelart-studies/elements/rock/LESSONS.md | sed -n 1,80p; wc -l ~/work/pixelart-studies/elements/rock/LESSONS.md

# output:
27:## Part 1. Observation: how Washington's rock works
34:### 1.1 The one idea: joints decide everything you see
53:### 1.2 Granite (Enchantments, Skykomish): why it is ribbed, pale, and dark at the same time
76:### 1.3 Columbia River Basalt (Yakima Canyon): why it is banded and striped
100:### 1.4 Olympic coast mudstone and sandstone: why stacks are lumpy and dark
117:### 1.5 San Juan shores: two looks, one story
129:### 1.6 Life on rock and water on rock (all five places)
146:### 1.7 Reading at each distance (what survives)
154:### 1.8 For the simulation (rules and numbers)
182:## Part 2. The method, stage by stage
184:### The pipeline in one paragraph
198:### Stage 0: reading the photos (`measure.py`, `img/grid/`, `img/measure_crops.png`)
213:### Stage 1: one form per rock, greyscale (`stage1.py` → `stages/s1_forms_gray.png`)
232:### Stage 2: walls from joint cells (`jointfield.py`; `stages/granite_r1–r5.png`)
263:### Stage 3: the other forms (`formations.py`)
281:### Stage 4: paint (`paint.py`, `scene.py`)
315:### Photo | mine (`stages/iter/*_final.png`, photo box-downsampled to the same 256×192, 3×)
348:### The sweeps (`sheets/`)
379:## Part 3. For the voxsim lanes: what to port
415:## Where it stands, and what I would do next
438:## Round 2: designed light, and matching the photo itself
444:### 2a. Masses first, then marks (`paint.shade_designed`, `paint.mass_marks`)
471:### 2b. Matching Dragontail on the real terrain (`stages/match/dragontail_t1–t8.png`)
545:## Round 3: 1 m lidar, the model systems, and crop-by-crop matching
547:### The model system I am standing on: Imhof
576:### What I did, in order (`stages/match/dragontail_t9–t24.png`, crops `crop_*_t*.png`)
611:### What I learned (including three mistakes)
633:### Where it stands (honest)
653:## Round 4: skeleton first (Imhof), joint planes by screen size
707:## Why Dragontail looks like this (the model the rendering should follow)
714:### 1. What it is
728:### 2. Why the form
752:### 3. Why the colours (the weathering model)
783:### 4. Then the rendering follows the model
796:### 5. The teacher I need
809:### 6. Update after reading Twidale & Vidal Romaní, and checking the dike map
841:## Round 5: the rendering follows the model (recency × wetness), photo only as a check
888:## Resources and tooling notes (for the coordinator)
     902 ~/work/pixelart-studies/elements/rock/LESSONS.md
Bash: sed -n 545,720p ~/work/pixelart-studies/elements/rock/LESSONS.md
sed -n 545,720p ~/work/pixelart-studies/elements/rock/LESSONS.md

# output:
## Round 3: 1 m lidar, the model systems, and crop-by-crop matching

### The model system I am standing on: Imhof

I read Imhof's *Cartographic Relief Presentation*, chapter 11 "Rock Drawing" (OCR of the scanned PDF
in `cache/imhof/imhof_ch11.txt`, pages rendered alongside), looked hard at Fig. 177 (a peak drawn in
cartographic rock style) and Fig. 178 (good and bad rock hachures), and at Holmes's Grand Cañon
plates. Imhof is the method I am building on, and it names almost every failure in my earlier
rounds:

- **Skeletal lines first.** Gully lines (negative: elongated, branching like a poplar, always
  linked) and crest/basin-edge lines (positive: jagged, broken, interrupted) divide the face into
  its main zones *before* any internal structure. Joints, beds and cracks come after, and are
  subordinate. My round-2 wall did the opposite: joint planes first, gullies never.
- **"The form lies in the transition."** No outlines: an edge is the sharp change between a lit
  and a shaded area. Every gully has a crisp lit side paralleled by a defined shadow (Fig. 178-15);
  a gully drawn only as a shadow stroke fails (178-17).
- **Fit the small into the large.** Small ribs, cracks and strata are indicated so slightly that
  they stay secondary to the light and shade of the main surfaces.
- **Aerial perspective.** Contrast is sharper high up, softer at the foot — otherwise the foot of
  a cliff reads as its top.
- **A saw-tooth crest** is made by tiny lights and shadows cutting across the edge, not by a line.
- **The debris mantle**: cones reach up into the gully mouths, giving the cliff foot a garlanded
  outline; fine at the top, coarse at the foot.

Holmes shows the same economy for stratified cliffs at distance (the Yakima case): each cliff one
lit plane and one shadow tone, strata as thin stepped dark lines, foreground blocks as flat planes.

From the art school I took the willow student's warning to heart ("the numbers matched and the
picture didn't"): every step below was judged by eye and by a cold critic as well as by the metrics.

### What I did, in order (`stages/match/dragontail_t9–t24.png`, crops `crop_*_t*.png`)

1. **1 m lidar for the whole view.** Two 3DEP tiles (Eastern Cascades 2019, `x66y526`, `x66y527`)
   from the project's S3 copy, cropped to the view box and cached as one 81 MB `.npy`
   (`dem.load_enchant_1m`); the raw tiles were deleted after caching (the study folder is ~210 MB).
2. **Camera at head height.** Refitting on the lidar with the height held to 1.7–3 m gives heading
   236°, pitch 7°, hfov 96°, 1.7 m above the ground, skyline IoU 0.94 — the same fit quality as the
   8 m camera of round 2, which was an artefact of the smoother 10 m data.
3. **A near field of its own**: inside 60 m the 1 m lidar is meshed at 1 m and boulders are
   placed on it (sizes 2–5 m near, shrinking to ~1 m by 60 m, never larger than 0.45 × distance, so
   no block swallows the camera); a **near shadow cascade** (2048² over the near 80 m) gives 
Bash: mkdir -p ~/work/pixelart-studies/books/imhof/copies; cd ~/work/pixelart-studies/books/imhof; python3 -c " from PIL import Image im=Image.open('pages/p-277.png').convert('L') for n,
mkdir -p ~/work/pixelart-studies/books/imhof/copies; cd ~/work/pixelart-studies/books/imhof; python3 -c "
from PIL import Image
im=Image.open('pages/p-277.png').convert('L')
for n,b in [('178_12',(190,1520,690,1950)),('178_15',(190,2000,690,2440)),('178_2',(190,520,690,700)),('178_9',(190,1180,690,1480))]:
    c=im.crop(b); c.save('refs/plates/fig%s.png'%n); print(n,c.size)
c=Image.open('refs/plates/fig178_12.png'); c.save('(tmp))
"

# output:
178_12 (500, 430)
178_15 (500, 440)
178_2 (500, 180)
178_9 (500, 300)
Read: (tmp)

(tmp) 89. (tmp)

Write: books/imhof/code/grid.py
"""Put a pixel grid over a reference so strokes can be placed by coordinate.
usage: grid.py REF.png target_width out.png [cell=10] [copy.png]
Shows the reference scaled so one target pixel = `cell` display px, with a faint line per pixel and a
labelled line every 5; optionally a copy (same target size) side by side with the same grid."""
import sys
import numpy as np
from PIL import Image, ImageDraw, ImageFont

FONT = ImageFont.truetype('/System/Library/Fonts/Supplemental/Arial.ttf', 10)


def gridded(im, tw, th, cell):
    big = im.convert('RGB').resize((tw * cell, th * cell), Image.LANCZOS if im.width > tw else Image.NEAREST)
    d = ImageDraw.Draw(big)
    for x in range(tw + 1):
        c = (255, 60, 60) if x % 5 == 0 else (190, 210, 255)
        d.line([(x * cell, 0), (x * cell, th * cell)], fill=c, width=1)
        if x % 5 == 0 and x < tw:
            d.text((x * cell + 1, 1), str(x), fill=(220, 0, 0), font=FONT)
    for y in range(th + 1):
        c = (255, 60, 60) if y % 5 == 0 else (190, 210, 255)
        d.line([(0, y * cell), (tw * cell, y * cell)], fill=c, width=1)
        if y % 5 == 0 and y < th:
            d.text((1, y * cell + 1), str(y), fill=(220, 0, 0), font=FONT)
    return big


if __name__ == '__main__':
    ref = Image.open(sys.argv[1]); tw = int(sys.argv[2]); out = sys.argv[3]
    cell = int(sys.argv[4]) if len(sys.argv) > 4 else 10
    th = round(ref.height * tw / ref.width)
    panels = [gridded(ref, tw, th, cell)]
    if len(sys.argv) > 5:
        panels.append(gridded(Image.open(sys.argv[5]), tw, th, cell))
    W = sum(p.width for p in panels) + 10 * (len(panels) - 1)
    S = Image.new('RGB', (W, panels[0].height), (128, 128, 128))
    x = 0
    for p in panels:
        S.paste(p, (x, 0)); x += p.width + 10
    S.save(out); print(tw, th)
Bash: cd ~/work/pixelart-studies/books/imhof; python3 -c " from PIL import Image im=Image.open('refs/plates/fig178_12.png').convert('L') out=Image.new('L',(3*420+20,380),255); x=0 for w
cd ~/work/pixelart-studies/books/imhof; python3 -c "
from PIL import Image
im=Image.open('refs/plates/fig178_12.png').convert('L')
out=Image.new('L',(3*420+20,380),255); x=0
for w in [60,84,110]:
    h=round(im.height*w/im.width); s=im.resize((w,h),Image.BOX).resize((420,round(420*h/w)),Image.NEAREST); out.paste(s,(x,0)); x+=430
out.save('(tmp))"

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 90. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/grid.py refs/plates/fig178_12.png 84 (tmp) 11
cd ~/work/pixelart-studies/books/imhof; python3 code/grid.py refs/plates/fig178_12.png 84 (tmp) 11

# output:
84 72
Read: (tmp)

(tmp) 91. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 -c " from PIL import Image im=Image.open('refs/plates/fig178_12.png'); im.crop((0,100,260,330)).resize((780,690),Image.LANCZOS).save
cd ~/work/pixelart-studies/books/imhof; python3 -c "
from PIL import Image
im=Image.open('refs/plates/fig178_12.png'); im.crop((0,100,260,330)).resize((780,690),Image.LANCZOS).save('(tmp))"

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 92. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 -c " import sys; sys.path.insert(0,'code') from grid import gridded from PIL import Image im=Image.open('refs/plates/fig178_12.png')
cd ~/work/pixelart-studies/books/imhof; python3 -c "
import sys; sys.path.insert(0,'code')
from grid import gridded
from PIL import Image
im=Image.open('refs/plates/fig178_12.png')
tw,th=84,72
# crop left half in target coords 0-44
sx=im.width/tw
c=im.crop((0,0,int(44*sx),im.height))
gridded(c,44,72,16).save('(tmp))"

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 93. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; cat > code/measure_ink.py <<'EOF' """Measure a 1-bit hachure plate: ink share, stroke orientation, stroke pitch, stroke length. usage: measu
cd ~/work/pixelart-studies/books/imhof; cat > code/measure_ink.py <<'EOF'
"""Measure a 1-bit hachure plate: ink share, stroke orientation, stroke pitch, stroke length.
usage: measure_ink.py plate.png [target_width]"""
import sys, numpy as np
from PIL import Image
from scipy import ndimage as ndi
from skimage.morphology import skeletonize
im = np.asarray(Image.open(sys.argv[1]).convert('L')) < 128
tw = int(sys.argv[2]) if len(sys.argv) > 2 else None
s = im.shape[1] / tw if tw else 1.0
print('size', im.shape, 'ink share %.3f' % im.mean())
# orientation from structure tensor at stroke scale
f = ndi.gaussian_filter(im.astype(float), 2)
gy, gx = np.gradient(f)
Jxx = ndi.gaussian_filter(gx * gx, 12); Jyy = ndi.gaussian_filter(gy * gy, 12); Jxy = ndi.gaussian_filter(gx * gy, 12)
ang = 0.5 * np.degrees(np.arctan2(2 * Jxy, Jxx - Jyy)) + 90   # stroke direction (perp to gradient)
coh = np.sqrt((Jxx - Jyy) ** 2 + 4 * Jxy ** 2) / (Jxx + Jyy + 1e-9)
h, e = np.histogram(ang[im], bins=12, range=(0, 180), weights=coh[im])
print('stroke direction hist (deg from x-axis, 15 deg bins):', (h / h.sum() * 100).round(0).astype(int).tolist())
# pitch: distance between ink runs across rows
runs = []; gaps = []
for r in range(0, im.shape[0], 3):
    row = im[r].astype(int); d = np.diff(np.r_[0, row, 0])
    st = np.flatnonzero(d == 1); en = np.flatnonzero(d == -1)
    runs += list(en - st); gaps += list(st[1:] - en[:-1])
runs = np.array(runs); gaps = np.array(gaps)
print('horizontal ink run: median %.1f  gap median %.1f  -> pitch %.1f src px = %.2f target px' % (
    np.median(runs), np.median(gaps), np.median(runs) + np.median(gaps), (np.median(runs) + np.median(gaps)) / s))
sk = skeletonize(im); lab, n = ndi.label(sk, np.ones((3, 3)))
lens = np.bincount(lab.ravel())[1:]
print('strokes %d  skeleton length median %.0f src px (%.1f target px), p90 %.0f' % (n, np.median(lens), np.median(lens) / s, np.percentile(lens, 90)))
EOF
for f in 178_12 178_15 178_2 178_9; do echo "== $f"; python3 code/measure_ink.py refs/plates/fig$f.png 84; done

# output:
== 178_12
size (430, 500) ink share 0.258
stroke direction hist (deg from x-axis, 15 deg bins): [0, 0, 0, 0, 2, 19, 43, 22, 9, 4, 0, 0]
horizontal ink run: median 5.0  gap median 8.0  -> pitch 13.0 src px = 2.18 target px
strokes 90  skeleton length median 28 src px (4.8 target px), p90 135
== 178_15
size (440, 500) ink share 0.198
stroke direction hist (deg from x-axis, 15 deg bins): [4, 0, 0, 0, 0, 3, 4, 9, 17, 27, 25, 11]
horizontal ink run: median 6.0  gap median 14.0  -> pitch 20.0 src px = 3.36 target px
strokes 116  skeleton length median 30 src px (5.0 target px), p90 192
== 178_2
size (180, 500) ink share 0.042
stroke direction hist (deg from x-axis, 15 deg bins): [0, 0, 0, 0, 28, 21, 39, 10, 1, 0, 1, 0]
horizontal ink run: median 4.0  gap median 9.0  -> pitch 13.0 src px = 2.18 target px
strokes 31  skeleton length median 20 src px (3.4 target px), p90 53
== 178_9
size (300, 500) ink share 0.176
stroke direction hist (deg from x-axis, 15 deg bins): [3, 0, 1, 0, 0, 5, 56, 5, 3, 4, 9, 13]
horizontal ink run: median 5.0  gap median 9.0  -> pitch 14.0 src px = 2.35 target px
strokes 146  skeleton length median 34 src px (5.6 target px), p90 74
Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/grid.py refs/plates/fig178_12.png 112 (tmp) 8
cd ~/work/pixelart-studies/books/imhof; python3 code/grid.py refs/plates/fig178_12.png 112 (tmp) 8

# output:
112 96
Read: (tmp)

(tmp) 94. (tmp)

Write: books/imhof/code/pen.py
"""A pixel pen for master copies: strokes placed by hand as polylines in target-pixel coordinates.

Canvas values: 0 paper, 1 light ink (e.g. lit-side fine strokes), 2 ink, 3 heavy ink.
line(): clean pixel lines (Bresenham between vertices, no doubled corners).
"""
import numpy as np


class Canvas:
    def __init__(self, w, h):
        self.a = np.zeros((h, w), np.uint8)
        self.w, self.h = w, h

    def px(self, x, y, v=2, force=False):
        x, y = int(round(x)), int(round(y))
        if 0 <= x < self.w and 0 <= y < self.h:
            self.a[y, x] = v if force else max(self.a[y, x], v)

    def seg(self, x0, y0, x1, y1, v=2, force=False):
        x0, y0, x1, y1 = map(lambda t: int(round(t)), (x0, y0, x1, y1))
        dx, dy = abs(x1 - x0), -abs(y1 - y0)
        sx, sy = (1 if x0 < x1 else -1), (1 if y0 < y1 else -1)
        err = dx + dy
        while True:
            self.px(x0, y0, v, force)
            if x0 == x1 and y0 == y1:
                break
            e2 = 2 * err
            if e2 >= dy:
                err += dy; x0 += sx
            if e2 <= dx:
                err += dx; y0 += sy

    def line(self, pts, v=2, force=False):
        for (x0, y0), (x1, y1) in zip(pts[:-1], pts[1:]):
            self.seg(x0, y0, x1, y1, v, force)

    def erase(self, pts, w=1):
        """the scraper: clear pixels along a polyline (p.274: lit edges and narrow lit areas)."""
        for (x0, y0), (x1, y1) in zip(pts[:-1], pts[1:]):
            n = int(max(abs(x1 - x0), abs(y1 - y0))) + 1
            for t in np.linspace(0, 1, n):
                x, y = x0 + (x1 - x0) * t, y0 + (y1 - y0) * t
                for ox in range(w):
                    self.px(x + ox, y, 0, force=True)

    def rgb(self, pal):
        return np.asarray(pal, np.uint8)[self.a]
Write: books/imhof/copies/c178_12.py
"""Master copy of Imhof Fig.178-12 (p.260): steep scale-like buttresses, plan view.
'The gullies, smoothly and sharply drawn, run upwards meeting at acute angles. Narrow, open,
illuminated areas or edges conform to the shaded areas. Ridge crests are not drawn in, the scale-like
type of structure being brought out by the variation of light and shadow.'

Copy at 112x96 px (plate 500x430 at 300 dpi; the plate's stroke pitch is 13 px = 2.9 target px).
Spines (gully lines) are placed by hand from the gridded plate; barbs follow the rules read off it:
barbs hang down the fall line and meet their gully at an acute angle; the gully wall below each
spine is a clean lit strip; barbs thicken toward the gully (the shaded foot of each scale)."""
import sys, numpy as np
sys.path.insert(0, __file__.rsplit('/', 2)[0] + '/code')
from pen import Canvas

rng = np.random.default_rng(3)
W, H = 112, 96
C = Canvas(W, H)

# ---- the gully spines, read from the plate (x, y in target px). Each: points, barb angle (deg, the
# direction from the spine UP the barb, measured from +x counter-clockwise in screen terms), barb reach.
SPINES = [
    # left buttress group: gullies rising to the upper right
    ([(3, 64), (12, 53), (22, 44), (32, 34), (40, 27)], 100, 20),
    ([(5, 42), (13, 33), (22, 24), (29, 16), (33, 10)], 100, 18),
    ([(6, 90), (16, 78), (27, 66), (37, 55), (41, 49)], 98, 22),
    # central chimney: a near-vertical gully
    ([(42, 88), (43, 70), (42, 55), (43, 40), (45, 28), (48, 14)], 118, 10),
    # right group: longer, flatter gullies
    ([(46, 70), (56, 61), (66, 52), (77, 43), (89, 34), (103, 24)], 96, 20),
    ([(56, 92), (67, 83), (80, 72), (93, 63), (106, 54)], 94, 18),
    ([(55, 33), (64, 25), (74, 18), (86, 10), (96, 6)], 98, 16),
    ([(74, 50), (84, 44), (95, 39), (106, 35)], 150, 10),     # upper-right: flatter facets, oblique barbs
]

PITCH = 3.0


def feather(pts, ang, reach, heavy=0.35, lit_w=1):
    pts = np.array(pts, float)
    # spine: the dark gully floor, drawn smoothly (p.261: 'smoothly and sharply drawn')
    C.line([tuple(p) for p in pts], 3)
    seg = np.diff(pts, axis=0); L = np.hypot(*seg.T); cum = np.r_[0, np.cumsum(L)]
    a = np.radians(ang); d = np.array([np.cos(a), -np.sin(a)])      # screen y down
    for s in np.arange(1.5, cum[-1], PITCH):
        i = min(np.searchsorted(cum, s) - 1, len(seg) - 1); t = (s - cum[i]) / L[i]
        p = pts[i] + seg[i] * t
        n = int(rng.uniform(0.35, 1.0) * reach)
        # barb: from the spine up the fall line, with a slight wander
        q = [tuple(p)]
        cur = p.copy()
        for k in range(n):
            cur = cur + d + np.array([rng.normal(0, 0.12), 0])
            q.append(tuple(cur))
        v = 3 if rng.random() < heavy else 2
        C.line(q[:max(2, n // 2)], v)          # lower half: heavier, into the gully
        C.line(q[max(1, n // 2 - 1):], 2)
    # lit strip on the other side of the spine: the scraper
    nrm = -d
    for w in range(1, lit_w + 1):
        C.erase([tuple(p + nrm * (w + 0.6)) for p in pts])


# draw from the top of the plate down so lower scales overlap upper barbs (their tops stop at the strip)
for pts, ang, reach in sorted(SPINES, key=lambda s: np.mean([p[1] for p in s[0]])):
    feather(pts, ang, reach)

np.save(__file__.replace('.py', '.npy'), C.a)
Bash: cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF' import re p='copies/c178_12.py'; s=open(p).read() s=s.replace(""" for k in range(n): cur = cur + d + np
cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF'
import re
p='copies/c178_12.py'; s=open(p).read()
s=s.replace("""        for k in range(n):
            cur = cur + d + np.array([rng.normal(0, 0.12), 0])
            q.append(tuple(cur))""","""        for k in range(n):
            cur = cur + d + np.array([rng.normal(0, 0.12), 0])
            x, y = int(round(cur[0])), int(round(cur[1]))
            if k > 1 and 0 <= x < W and 0 <= y < H and C.a[y, x] == 3:
                break
            q.append(tuple(cur))
        n = len(q) - 1""")
open(p,'w').write(s)
EOF
cat > code/cmp_copy.py <<'EOF'
"""plate | plate at pixel size | my copy, at scale. usage: cmp_copy.py plate.png copy.npy out.png title [scale]"""
import sys, numpy as np
from PIL import Image
sys.path.insert(0, __file__.rsplit('/', 1)[0])
import sheet as SH
plate = Image.open(sys.argv[1]).convert('L'); cp = np.load(sys.argv[2]); out = sys.argv[3]
title = sys.argv[4]; sc = int(sys.argv[5]) if len(sys.argv) > 5 else 6
H, W = cp.shape
PAPER = (240, 236, 226); INK = [(240, 236, 226), (150, 146, 140), (48, 44, 42), (20, 18, 18)]
small = plate.resize((W, H), Image.BOX)
thr = (np.asarray(small) < 150)
mine = np.asarray(INK, np.uint8)[cp]
b = np.where(thr[..., None], np.array(INK[2], np.uint8), np.array(PAPER, np.uint8))
rows = [[(SH.fit(plate, h=H * sc, smooth=True), 'Imhof (scan)'),
         (SH.fit(b.astype(np.uint8), sc), 'plate box-reduced to %dx%d, thresholded' % (W, H)),
         (SH.fit(mine, sc), 'my copy, %dx%d px' % (W, H))],
        [(SH.fit(plate, h=H * 2, smooth=True), 'Imhof, small'), (SH.fit(b.astype(np.uint8), 2), 'reduced, 2x'), (SH.fit(mine, 2), 'mine, 2x')]]
cov = (cp >= 2).mean(); print('ink share plate %.3f  mine %.3f' % (thr.mean(), cov))
SH.sheet(rows, title, 'ink share: plate %.2f, mine %.2f' % (thr.mean(), cov), out=out)
EOF
python3 copies/c178_12.py && python3 code/cmp_copy.py refs/plates/fig178_12.png copies/c178_12.npy (tmp) "Fig.178-12 copy v1" 5

# output:
ink share plate 0.289  mine 0.175
Read: (tmp)

(tmp) 95. (tmp)

Write: books/imhof/code/hachure.py
"""Evenly spaced fall-line strokes (Jobard & Lefer 1997 streamline placement) on a heightfield.

This is how Imhof's slope and rock hachures behave: every stroke runs down the fall line; strokes keep
a constant separation, so where the fall lines converge into a gully they END against their
neighbours (the feather of Fig.178-12) and where they diverge over a spur new strokes start.

streamlines(z, sep, ...) -> list of polylines (N x 2, (row, col) float), each ordered downhill.
"""
import numpy as np
from scipy import ndimage as ndi


def field(z, smooth=1.0):
    zs = ndi.gaussian_filter(z.astype(float), smooth) if smooth else z.astype(float)
    gr, gc = np.gradient(zs)
    m = np.hypot(gr, gc) + 1e-12
    return -gr / m, -gc / m, m          # unit downhill (row, col), gradient magnitude


def _bil(a, r, c):
    H, W = a.shape
    r = min(max(r, 0), H - 1.001); c = min(max(c, 0), W - 1.001)
    r0, c0 = int(r), int(c); fr, fc = r - r0, c - c0
    return (a[r0, c0] * (1 - fr) * (1 - fc) + a[r0 + 1, c0] * fr * (1 - fc) +
            a[r0, c0 + 1] * (1 - fr) * fc + a[r0 + 1, c0 + 1] * fr * fc)


class Grid:
    """occupancy of existing stroke points for the distance tests."""
    def __init__(self, H, W, cell):
        self.cell = cell; self.nr = int(H / cell) + 2; self.nc = int(W / cell) + 2
        self.b = {}

    def add(self, p, sid):
        k = (int(p[0] / self.cell), int(p[1] / self.cell))
        self.b.setdefault(k, []).append((p[0], p[1], sid))

    def near(self, p, d, exclude=None):
        k0, k1 = int(p[0] / self.cell), int(p[1] / self.cell)
        d2 = d * d
        for i in (k0 - 1, k0, k0 + 1):
            for j in (k1 - 1, k1, k1 + 1):
                for (r, c, s) in self.b.get((i, j), ()):
                    if s == exclude:
                        continue
                    if (r - p[0]) ** 2 + (c - p[1]) ** 2 < d2:
                        return True
        return False


def streamlines(z, sep=3.0, test=0.55, step=0.5, min_len=3.0, max_len=60.0, smooth=1.0,
                mask=None, min_grad=1e-3, seeds=None, both_ways=True, rng=None):
    """Evenly spaced streamlines of the downhill field of z (units: pixels).
    sep: separation between strokes (the hachure pitch); test: stop when closer than test*sep to
    another stroke; mask: where strokes may exist."""
    rng = rng or np.random.default_rng(0)
    H, W = z.shape
    ur, uc, gm = field(z, smooth)
    if mask is None:
        mask = np.ones((H, W), bool)
    ok = mask & (gm > min_grad)
    G = Grid(H, W, sep)
    lines = []

    def valid(p):
        return 0 <= p[0] < H - 1 and 0 <= p[1] < W - 1 and ok[int(p[0]), int(p[1])]

    def integrate(p0, sgn, sid):
        pts = []; p = np.array(p0, float); n = int(max_len / step)
        for _ in range(n):
            v = np.array([_bil(ur, *p), _bil(uc, *p)]) * sgn
            q = p + step * v          # Heun
            v2 = np.array([_bil(ur, *q), _bil(uc, *q)]) * sgn
            if v @ v2 < 0:            # direction flips: a gully floor or ridge crest
                break
            q = p + step * 0.5 * (v + v2)
            if not valid(q) or G.near(q, test * sep, exclude=sid):
                break
            pts.append(q.copy()); p = q
        return pts

    def make(seed, sid):
        if not valid(seed) or G.near(seed, sep * 0.99):
            return None
        fw = integrate(seed, +1, sid)
        bw = integrate(seed, -1, sid) if both_ways else []
        pts = bw[::-1] + [np.array(seed, float)] + fw
        if len(pts) * step < min_len:
            return None
        pts = np.array(pts)
        for p in pts:
            G.add(p, sid)
        return pts

    if seeds is None:
        cand = [(r + rng.uniform(-.5, .5), c + rng.uniform(-.5, .5))
                for r in np.arange(1, H - 1, sep) for c in np.arange(1, W - 1, sep)]
        rng.shuffle(cand)
    else:
        cand = list(seeds)
    queue = []
    sid = 0
    ci = 0
    while ci < len(cand) or queue:
        if queue:
            s = queue.pop()
        else:
            s = cand[ci]; ci += 1
        L = make(s, sid)
        if L is None:
            continue
        lines.append(L); sid += 1
        # Jobard-Lefer: new seeds at distance sep on both sides of the new line
        d = np.gradient(L, axis=0)
        d /= (np.hypot(d[:, 0], d[:, 1])[:, None] + 1e-9)
        nrm = np.stack([-d[:, 1], d[:, 0]], -1)
        for k in range(0, len(L), max(1, int(sep / step))):
            for sg in (1, -1):
                queue.append(tuple(L[k] + sg * sep * nrm[k]))
    return lines
Write: books/imhof/copies/c178_12b.py
"""Master copy of Fig.178-12, second way: Imhof's own order (p.273).

1. skeletal lines by hand: the gully lines read off the plate;
2. the surface they imply (steep slope falling down the page, V-cut along each gully);
3. shading of that surface under NW light;
4. strokes down the fall line at constant separation, weighted by the shading: heavy on the shaded
   walls, absent on the lit gully walls (the 'narrow open illuminated areas').
Nothing is drawn along crests; they appear where the stroke fields of two walls meet."""
import sys, numpy as np
sys.path.insert(0, __file__.rsplit('/', 2)[0] + '/code')
import hachure as HA, imhof as I
from pen import Canvas

W, H = 112, 96
yy, xx = np.mgrid[0:H, 0:W].astype(float)

GULLIES = [   # hand-placed from the gridded plate (x, y), each a gully descending to the lower left
    [(3, 64), (12, 53), (22, 44), (32, 34), (40, 27), (46, 20)],
    [(5, 42), (13, 33), (22, 24), (29, 16), (33, 8)],
    [(6, 92), (16, 78), (27, 66), (37, 55), (44, 46)],
    [(42, 92), (43, 70), (42, 55), (43, 40), (45, 28), (48, 12)],
    [(46, 72), (56, 61), (66, 52), (77, 43), (89, 34), (104, 24)],
    [(56, 94), (67, 83), (80, 72), (93, 63), (108, 54)],
    [(55, 33), (64, 25), (74, 18), (86, 10), (98, 5)],
    [(70, 52), (84, 45), (96, 40), (108, 36)],
    [(20, 90), (24, 78), (27, 66)],                         # short tributaries
    [(88, 94), (92, 80), (93, 63)],
]


def dist_to_poly(px, py, poly):
    d = np.full(px.shape, 1e9)
    for (x0, y0), (x1, y1) in zip(poly[:-1], poly[1:]):
        vx, vy = x1 - x0, y1 - y0
        t = np.clip(((px - x0) * vx + (py - y0) * vy) / (vx * vx + vy * vy), 0, 1)
        d = np.minimum(d, np.hypot(px - x0 - t * vx, py - y0 - t * vy))
    return d


z = -1.2 * yy                                    # the wall falls down the page (plan view, north up)
for g in GULLIES:
    d = dist_to_poly(xx, yy, g)
    z -= 9.0 * np.clip(1 - d / 7.0, 0, None) ** 1.3  # V-cut, a little concave
rng = np.random.default_rng(1)
z += 0.6 * np.cumsum(rng.normal(0, 1, (H, W)), 1) * 0.05   # slight roughness so strokes wander

tone = I.imhof_tone(z, 1.0, az=315, el=40, local_dev=0, big_w=0, ap_low=1.0, diffuse=0.15)['t']
lines = HA.streamlines(z, sep=2.9, test=0.6, min_len=2.5, max_len=40, smooth=0.8, rng=rng)

C = Canvas(W, H)
for L in lines:
    for i, (r, c) in enumerate(L):
        t = I_t = tone[int(r), int(c)]
        if t > 0.45:            # lit wall: left open (the scraper)
            continue
        C.px(c, r, 2)
        if t < -0.35:           # shaded wall: the stroke thickens toward the light-averse side
            C.px(c + 1, r, 3)
np.save(__file__.replace('.py', '.npy'), C.a)
np.save(__file__.replace('.py', '_tone.npy'), tone)
print(len(lines), 'strokes')
Bash: cd ~/work/pixelart-studies/books/imhof; time python3 copies/c178_12b.py && python3 code/cmp_copy.py refs/plates/fig178_12.png copies/c178_12b.npy (tmp) "Fig.178-12
cd ~/work/pixelart-studies/books/imhof; time python3 copies/c178_12b.py && python3 code/cmp_copy.py refs/plates/fig178_12.png copies/c178_12b.npy (tmp) "Fig.178-12 copy v2: skeleton by hand, strokes down the fall line" 5

# output:
230 strokes
python3 copies/c178_12b.py  1.23s user 0.60s system 66% cpu 2.743 total
ink share plate 0.289  mine 0.586
Read: (tmp)

(tmp) 96. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF' p='copies/c178_12b.py'; s=open(p).read() s=s.replace(""" [(88, 94), (92, 80), (93, 63)], ]""",""" [(88, 94), (92, 80
cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF'
p='copies/c178_12b.py'; s=open(p).read()
s=s.replace("""    [(88, 94), (92, 80), (93, 63)],
]""","""    [(88, 94), (92, 80), (93, 63)],
    [(14, 70), (22, 60), (30, 50), (36, 44)],               # secondary gullies between the main ones
    [(2, 30), (10, 22), (18, 14), (22, 8)],
    [(60, 50), (66, 40), (72, 30), (80, 22), (88, 16)],
    [(74, 92), (82, 84), (92, 76), (104, 70)],
    [(28, 36), (34, 26), (38, 18), (40, 10)],
    [(96, 30), (104, 22), (110, 16)],
]""")
s=s.replace("""C = Canvas(W, H)
for L in lines:
    for i, (r, c) in enumerate(L):
        t = I_t = tone[int(r), int(c)]
        if t > 0.45:            # lit wall: left open (the scraper)
            continue
        C.px(c, r, 2)
        if t < -0.35:           # shaded wall: the stroke thickens toward the light-averse side
            C.px(c + 1, r, 3)""","""C = Canvas(W, H)
tn = tone - np.median(tone)                      # the main wall is the mid tone
for L in lines:
    # roughness: break each stroke into dashes (plate: median stroke 5 target px)
    dash = rng.integers(8, 16); gap = rng.integers(1, 3); k0 = rng.integers(0, dash)
    for i, (r, c) in enumerate(L):
        if ((i + k0) % (dash + gap)) >= dash:
            continue
        t = tn[int(r), int(c)]
        if t > 0.30:            # lit wall: left open (the scraper)
            continue
        C.px(c, r, 2)
        if t < -0.45:           # shaded wall: the stroke thickens toward the light-averse side
            C.px(c + 1, r, 3)""")
open(p,'w').write(s)
EOF
python3 copies/c178_12b.py && python3 code/cmp_copy.py refs/plates/fig178_12.png copies/c178_12b.npy (tmp) "Fig.178-12 copy v3" 5

# output:
263 strokes
ink share plate 0.289  mine 0.263
Read: (tmp)

(tmp) 97. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF' p='copies/c178_12b.py'; s=open(p).read() s=s.replace("if t > 0.30:","if t > 0.16:").replace("if t < -0.45:","if t < -0.18:
cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF'
p='copies/c178_12b.py'; s=open(p).read()
s=s.replace("if t > 0.30:","if t > 0.16:").replace("if t < -0.45:","if t < -0.18:")
s=s.replace("np.save(__file__.replace('.py', '.npy'), C.a)","""# the gully floors: 'smoothly and sharply drawn' (p.261), heavy, the one place a continuous line is right
for g in GULLIES:
    C.line(g, 3)
np.save(__file__.replace('.py', '.npy'), C.a)""")
open(p,'w').write(s)
EOF
python3 copies/c178_12b.py && python3 code/cmp_copy.py refs/plates/fig178_12.png copies/c178_12b.npy (tmp) "Fig.178-12 copy v4" 5

# output:
263 strokes
ink share plate 0.289  mine 0.373
Read: (tmp)

(tmp) 98. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; sed -i '' 's/z -= 9.0 \* np.clip(1 - d \/ 7.0, 0, None) \*\* 1.3/z -= GD * np.clip(1 - d \/ GW, 0, None) ** 1.3/; s/^z = -1.2 \* yy .*/GD, G
cd ~/work/pixelart-studies/books/imhof; sed -i '' 's/z -= 9.0 \* np.clip(1 - d \/ 7.0, 0, None) \*\* 1.3/z -= GD * np.clip(1 - d \/ GW, 0, None) ** 1.3/; s/^z = -1.2 \* yy .*/GD, GW = float(sys.argv[1]) if len(sys.argv) > 1 else 4.0, float(sys.argv[2]) if len(sys.argv) > 2 else 5.0\nz = -1.2 * yy/' copies/c178_12b.py; for a in "3 4" "4 5" "5 6"; do python3 copies/c178_12b.py $a >/dev/null; cp copies/c178_12b.npy (tmp) /_}.npy; done; python3 -c "
import numpy as np, sys; sys.path.insert(0,'code'); import sheet as SH
INK=np.array([(240,236,226),(150,146,140),(48,44,42),(20,18,18)],np.uint8)
rows=[[ (SH.fit(INK[np.load('(tmp))],3),'gully depth,width '+k) for k in ['3_4','4_5','5_6']]]
SH.sheet(rows,'gully depth sweep',out='(tmp))"

# output:
Traceback (most recent call last):
  File "~/work/pixelart-studies/books/imhof/copies/c178_12b.py", line 45, in <module>
    GD, GW = float(sys.argv[1]) if len(sys.argv) > 1 else 4.0, float(sys.argv[2]) if len(sys.argv) > 2 else 5.0
             ^^^^^^^^^^^^^^^^^^
ValueError: could not convert string to float: '3 4'
Traceback (most recent call last):
  File "~/work/pixelart-studies/books/imhof/copies/c178_12b.py", line 45, in <module>
    GD, GW = float(sys.argv[1]) if len(sys.argv) > 1 else 4.0, float(sys.argv[2]) if len(sys.argv) > 2 else 5.0
             ^^^^^^^^^^^^^^^^^^
ValueError: could not convert string to float: '4 5'
Traceback (most recent call last):
  File "~/work/pixelart-studies/books/imhof/copies/c178_12b.py", line 45, in <module>
    GD, GW = float(sys.argv[1]) if len(sys.argv) > 1 else 4.0, float(sys.argv[2]) if len(sys.argv) > 2 else 5.0
             ^^^^^^^^^^^^^^^^^^
ValueError: could not convert string to float: '5 6'
Bash: cd ~/work/pixelart-studies/books/imhof; for a in "3 4" "4 5" "5 6"; do python3 copies/c178_12b.py $a >/dev/null; cp copies/c178_12b.npy (tmp) /_}.npy; done; pytho
cd ~/work/pixelart-studies/books/imhof; for a in "3 4" "4 5" "5 6"; do python3 copies/c178_12b.py $a >/dev/null; cp copies/c178_12b.npy (tmp) /_}.npy; done; python3 -c "
import numpy as np, sys; sys.path.insert(0,'code'); import sheet as SH
INK=np.array([(240,236,226),(150,146,140),(48,44,42),(20,18,18)],np.uint8)
rows=[[ (SH.fit(INK[np.load('(tmp))],3),'gully depth,width '+k) for k in ['3_4','4_5','5_6']]]
SH.sheet(rows,'gully depth sweep',out='(tmp))"

# output:
Traceback (most recent call last):
  File "~/work/pixelart-studies/books/imhof/copies/c178_12b.py", line 45, in <module>
    GD, GW = float(sys.argv[1]) if len(sys.argv) > 1 else 4.0, float(sys.argv[2]) if len(sys.argv) > 2 else 5.0
             ^^^^^^^^^^^^^^^^^^
ValueError: could not convert string to float: '3 4'
Traceback (most recent call last):
  File "~/work/pixelart-studies/books/imhof/copies/c178_12b.py", line 45, in <module>
    GD, GW = float(sys.argv[1]) if len(sys.argv) > 1 else 4.0, float(sys.argv[2]) if len(sys.argv) > 2 else 5.0
             ^^^^^^^^^^^^^^^^^^
ValueError: could not convert string to float: '4 5'
Traceback (most recent call last):
  File "~/work/pixelart-studies/books/imhof/copies/c178_12b.py", line 45, in <module>
    GD, GW = float(sys.argv[1]) if len(sys.argv) > 1 else 4.0, float(sys.argv[2]) if len(sys.argv) > 2 else 5.0
             ^^^^^^^^^^^^^^^^^^
ValueError: could not convert string to float: '5 6'

zsh doesn't word-split. Use explicit args.

Bash: cd ~/work/pixelart-studies/books/imhof; for a in 3_4 4_5 5_6; do python3 copies/c178_12b.py ${a%_*} ${a#*_} >/dev/null; cp copies/c178_12b.npy (tmp) done; pytho
cd ~/work/pixelart-studies/books/imhof; for a in 3_4 4_5 5_6; do python3 copies/c178_12b.py ${a%_*} ${a#*_} >/dev/null; cp copies/c178_12b.npy (tmp) done; python3 -c "
import numpy as np, sys; sys.path.insert(0,'code'); import sheet as SH
INK=np.array([(240,236,226),(150,146,140),(48,44,42),(20,18,18)],np.uint8)
rows=[[ (SH.fit(INK[np.load('(tmp))],3),'gully depth,width '+k) for k in ['3_4','4_5','5_6']]]
SH.sheet(rows,'gully depth sweep',out='(tmp))"

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 99. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; sed -i '' 's/else 4.0, float(sys.argv\[2\]) if len(sys.argv) > 2 else 5.0/else 4.0, float(sys.argv[2]) if len(sys.argv) > 2 else 5.0 # chos
cd ~/work/pixelart-studies/books/imhof; sed -i '' 's/else 4.0, float(sys.argv\[2\]) if len(sys.argv) > 2 else 5.0/else 4.0, float(sys.argv[2]) if len(sys.argv) > 2 else 5.0  # chosen from the sweep (sheets\/copy_178_12.png)/' copies/c178_12b.py; python3 copies/c178_12b.py && python3 code/cmp_copy.py refs/plates/fig178_12.png copies/c178_12b.npy sheets/copy_178_12.png "Copy 1: Fig.178-12, steep scale-like buttresses (p.260) - skeleton by hand, strokes down the fall line" 5; python3 code/grid.py refs/plates/fig178_15.png 112 (tmp) 8

# output:
198 strokes
ink share plate 0.289  mine 0.363
112 99
Read: (tmp)

(tmp) 100. (tmp)

Write: books/imhof/copies/c178_15.py
"""Master copy of Fig.178-15 (p.260): a slab wall channelled by narrow gullies.
'The whole surface is given a light medium tone of horizontal form lines. Each gully is characterized
by a crisp illuminated side paralleled by a well defined shadow.'

Order: gully lines by hand -> the surface they imply -> its contours are the form lines, drawn as
broken dashes at the plate's pitch (~4 px) -> each gully: shadow line on its east-facing wall, the
dashes erased on its lit west-facing... (NW light: the gully wall that faces WEST is lit)."""
import sys, numpy as np
sys.path.insert(0, __file__.rsplit('/', 2)[0] + '/code')
import imhof as I, strokes as S
from pen import Canvas

W, H = 112, 99
yy, xx = np.mgrid[0:H, 0:W].astype(float)
rng = np.random.default_rng(4)

GULLIES = [  # (x, y) from the gridded plate, top to bottom
    [(9, 6), (8, 30), (10, 55), (12, 84)],
    [(29, 26), (33, 45), (35, 62), (36, 86)],
    [(45, 8), (52, 30), (56, 50), (56, 64), (55, 85)],
    [(65, 6), (65, 30), (63, 55), (61, 86)],
    [(94, 10), (92, 35), (86, 60), (80, 86)],
    [(100, 30), (104, 60), (106, 86)],
]


def dist_to_poly(px, py, poly):
    d = np.full(px.shape, 1e9); side = np.zeros(px.shape)
    for (x0, y0), (x1, y1) in zip(poly[:-1], poly[1:]):
        vx, vy = x1 - x0, y1 - y0
        t = np.clip(((px - x0) * vx + (py - y0) * vy) / (vx * vx + vy * vy), 0, 1)
        dd = np.hypot(px - x0 - t * vx, py - y0 - t * vy)
        sd = np.sign((px - x0) * vy - (py - y0) * vx)      # which side of the gully
        side = np.where(dd < d, sd, side); d = np.minimum(d, dd)
    return d, side


GW = 3.5
z = -1.0 * yy + 0.8 * np.sin(xx / 17.0)            # the wall; gentle undulation
gd = np.full(z.shape, 1e9); gs = np.zeros(z.shape)
for g in GULLIES:
    d, sd = dist_to_poly(xx, yy, g)
    z -= 3.0 * np.clip(1 - d / GW, 0, None)          # narrow gully: contours kink down-slope
    gs = np.where(d < gd, sd, gs); gd = np.minimum(gd, d)
z += np.cumsum(rng.normal(0, 0.18, z.shape), 1) * 0.15

C = Canvas(W, H)
PITCH = 4.1                                          # plate: horizontal form lines every ~4 px
for lev, poly in S.contour_lines(z, PITCH, offset=rng.uniform(0, PITCH)):
    pts = S.resample(poly, 0.5)
    dash = rng.integers(10, 24); gap = rng.integers(2, 6); k0 = rng.integers(0, dash + gap)
    for i, (r, c) in enumerate(pts):
        if ((i + k0) % (dash + gap)) >= dash:
            k0 = k0 if rng.random() > 0.02 else rng.integers(0, dash)
            continue
        ri, ci = int(r), int(c)
        if gd[ri, ci] < GW and gs[ri, ci] < 0:        # lit wall of the gully: open (scraped)
            continue
        C.px(c, r, 2)

# each gully: a well defined shadow on its east-facing wall, one px off the floor
for g in GULLIES:
    pts = np.array(g, float)
    C.line([tuple(p + np.array([1.0, 0])) for p in pts], 3)
    C.line([tuple(p + np.array([2.0, 0])) for p in pts[1:-1]], 2)
    C.erase([tuple(p + np.array([-1.0, 0])) for p in pts])           # the crisp lit side
np.save(__file__.replace('.py', '.npy'), C.a)
Bash: cd ~/work/pixelart-studies/books/imhof; python3 copies/c178_15.py && python3 code/cmp_copy.py refs/plates/fig178_15.png copies/c178_15.npy (tmp) "Copy 2: Fig.178-15
cd ~/work/pixelart-studies/books/imhof; python3 copies/c178_15.py && python3 code/cmp_copy.py refs/plates/fig178_15.png copies/c178_15.npy (tmp) "Copy 2: Fig.178-15 slab wall with gullies" 5

# output:
ink share plate 0.226  mine 0.244
Read: (tmp)

(tmp) 101. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF' p='copies/c178_15.py'; s=open(p).read() s=s.replace("z = -1.0 * yy + 0.8 * np.sin(xx / 17.0) # the wall; gentle
cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF'
p='copies/c178_15.py'; s=open(p).read()
s=s.replace("z = -1.0 * yy + 0.8 * np.sin(xx / 17.0)            # the wall; gentle undulation",
"z = -1.0 * (yy + 0.28 * xx) + 0.8 * np.sin(xx / 17.0)   # the wall: form lines rise to the right (plate)")
s=s.replace("z -= 3.0 * np.clip(1 - d / GW, 0, None)          # narrow gully: contours kink down-slope",
"z -= 5.0 * np.clip(1 - d / GW, 0, None) ** 0.7   # narrow gully: contours kink down into it (chevrons)")
s=s.replace("GW = 3.5","GW = 5.0")
s=s.replace("z += np.cumsum(rng.normal(0, 0.18, z.shape), 1) * 0.15",
"from scipy import ndimage as ndi\nz += ndi.gaussian_filter(rng.normal(0, 1, z.shape), 3) * 6.0   # the lines wobble\nz += 1.5 * np.clip(gd / 12.0, 0, 1)                            # strips between gullies bulge (convex)")
s=s.replace("""        if gd[ri, ci] < GW and gs[ri, ci] < 0:        # lit wall of the gully: open (scraped)
            continue""","""        if gd[ri, ci] < 1.6:                          # dashes stop short of the gully floor
            continue
        if gd[ri, ci] < GW and gs[ri, ci] < 0 and gd[ri, ci] < 3.0:   # lit wall: open (scraped)
            continue""")
s=s.replace("GW = 5.0\nz = ","GW = 5.0\ngd = np.full((H, W), 1e9)\nfor g in GULLIES:\n    gd = np.minimum(gd, dist_to_poly(xx, yy, g)[0])\nz = ")
open(p,'w').write(s)
EOF
python3 copies/c178_15.py && python3 code/cmp_copy.py refs/plates/fig178_15.png copies/c178_15.npy (tmp) "Copy 2: Fig.178-15 slab wall with gullies, v2" 5

# output:
ink share plate 0.226  mine 0.277
Read: (tmp)

(tmp) 102. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/cmp_copy.py refs/plates/fig178_15.png copies/c178_15.npy sheets/copy_178_15.png "Copy 2: Fig.178-15, slab wall channelled by gu
cd ~/work/pixelart-studies/books/imhof; python3 code/cmp_copy.py refs/plates/fig178_15.png copies/c178_15.npy sheets/copy_178_15.png "Copy 2: Fig.178-15, slab wall channelled by gullies (p.260): contours of the implied surface as broken form lines" 5 >/dev/null; python3 code/spread.py (tmp) 900 256 258

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 103. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 -c " from PIL import Image im=Image.open('pages/p-256.png').convert('L'); print(im.size) im.crop((0,0,im.width,1400)).resize((700,in
cd ~/work/pixelart-studies/books/imhof; python3 -c "
from PIL import Image
im=Image.open('pages/p-256.png').convert('L'); print(im.size)
im.crop((0,0,im.width,1400)).resize((700,int(1400*700/im.width))).save('(tmp))
"

# output:
(2039, 3381)
Read: (tmp)

(tmp) 104. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 -c " from PIL import Image im=Image.open('pages/p-256.png').convert('L') im.crop((30,180,1260,1030)).save('refs/plates/fig161.png')
cd ~/work/pixelart-studies/books/imhof; python3 -c "
from PIL import Image
im=Image.open('pages/p-256.png').convert('L')
im.crop((30,180,1260,1030)).save('refs/plates/fig161.png')
im=Image.open('pages/p-257.png').convert('L'); print(im.size)
im.resize((600,int(im.height*600/im.width))).save('(tmp))
"

# output:
(2050, 3364)
Read: (tmp)

(tmp) 105. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 -c " from PIL import Image im=Image.open('pages/p-257.png').convert('L') im.crop((120,1300,2000,2280)).save('refs/plates/fig165.png'
cd ~/work/pixelart-studies/books/imhof; python3 -c "
from PIL import Image
im=Image.open('pages/p-257.png').convert('L')
im.crop((120,1300,2000,2280)).save('refs/plates/fig165.png')
im.crop((80,120,1650,1050)).save('refs/plates/fig164.png')
Image.open('refs/plates/fig165.png').resize((940,490)).save('(tmp))"

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 106. (tmp)

Bash: python3 -c "import skan; print(skan.__version__)" 2>&1 | tail -1
python3 -c "import skan; print(skan.__version__)" 2>&1 | tail -1

# output:
ModuleNotFoundError: No module named 'skan'
Write: books/imhof/code/vectorize.py
"""Turn a 1-bit pen plate into stroke polylines (for interpretive master copies).

strokes(img_bool, scale) -> list of dict(pts=(N,2) (x, y) in target px, length, width)
Skeleton -> split at junctions -> paths; width = mean distance-transform along the path (pen weight).
"""
import numpy as np
from scipy import ndimage as ndi
from skimage.morphology import skeletonize

NB = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]


def rdp(pts, eps):
    if len(pts) < 3:
        return pts
    a, b = pts[0], pts[-1]; ab = b - a; n = np.hypot(*ab) + 1e-9
    d = np.abs(ab[0] * (pts[:, 1] - a[1]) - ab[1] * (pts[:, 0] - a[0])) / n
    i = int(np.argmax(d))
    if d[i] > eps:
        return np.vstack([rdp(pts[:i + 1], eps)[:-1], rdp(pts[i:], eps)])
    return np.array([a, b])


def strokes(ink, scale, min_len_src=4, eps=0.35):
    """ink: bool array of the plate (True = ink). scale: target px per source px."""
    sk = skeletonize(ink)
    dt = ndi.distance_transform_edt(ink)
    H, W = sk.shape
    nbc = ndi.convolve(sk.astype(int), np.ones((3, 3), int), mode='constant') - 1
    nbc[~sk] = 0
    junction = sk & (nbc >= 3)
    free = sk & ~junction
    seen = np.zeros_like(sk)
    out = []
    ys, xs = np.nonzero(free)
    order = np.argsort(nbc[ys, xs])            # start from endpoints first
    for k in order:
        y, x = ys[k], xs[k]
        if seen[y, x]:
            continue
        path = [(y, x)]; seen[y, x] = True
        for direction in (0, 1):
            cy, cx = (y, x)
            while True:
                nxt = None
                for dy, dx in NB:
                    ny, nx = cy + dy, cx + dx
                    if 0 <= ny < H and 0 <= nx < W and free[ny, nx] and not seen[ny, nx]:
                        nxt = (ny, nx); break
                if nxt is None:
                    break
                seen[nxt] = True
                (path.append if direction == 0 else lambda p: path.insert(0, p))(nxt)
                cy, cx = nxt
        if len(path) < min_len_src:
            continue
        p = np.array(path, float)
        w = float(dt[p[:, 0].astype(int), p[:, 1].astype(int)].mean() * 2)
        xy = np.stack([p[:, 1], p[:, 0]], -1) * scale
        xy = rdp(xy, eps)
        L = float(np.sum(np.hypot(*np.diff(xy, axis=0).T))) if len(xy) > 1 else 0
        out.append(dict(pts=xy, length=L, width=w * scale))
    return out
Write: books/imhof/copies/copy_pen.py
"""Interpretive master copy of an Imhof pen drawing (Figs 161, 165 ...) at pixel size.

Panels: plate | photographic reduction (box + threshold, Imhof's Fig.192 failure) | every stroke
redrawn as a clean 1-px line | generalized: only strokes long enough to carry form at this size, pen
weight kept (heavy strokes 2 px on their shadow side) | value translation: the drawing's own stroke
density read as tone (its notan), three steps, with the kept strokes over it.
usage: copy_pen.py plate.png target_w out.png "title" [min_len]"""
import sys, numpy as np
from PIL import Image
from scipy import ndimage as ndi
sys.path.insert(0, __file__.rsplit('/', 2)[0] + '/code')
import vectorize as V, sheet as SH
from pen import Canvas

plate = Image.open(sys.argv[1]).convert('L'); tw = int(sys.argv[2]); out = sys.argv[3]; title = sys.argv[4]
min_len = float(sys.argv[5]) if len(sys.argv) > 5 else 3.0
ink = np.asarray(plate) < 128
s = tw / plate.width; th = round(plate.height * s)
ST = V.strokes(ink, s, min_len_src=3, eps=0.3)
print(len(ST), 'strokes; lengths (target px) median %.1f' % np.median([t['length'] for t in ST]))

PAPER = np.array([240, 236, 226]); INK = np.array([[240, 236, 226], [176, 170, 160], [96, 92, 90], [30, 28, 30]], np.uint8)

red = np.asarray(plate.resize((tw, th), Image.BOX)) < 170

all_ = Canvas(tw, th)
for t in ST:
    all_.line([tuple(p) for p in t['pts']], 3)

gen = Canvas(tw, th)
wmed = np.median([t['width'] for t in ST])
for t in sorted(ST, key=lambda t: t['length']):
    if t['length'] < min_len:
        continue
    pts = [tuple(p) for p in t['pts']]
    gen.line(pts, 3)
    if t['width'] > 1.8 * wmed and t['length'] > 2 * min_len:     # a heavy pen stroke: 2 px, shadow side (right/down)
        gen.line([(x + 1, y) for x, y in pts], 3)

# value translation: stroke density of the plate at the scale of a few target px = the drawing's tone
dens = ndi.gaussian_filter(ink.astype(float), 2.2 / s)
dens = np.asarray(Image.fromarray((dens * 255).astype(np.uint8)).resize((tw, th), Image.BOX)) / 255.
q = np.digitize(dens, [0.10, 0.24])                    # 0 light, 1 half, 2 shade
val = np.array([0, 1, 2])[q]
valimg = INK[val].copy()
valimg[gen.a >= 3] = INK[3]

rows = [[(SH.fit(plate, h=th * 3, smooth=True), 'Imhof'),
         (SH.fit(np.where(red[..., None], INK[3], INK[0]).astype(np.uint8), 3), 'photographic reduction %dx%d' % (tw, th))],
        [(SH.fit(INK[all_.a], 3), 'every stroke, 1 px'),
         (SH.fit(INK[gen.a], 3), 'generalized: strokes >= %.0f px, pen weight kept' % min_len)],
        [(SH.fit(valimg, 3), 'value translation: stroke density as tone + kept strokes'),
         (SH.fit(valimg, 1), '1x')]]
SH.sheet(rows, title, out=out)
np.save(out.replace('.png', '_gen.npy'), gen.a); np.save(out.replace('.png', '_val.npy'), val)
Bash: cd ~/work/pixelart-studies/books/imhof; time python3 copies/copy_pen.py refs/plates/fig161.png 200 (tmp) "Copy 3: Fig.161 granite (p.239)" 4
cd ~/work/pixelart-studies/books/imhof; time python3 copies/copy_pen.py refs/plates/fig161.png 200 (tmp) "Copy 3: Fig.161 granite (p.239)" 4

# output:
954 strokes; lengths (target px) median 3.3
python3 copies/copy_pen.py refs/plates/fig161.png 200 (tmp)  1.74s user 0.54s system 74% cpu 3.050 total
Read: (tmp)

(tmp) 107. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/grid.py refs/plates/fig161.png 160 (tmp) 7
cd ~/work/pixelart-studies/books/imhof; python3 code/grid.py refs/plates/fig161.png 160 (tmp) 7

# output:
160 111
Read: (tmp)

(tmp) 108. (tmp)

Write: books/imhof/copies/c161_paint.py
"""Copy 3b: Fig.161 (granite, p.239) translated from pen to pixel values, by hand.

The pen drawing states, and I carry over: (1) a sharp sky silhouette of needles; (2) masses lit from
the left, the right flank of every tower denser = in shade; (3) long joint flutes following each tower,
a few heavy ones in the deep clefts; (4) the snow couloirs and snowfield left open, with long fall-line
strokes; (5) a bold near ridge. Polygons are read off the gridded plate (160x111), by eye."""
import sys, numpy as np
from PIL import Image, ImageDraw
sys.path.insert(0, __file__.rsplit('/', 2)[0] + '/code')
import vectorize as V, sheet as SH
from pen import Canvas

W, H = 160, 111
# palette (index: role)
PAL = np.array([
    [203, 214, 226],   # 0 sky
    [244, 244, 240],   # 1 snow lit
    [196, 206, 222],   # 2 snow shade / groove
    [222, 214, 198],   # 3 granite lit
    [178, 172, 164],   # 4 granite half (flute on lit)
    [120, 122, 136],   # 5 granite shade (blue-grey, Imhof p.190)
    [70, 70, 86],      # 6 crevice / flute in shade
    [34, 34, 44],      # 7 deepest
    [150, 150, 130],   # 8 near ridge (grass in shade)
    [196, 190, 150],   # 9 near ridge lit rim
], np.uint8)

SKYLINE = [(0, 42), (3, 40), (5, 37), (8, 40), (10, 36), (14, 33), (16, 31), (18, 34), (22, 33), (25, 31),
           (27, 27), (29, 22), (31, 19), (34, 15), (36, 14), (38, 17), (41, 20), (45, 20), (48, 18), (51, 12),
           (54, 10), (57, 8), (60, 9), (63, 13), (66, 17), (70, 19), (74, 22), (77, 22), (79, 18), (80, 12),
           (82, 3), (84, 7), (86, 8), (88, 11), (90, 13), (93, 13), (96, 14), (99, 15), (101, 18), (104, 17),
           (106, 14), (109, 11), (113, 8), (116, 5), (117, 4), (118, 6), (120, 9), (122, 12), (124, 18),
           (126, 23), (129, 28), (133, 32), (136, 36), (139, 40), (142, 44), (147, 48)]
FOOT = [(147, 48), (140, 51), (128, 56), (118, 62), (106, 70), (100, 76), (92, 78), (80, 76), (70, 74),
        (62, 72), (55, 76), (48, 86), (44, 86), (40, 80), (34, 78), (28, 76), (20, 78), (10, 80), (0, 81)]
SHADE = [  # right flanks and clefts (in shade under light from the left)
    [(46, 22), (52, 30), (58, 45), (60, 60), (57, 70), (53, 62), (50, 45), (46, 30)],
    [(60, 10), (66, 17), (72, 22), (74, 40), (72, 56), (68, 64), (66, 45), (62, 24)],
    [(84, 7), (90, 13), (96, 15), (98, 30), (96, 52), (92, 62), (89, 44), (86, 20)],
    [(118, 6), (124, 18), (133, 32), (147, 48), (128, 56), (124, 45), (121, 25)],
    [(76, 22), (79, 20), (80, 40), (79, 60), (76, 70), (75, 50)],
    [(100, 18), (104, 17), (105, 40), (104, 62), (100, 72), (100, 45)],
    [(108, 34), (112, 36), (112, 58), (109, 64), (107, 50)],
    [(16, 31), (18, 34), (21, 48), (20, 66), (17, 76), (16, 55)],
    [(27, 27), (29, 22), (33, 40), (32, 60), (29, 74), (28, 50)],
    [(4, 40), (5, 37), (8, 45), (8, 62), (6, 78), (5, 60)],
]
COULOIR = [(30, 64), (36, 62), (44, 70), (48, 84), (42, 84), (36, 76)]      # snow tongue into the massif
NEAR = [(0, 108), (10, 102), (25, 98), (40, 93), (52, 90), (55, 89), (62, 91), (75, 93), (100, 95),
        (105, 93), (120, 97), (128, 100), (145, 104), (160, 106), (160, 111), (0, 111)]


def poly_mask(pts):
    im = Image.new('L', (W, H), 0); ImageDraw.Draw(im).polygon(pts, fill=1)
    return np.asarray(im).astype(bool)


idx = np.zeros((H, W), np.uint8)                       # sky
yy, xx = np.mgrid[0:H, 0:W]
idx[yy > 50] = 1                                       # snowfield below the massif
massif = poly_mask(SKYLINE + FOOT)
idx[massif] = 3
for sp in SHADE:
    idx[poly_mask(sp) & massif] = 5
idx[poly_mask(COULOIR)] = 1

# the flutes: Imhof's own long strokes, vectorized, kept if they carry form at this size
plate = Image.open(__file__.rsplit('/', 2)[0] + '/refs/plates/fig161.png').convert('L')
ink = np.asarray(plate) < 128
ST = V.strokes(ink, W / plate.width, min_len_src=3, eps=0.3)
marks = Canvas(W, H)
for t in ST:
    if t['length'] < 4:
        continue
    marks.line([tuple(p) for p in t['pts']], 3 if t['width'] > 1.6 * np.median([s['width'] for s in ST]) else 2)
m = marks.a
on_lit = (m > 0) & (idx == 3)
on_shade = (m > 0) & (idx == 5)
on_snow = (m > 0) & (idx == 1)
idx[on_lit] = 4                                        # a flute on a lit face: one step down
idx[on_shade] = 6                                      # in shade: the crevice step
idx[(m == 3) & (idx == 6)] = 7                         # heavy pen strokes in the clefts: deepest
# 178-15: each crevice in shade gets its lit side (the pixel to its left, if still shade)
lit_side = np.roll(on_shade, -1, axis=1) & (idx == 5)
idx[lit_side] = 4
idx[on_snow & (yy > 55)] = 2                           # fall-line grooves on the snowfield
# sky edge: the silhouette stays crisp; lit needle tips get the lit step (already)
near = poly_mask(NEAR)
idx[near] = 8
top = near & ~np.roll(near, 1, axis=0)
idx[top & (xx < 60)] = 9                               # lit rim on the near ridge where it faces the light
idx[top & (xx >= 60)] = 7                              # its far edge against snow in shade: dark
out = PAL[idx]
np.save(__file__.replace('.py', '.npy'), idx)
rows = [[(SH.fit(plate, h=H * 4, smooth=True), 'Imhof Fig.161 (pen)'), (SH.fit(out, 4), 'my pixel copy 160x111, 4x')],
        [(SH.fit(out, 1), '1x'), (SH.fit(out, 2), '2x')]]
SH.sheet(rows, 'Copy 3: Fig.161 granite, pen -> pixel values (masses by hand, flutes from his strokes)',
         out=sys.argv[1] if len(sys.argv) > 1 else '(tmp))
Bash: cd ~/work/pixelart-studies/books/imhof; python3 copies/c161_paint.py
cd ~/work/pixelart-studies/books/imhof; python3 copies/c161_paint.py

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 109. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF' p='copies/c161_paint.py'; s=open(p).read() s=s.replace(""" [34, 34, 44], # 7 deepest""",""" [34, 34, 44],
cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF'
p='copies/c161_paint.py'; s=open(p).read()
s=s.replace("""    [34, 34, 44],      # 7 deepest""","""    [34, 34, 44],      # 7 deepest""")
s=s.replace("""out = PAL[idx]""","""# aerial perspective (p.172, 263): contrast falls toward the foot of the wall
foot_y = np.full(W, H, float)
for x in range(W):
    col = np.flatnonzero(massif[:, x]); foot_y[x] = col.max() if len(col) else H
lowpart = massif & (yy > foot_y[None, :] - 9)
idx[lowpart & (idx == 5)] = 4
idx[lowpart & (idx == 6)] = 5
idx[lowpart & (idx == 7)] = 5
# the foot: debris tongues of snow and scree reach up into the gully mouths (p.247), no straight edge
rng = np.random.default_rng(2)
for x in range(W):
    if not massif[:, x].any():
        continue
    f = int(foot_y[x]); k = int(max(0, rng.normal(1.5, 1.6)))
    idx[max(0, f - k):f + 1, x] = np.where(idx[max(0, f - k):f + 1, x] >= 3, 1, idx[max(0, f - k):f + 1, x])
    if rng.random() < 0.35:
        idx[f + 1:f + 1 + rng.integers(1, 3), x] = 2       # a scree grain below the foot, in shadow blue
# saw-tooth skyline: lit needle edges facing the light get the lightest step (p.263: tiny lights cut the edge)
sky = (idx == 0)
edge_lit = massif & np.roll(sky, 1, axis=1) & (idx == 3)
idx[edge_lit] = 1
out = PAL[idx]""")
open(p,'w').write(s)
EOF
python3 copies/c161_paint.py sheets/copy_161.png && cp sheets/copy_161.png (tmp)

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 110. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/grid.py refs/plates/fig165.png 160 (tmp) 7
cd ~/work/pixelart-studies/books/imhof; python3 code/grid.py refs/plates/fig165.png 160 (tmp) 7

# output:
160 83
Read: (tmp)

(tmp) 111. (tmp)

Write: books/imhof/copies/c165_paint.py
"""Copy 4: Fig.165 (horizontally bedded rock, p.240) translated from pen to pixel values.

What the pen says, carried over: a stack of bands -- cliff (hard bed, vertical joints), bench (soft
bed, debris slopes cut by branching gullies), cliff, debris apron -- lit from the left, each buttress's
right face in shade, each gully a dark V under a lit ridge; the receding plateau steps on the right drawn
with sparse ledge lines only: far = less contrast. All positions read off the gridded plate (160x83)."""
import sys, numpy as np
from PIL import Image, ImageDraw
sys.path.insert(0, __file__.rsplit('/', 2)[0] + '/code')
import vectorize as V, sheet as SH
from pen import Canvas

W, H = 160, 83
PAL = np.array([
    [206, 214, 222],   # 0 sky
    [236, 226, 204],   # 1 cliff lit
    [200, 190, 172],   # 2 cliff half / joint on lit
    [128, 122, 128],   # 3 cliff shade
    [76, 72, 82],      # 4 slot / crevice
    [214, 200, 172],   # 5 bench debris lit
    [168, 156, 140],   # 6 bench debris shade / gully wall
    [226, 212, 186],   # 7 apron lit
    [186, 176, 160],   # 8 apron streak
    [214, 214, 212],   # 9 far plateau lit (haze)
    [184, 186, 194],   # 10 far plateau shade (haze)
    [40, 38, 48],      # 11 deepest
], np.uint8)

SIL = [(0, 34), (8, 33), (10, 24), (12, 23), (20, 23), (22, 25), (26, 26), (28, 20), (33, 20), (36, 22),
       (40, 19), (42, 14), (44, 13), (52, 13), (56, 10), (62, 10), (66, 9), (74, 9), (76, 8), (78, 11),
       (84, 12), (86, 16), (88, 15), (94, 15), (96, 18), (100, 21), (106, 22), (112, 24), (122, 25),
       (130, 26), (140, 29), (150, 30), (160, 31), (160, 83), (0, 83)]
A = [(0, 36), (10, 34), (30, 31), (42, 30), (60, 29), (80, 29), (90, 31)]          # foot of the cap cliff
B = [(0, 46), (20, 46), (40, 47), (60, 46), (80, 46), (100, 47), (118, 51)]        # top of the lower cliff
C = [(0, 67), (30, 67), (60, 66), (90, 66), (120, 67)]                              # foot of the lower cliff
FAR_X = 92                                                                          # right of this: far plateau
COLS = [9, 16, 22, 30, 36, 44, 52, 58, 64, 70, 76, 84, 92, 100, 106, 112]           # joint slots, lower cliff
CAPCOLS = [14, 20, 32, 38, 47, 52, 58, 63, 69, 74, 80, 86]                         # joints in the cap
GULLIES = [40, 53, 68, 81]                                                          # bench gullies (x at B)


def interp(line, x):
    xs, ys = zip(*line); return np.interp(x, xs, ys)


def poly(pts):
    im = Image.new('L', (W, H), 0); ImageDraw.Draw(im).polygon(pts, fill=1)
    return np.asarray(im).astype(bool)


yy, xx = np.mgrid[0:H, 0:W]
land = poly(SIL)
ya, yb, yc = interp(A, xx), interp(B, xx), interp(C, xx)
idx = np.zeros((H, W), np.uint8)
cap = land & (yy < ya)
bench = land & (yy >= ya) & (yy < yb)
cliff = land & (yy >= yb) & (yy < yc)
apron = land & (yy >= yc)
idx[cap] = 1; idx[bench] = 5; idx[cliff] = 1; idx[apron] = 7
rng = np.random.default_rng(5)

# joints: each column's right face in shade (2-3 px), a dark slot at the joint itself
for zone, cols, depth in ((cliff, COLS, 3), (cap, CAPCOLS, 2)):
    for x in cols:
        w = depth + int(rng.integers(0, 2))
        band = zone & (xx >= x) & (xx < x + w)
        idx[band] = 3
        slot = zone & (xx == x)
        idx[slot] = 4
        # the lit side of the slot: the pixel left of it stays lit, one step up is not needed (already lit)
# the stepped foot of the columns (p.240: 'resistant beds vertical walls'), a dark contact line
for x in range(W):
    y = int(round(yc[x]))
    if 0 <= y < H and land[y, x]:
        idx[y, x] = 4 if idx[y - 1, x] in (3, 4) else 2
# bench: debris slopes falling from each buttress, gullies as dark V's with a lit ridge beside them
for g in GULLIES:
    for y in range(H):
        if not (bench[y, :].any()):
            continue
    top = interp(A, g); bot = interp(B, g)
    for y in range(int(top), int(bot) + 1):
        t = (y - top) / max(bot - top, 1)
        half = 1 + 7 * t                                 # the V widens downward (fan into the gully mouth)
        xl = int(round(g - half)); xr = int(round(g + half * 0.5))
        idx[y, max(0, xl):g] = np.where(bench[y, max(0, xl):g], 6, idx[y, max(0, xl):g])   # shaded wall
        if 0 <= g < W and bench[y, g]:
            idx[y, g] = 4                                                                   # gully floor
        idx[y, g + 1:min(W, xr + 1)] = np.where(bench[y, g + 1:min(W, xr + 1)], 5, idx[y, g + 1:min(W, xr + 1)])
# apron: fall-line streaks radiating from the slots below each column
for x in COLS:
    y0 = int(round(interp(C, x))) + 1
    for k in range(-1, 2):
        ang = np.radians(90 + k * 18 + rng.normal(0, 4))
        L = rng.integers(6, 14)
        for s in range(L):
            px, py = int(round(x + 0.5 + np.cos(ang) * s * 1.1 * np.sign(k or 1) * (1 if k else 0))), y0 + s
            px = int(round(x + 0.5 + s * np.tan(np.radians(k * 22))))
            if 0 <= py < H and 0 <= px < W and apron[py, px] and (s % 5 != 4):
                idx[py, px] = 8
# far plateau (right): the same bands, but only ledge lines and a hazed shade: aerial perspective
far = land & (xx >= FAR_X) & (yy < 68)
idx[far] = 9
for y0, x0, x1 in [(25, 96, 150), (31, 100, 158), (37, 104, 158), (42, 110, 150), (47, 116, 150),
                   (53, 118, 156), (59, 122, 158), (64, 128, 158)]:
    for x in range(x0, x1):
        yb_ = y0 + int(0.03 * (x - x0))
        if 0 <= yb_ + 1 < H and far[yb_, x]:
            idx[yb_ + 1, x] = 10                        # shadow under each ledge lip
            if rng.random() < 0.12:
                idx[yb_ + 1:yb_ + 3, x] = 10            # a joint notch
out = PAL[idx]
np.save(__file__.replace('.py', '.npy'), idx)
plate = Image.open(__file__.rsplit('/', 2)[0] + '/refs/plates/fig165.png').convert('L')
rows = [[(SH.fit(plate, h=H * 4, smooth=True), 'Imhof Fig.165 (pen)'), (SH.fit(out, 4), 'pixel copy 160x83, 4x')],
        [(SH.fit(out, 1), '1x'), (SH.fit(out, 2), '2x')]]
SH.sheet(rows, 'Copy 4: Fig.165 horizontally bedded rock, pen -> pixel values', out=sys.argv[1] if len(sys.argv) > 1 else '(tmp))
Bash: cd ~/work/pixelart-studies/books/imhof; python3 copies/c165_paint.py
cd ~/work/pixelart-studies/books/imhof; python3 copies/c165_paint.py

# output:
Exit code 1
Traceback (most recent call last):
  File "~/work/pixelart-studies/books/imhof/copies/c165_paint.py", line 73, in <module>
    y = int(round(yc[x]))
            ^^^^^^^^^^^^
TypeError: type numpy.ndarray doesn't define __round__ method
Bash: cd ~/work/pixelart-studies/books/imhof; sed -i '' 's/ y = int(round(yc\[x\]))/ y = int(round(interp(C, x)))/' copies/c165_paint.py; python3 copies/c165_paint.py
cd ~/work/pixelart-studies/books/imhof; sed -i '' 's/    y = int(round(yc\[x\]))/    y = int(round(interp(C, x)))/' copies/c165_paint.py; python3 copies/c165_paint.py

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 112. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF' p='copies/c165_paint.py'; s=open(p).read() start=s.index('# joints: each column') end=s.index('out = PAL[idx]') new='''# -
cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF'
p='copies/c165_paint.py'; s=open(p).read()
start=s.index('# joints: each column')
end=s.index('out = PAL[idx]')
new='''# ---- Imhof's own strokes are the structure; the masses are decided per stroke by the light rule:
# a recess (joint slot, gully) has its shaded wall on the side away from the light (left, for light
# from the left) and a lit wall on the other. Strokes are classified by zone and direction.
plate0 = Image.open(__file__.rsplit('/', 2)[0] + '/refs/plates/fig165.png').convert('L')
ST = V.strokes(np.asarray(plate0) < 128, W / plate0.width, min_len_src=3, eps=0.3)
wmed = np.median([t['width'] for t in ST])
for t in sorted(ST, key=lambda t: -t['length']):
    if t['length'] < 2.5:
        continue
    pts = t['pts']; d = pts[-1] - pts[0]
    vert = abs(d[1]) > 1.2 * abs(d[0])
    heavy = t['width'] > 1.5 * wmed
    cv = Canvas(W, H); cv.line([tuple(p) for p in pts], 1); m = cv.a > 0
    for y, x in zip(*np.nonzero(m)):
        if far[y, x] if 'far' in dir() else False:
            continue
        if cliff[y, x] or cap[y, x]:
            if vert:
                idx[y, x] = 11 if heavy else 4            # the slot
                for k in (1, 2) if heavy else (1,):
                    if x - k >= 0 and (cliff[y, x - k] or cap[y, x - k]) and idx[y, x - k] in (1, 2):
                        idx[y, x - k] = 3                 # the column's right face, in shade
            else:
                idx[y, x] = 2 if idx[y, x] == 1 else idx[y, x]   # a bedding crack on a lit face
                if y + 1 < H and idx[y + 1, x] == 1:
                    idx[y + 1, x] = 2
        elif bench[y, x]:
            idx[y, x] = 4 if heavy else 6                 # gully lines on the debris
            if x - 1 >= 0 and bench[y, x - 1] and idx[y, x - 1] == 5:
                idx[y, x - 1] = 6
        elif apron[y, x]:
            idx[y, x] = 8 if not heavy else 6
'''
s=s[:start]+new+"\n"+s[end:]
# far plateau must be defined before the stroke loop: move far definition up
s=s.replace("idx[cap] = 1; idx[bench] = 5; idx[cliff] = 1; idx[apron] = 7","idx[cap] = 1; idx[bench] = 5; idx[cliff] = 1; idx[apron] = 7\nfar = land & (xx >= FAR_X) & (yy < 68)\ncap &= ~far; bench &= ~far; cliff &= ~far")
s=s.replace("        if far[y, x] if 'far' in dir() else False:\n            continue\n","")
open(p,'w').write(s)
EOF
grep -n "far" copies/c165_paint.py | head

# output:
6:with sparse ledge lines only: far = less contrast. All positions read off the gridded plate (160x83)."""
24:    [214, 214, 212],   # 9 far plateau lit (haze)
25:    [184, 186, 194],   # 10 far plateau shade (haze)
36:FAR_X = 92                                                                          # right of this: far plateau
60:far = land & (xx >= FAR_X) & (yy < 68)
61:cap &= ~far; bench &= ~far; cliff &= ~far
Bash: cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF' p='copies/c165_paint.py'; s=open(p).read() s=s.replace("cap &= ~far; bench &= ~far; cliff &= ~far","cap &= ~far; bench &=
cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF'
p='copies/c165_paint.py'; s=open(p).read()
s=s.replace("cap &= ~far; bench &= ~far; cliff &= ~far","cap &= ~far; bench &= ~far; cliff &= ~far\nidx[far] = 9")
s=s.replace("""        elif apron[y, x]:
            idx[y, x] = 8 if not heavy else 6
""","""        elif apron[y, x]:
            idx[y, x] = 8 if not heavy else 6
        elif far[y, x]:
            idx[y, x] = 10                                # far: every mark in the one hazed shade
""")
open(p,'w').write(s)
EOF
python3 copies/c165_paint.py

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 113. (tmp)

Write: books/imhof/copies/c165_paint.py
"""Copy 4: Fig.165 (horizontally bedded rock, p.240), translated from pen to pixel masses by hand.

Round 1 (a band chart with barcode joints) and round 2 (Imhof's strokes transferred one-to-one) both
failed -- the first mechanical, the second pen graffiti. This round paints what the pen describes:
  cap cliff -> blocks, each with a lit front and a shaded right face (light from the left);
  bench (soft bed) -> a row of debris fans hanging from the gullies, each fan lit on its left half,
    darker on its right, the gully trunk a dark line; spurs between fans;
  lower cliff -> buttresses of varying width, shaded right faces, dark slots, a stepped foot;
  apron -> a fan below each slot; far plateau (right) -> the same bands, hazed, drawn with ledge lips only.
Positions are read off the gridded plate (160x83) and then drawn by eye."""
import sys, numpy as np
from PIL import Image, ImageDraw
sys.path.insert(0, __file__.rsplit('/', 2)[0] + '/code')
import sheet as SH

W, H = 160, 83
PAL = np.array([
    [206, 214, 222],   # 0 sky
    [238, 228, 206],   # 1 lit face
    [210, 198, 178],   # 2 half (crack on lit, turning face)
    [140, 132, 136],   # 3 shaded face
    [88, 82, 92],      # 4 slot
    [220, 204, 176],   # 5 debris lit
    [186, 172, 154],   # 6 debris shade
    [150, 140, 132],   # 7 gully trunk on debris
    [224, 222, 218],   # 8 haze lit
    [196, 198, 206],   # 9 haze shade
    [52, 48, 58],      # 10 deepest
], np.uint8)
rng = np.random.default_rng(11)


def interp(line, x):
    xs, ys = zip(*line); return float(np.interp(x, xs, ys))


def poly(pts):
    im = Image.new('L', (W, H), 0); ImageDraw.Draw(im).polygon([tuple(map(float, p)) for p in pts], fill=1)
    return np.asarray(im).astype(bool)


SIL = [(0, 35), (6, 34), (9, 25), (13, 23), (19, 23), (21, 26), (26, 26), (28, 21), (33, 20), (36, 23),
       (40, 19), (42, 14), (45, 12), (51, 13), (55, 10), (61, 11), (66, 9), (73, 9), (76, 8), (78, 11),
       (84, 12), (86, 16), (89, 15), (93, 15), (96, 19), (101, 21), (107, 22), (113, 24), (123, 25),
       (131, 26), (140, 29), (150, 30), (160, 31)]
A = [(0, 37), (8, 35), (20, 33), (30, 31), (42, 30), (54, 30), (66, 29), (80, 29), (88, 31), (96, 33)]
B = [(0, 47), (12, 46), (24, 46), (40, 47), (52, 46), (66, 46), (80, 46), (92, 47), (104, 49), (118, 52)]
C = [(0, 67), (14, 68), (30, 67), (46, 66), (60, 67), (76, 66), (92, 66), (106, 66), (118, 67)]

yy, xx = np.mgrid[0:H, 0:W]
land = poly(SIL + [(160, 83), (0, 83)])
idx = np.zeros((H, W), np.uint8)
idx[land] = 1


def band(top, bot):
    ya = np.array([interp(top, x) for x in range(W)]); yb = np.array([interp(bot, x) for x in range(W)])
    return land & (yy >= ya[None, :]) & (yy < yb[None, :])


cap = land & (yy < np.array([interp(A, x) for x in range(W)])[None, :])
bench = band(A, B); cliff = band(B, C)
apron = land & (yy >= np.array([interp(C, x) for x in range(W)])[None, :])

# ---- cap: blocks (x_left, x_right), right faces shaded 1-3 px, joint slots
CAPB = [(8, 14), (14, 21), (26, 32), (32, 39), (42, 48), (48, 54), (54, 60), (60, 66), (66, 71), (71, 77), (77, 84), (84, 90)]
for xl, xr in CAPB:
    sw = int(rng.choice([1, 2, 2, 3]))
    idx[cap & (xx >= xr - sw) & (xx < xr)] = 3
    idx[cap & (xx == xr)] = 4
    for y in range(H):                                  # a horizontal bedding crack across each block
        pass
for y0 in (18, 24):
    for x in range(40, 90):
        if cap[y0, x] and idx[y0, x] == 1 and rng.random() < 0.8:
            idx[y0, x] = 2

# ---- bench: debris fans hanging from the gully mouths
GUL = [(24, 9), (40, 8), (53, 8), (68, 9), (81, 8)]       # (x at the cap foot, half-width at B)
idx[bench] = 5
for i, (g, hw) in enumerate(GUL):
    top = interp(A, g); bot = interp(B, g)
    fan = poly([(g, top), (g - hw, bot + 1), (g + hw, bot + 1)]) & bench
    idx[fan & (xx < g)] = 5                             # left half of the cone faces the light
    idx[fan & (xx >= g)] = 6                            # right half turns away
    for y in range(int(top), int(bot) + 1):             # the gully trunk: dark, then fading into the fan
        t = (y - top) / max(bot - top, 1)
        if t < 0.55 and bench[y, g]:
            idx[y, g] = 7 if t > 0.25 else 4
    # fall lines radiating on the fan (sparse, the lit side lighter)
    for a in (-0.7, -0.35, 0.35, 0.7):
        for s in range(2, int(bot - top)):
            x = int(round(g + a * s * hw / max(bot - top, 1) * 1.1)); y = int(round(top + s))
            if 0 <= x < W and bench[y, x] and s % 4 != 3:
                idx[y, x] = 6 if a < 0 else 7
# spurs between fans: the buttress under each cap block continues down as a ridge, shaded right
for (g0, _), (g1, _) in zip(GUL[:-1], GUL[1:]):
    xs = (g0 + g1) // 2
    top = interp(A, xs)
    for y in range(int(top), int(interp(B, xs)) - 3):
        if bench[y, xs]:
            idx[y, xs + 1:xs + 3] = np.where(bench[y, xs + 1:xs + 3], 6, idx[y, xs + 1:xs + 3])

# ---- lower cliff: buttresses of varying width
BUT = [(3, 9), (9, 15), (15, 22), (22, 26), (26, 33), (33, 40), (40, 46), (46, 55), (55, 60), (60, 66),
       (66, 73), (73, 80), (80, 86), (86, 92), (92, 99), (99, 105), (105, 112), (112, 118)]
idx[cliff] = 1
for xl, xr in BUT:
    sw = int(rng.choice([1, 2, 2, 3, 3]))
    idx[cliff & (xx >= xr - sw) & (xx < xr)] = 3
    idx[cliff & (xx == xr)] = 4
    # stepped foot: each buttress ends at its own height
    foot = int(interp(C, (xl + xr) / 2)) + int(rng.integers(-2, 2))
    idx[cliff & (xx >= xl) & (xx <= xr) & (yy > foot)] = 5
    # a few cross-fractures, never full width
    for y in rng.choice(np.arange(int(interp(B, xl)) + 3, foot - 2), size=2, replace=False):
        x0 = xl + int(rng.integers(0, 2)); x1 = xr - sw - int(rng.integers(0, 2))
        idx[y, x0:x1] = np.where(idx[y, x0:x1] == 1, 2, idx[y, x0:x1])
# the top edge of the lower cliff catches light (the lip of the hard bed)
for x in range(W):
    y = int(round(interp(B, x)))
    if 0 <= y < H and cliff[y, x] and idx[y, x] == 1:
        idx[y - 1, x] = 6 if bench[y - 1, x] else idx[y - 1, x]

# ---- apron: a debris fan below each slot
idx[apron] = 5
for _, xr in BUT:
    top = interp(C, xr) + 1; L = int(rng.integers(8, 14)); hw = L * 0.55
    fan = poly([(xr, top - 1), (xr - hw, top + L), (xr + hw, top + L)]) & apron
    idx[fan & (xx > xr)] = 6
    for s in range(1, L):
        if s % 3 != 2 and apron[min(H - 1, int(top + s)), xr]:
            idx[int(top + s), xr] = 7

# ---- far plateau: hazed bands, ledge lips only (aerial perspective)
far = land & (xx >= 96)
fade = np.clip((xx - 96) / 20.0, 0, 1)
FAR_LEDGES = [(25, 96, 150), (30, 100, 158), (36, 104, 158), (41, 110, 152), (46, 116, 150),
              (52, 120, 156), (58, 122, 158), (63, 128, 158), (69, 118, 160), (74, 124, 160)]
hz = far & (rng.random((H, W)) < fade)
idx[hz] = 8
for y0, x0, x1 in FAR_LEDGES:
    for x in range(x0 + int(rng.integers(0, 4)), x1 - int(rng.integers(0, 6))):
        y = y0 + int(0.02 * (x - x0))
        if far[y, x] and rng.random() < 0.93:
            idx[y, x] = 9
            if rng.random() < 0.10 and y + 2 < H:
                idx[y + 1:y + 3, x] = 9                  # a notch where a joint cuts the ledge
out = PAL[idx]
np.save(__file__.replace('.py', '.npy'), idx)
plate = Image.open(__file__.rsplit('/', 2)[0] + '/refs/plates/fig165.png').convert('L')
rows = [[(SH.fit(plate, h=H * 4, smooth=True), 'Imhof Fig.165 (pen)'), (SH.fit(out, 4), 'pixel copy 160x83, 4x')],
        [(SH.fit(out, 1), '1x'), (SH.fit(out, 2), '2x')]]
SH.sheet(rows, 'Copy 4: Fig.165 horizontally bedded rock, pen -> pixel masses (round 3)',
         out=sys.argv[1] if len(sys.argv) > 1 else '(tmp))
Bash: cd ~/work/pixelart-studies/books/imhof; mv copies/c165_paint.py copies/c165_paint_r2.py; echo moved
cd ~/work/pixelart-studies/books/imhof; mv copies/c165_paint.py copies/c165_paint_r2.py; echo moved

# output:
moved
Write: books/imhof/copies/c165_paint.py
"""Copy 4: Fig.165 (horizontally bedded rock, p.240), translated from pen to pixel masses by hand.

Round 1 (a band chart with barcode joints) and round 2 (c165_paint_r2.py: Imhof's strokes transferred
one-to-one) both failed -- the first mechanical, the second pen graffiti. This round paints what the pen
describes:
  cap cliff -> blocks, each with a lit front and a shaded right face (light from the left);
  bench (soft bed) -> a row of debris fans hanging from the gullies, each fan lit on its left half,
    darker on its right, the gully trunk a dark line; spurs between fans;
  lower cliff -> buttresses of varying width, shaded right faces, dark slots, a stepped foot;
  apron -> a fan below each slot; far plateau (right) -> the same bands, hazed, drawn with ledge lips only.
Positions are read off the gridded plate (160x83) and then drawn by eye."""
import sys, numpy as np
from PIL import Image, ImageDraw
sys.path.insert(0, __file__.rsplit('/', 2)[0] + '/code')
import sheet as SH

W, H = 160, 83
PAL = np.array([
    [206, 214, 222],   # 0 sky
    [238, 228, 206],   # 1 lit face
    [210, 198, 178],   # 2 half (crack on lit, turning face)
    [140, 132, 136],   # 3 shaded face
    [88, 82, 92],      # 4 slot
    [220, 204, 176],   # 5 debris lit
    [186, 172, 154],   # 6 debris shade
    [150, 140, 132],   # 7 gully trunk on debris
    [224, 222, 218],   # 8 haze lit
    [196, 198, 206],   # 9 haze shade
    [52, 48, 58],      # 10 deepest
], np.uint8)
rng = np.random.default_rng(11)


def interp(line, x):
    xs, ys = zip(*line); return float(np.interp(x, xs, ys))


def poly(pts):
    im = Image.new('L', (W, H), 0); ImageDraw.Draw(im).polygon([tuple(map(float, p)) for p in pts], fill=1)
    return np.asarray(im).astype(bool)


SIL = [(0, 35), (6, 34), (9, 25), (13, 23), (19, 23), (21, 26), (26, 26), (28, 21), (33, 20), (36, 23),
       (40, 19), (42, 14), (45, 12), (51, 13), (55, 10), (61, 11), (66, 9), (73, 9), (76, 8), (78, 11),
       (84, 12), (86, 16), (89, 15), (93, 15), (96, 19), (101, 21), (107, 22), (113, 24), (123, 25),
       (131, 26), (140, 29), (150, 30), (160, 31)]
A = [(0, 37), (8, 35), (20, 33), (30, 31), (42, 30), (54, 30), (66, 29), (80, 29), (88, 31), (96, 33)]
B = [(0, 47), (12, 46), (24, 46), (40, 47), (52, 46), (66, 46), (80, 46), (92, 47), (104, 49), (118, 52)]
C = [(0, 67), (14, 68), (30, 67), (46, 66), (60, 67), (76, 66), (92, 66), (106, 66), (118, 67)]

yy, xx = np.mgrid[0:H, 0:W]
land = poly(SIL + [(160, 83), (0, 83)])
idx = np.zeros((H, W), np.uint8)
idx[land] = 1


def yline(line):
    return np.array([interp(line, x) for x in range(W)])[None, :]


cap = land & (yy < yline(A))
bench = land & (yy >= yline(A)) & (yy < yline(B))
cliff = land & (yy >= yline(B)) & (yy < yline(C))
apron = land & (yy >= yline(C))

# ---- cap: blocks, right faces shaded 1-3 px, joint slots, two bedding cracks
CAPB = [(8, 14), (14, 21), (26, 32), (32, 39), (42, 48), (48, 54), (54, 60), (60, 66), (66, 71), (71, 77), (77, 84), (84, 90)]
for xl, xr in CAPB:
    sw = int(rng.choice([1, 2, 2, 3]))
    idx[cap & (xx >= xr - sw) & (xx < xr)] = 3
    idx[cap & (xx == xr)] = 4
for y0 in (18, 24):
    for x in range(40, 90):
        if cap[y0, x] and idx[y0, x] == 1 and rng.random() < 0.8:
            idx[y0, x] = 2

# ---- bench: debris fans hanging from the gully mouths
GUL = [(24, 9), (40, 8), (53, 8), (68, 9), (81, 8)]       # (x at the cap foot, half-width at B)
idx[bench] = 5
for g, hw in GUL:
    top = interp(A, g); bot = interp(B, g)
    fan = poly([(g, top), (g - hw, bot + 1), (g + hw, bot + 1)]) & bench
    idx[fan & (xx >= g)] = 6                            # right half of the cone turns away from the light
    for y in range(int(top), int(bot) + 1):             # the gully trunk
        t = (y - top) / max(bot - top, 1)
        if t < 0.55 and bench[y, g]:
            idx[y, g] = 7 if t > 0.25 else 4
    for a in (-0.7, -0.35, 0.35, 0.7):                  # sparse fall lines on the fan
        for s in range(2, int(bot - top)):
            x = int(round(g + a * s * hw / max(bot - top, 1) * 1.1)); y = int(round(top + s))
            if 0 <= x < W and bench[y, x] and s % 4 != 3:
                idx[y, x] = 6 if a < 0 else 7
for (g0, _), (g1, _) in zip(GUL[:-1], GUL[1:]):          # spurs between fans, shaded on their right
    xs = (g0 + g1) // 2
    for y in range(int(interp(A, xs)), int(interp(B, xs)) - 3):
        if bench[y, xs]:
            idx[y, xs + 1:xs + 3] = np.where(bench[y, xs + 1:xs + 3], 6, idx[y, xs + 1:xs + 3])

# ---- lower cliff: buttresses of varying width, stepped feet, broken cross-fractures
BUT = [(3, 9), (9, 15), (15, 22), (22, 26), (26, 33), (33, 40), (40, 46), (46, 55), (55, 60), (60, 66),
       (66, 73), (73, 80), (80, 86), (86, 92), (92, 99), (99, 105), (105, 112), (112, 118)]
for xl, xr in BUT:
    sw = int(rng.choice([1, 2, 2, 3, 3]))
    idx[cliff & (xx >= xr - sw) & (xx < xr)] = 3
    idx[cliff & (xx == xr)] = 4
    foot = int(interp(C, (xl + xr) / 2)) + int(rng.integers(-2, 2))
    idx[cliff & (xx >= xl) & (xx <= xr) & (yy > foot)] = 5
    lo, hi = int(interp(B, xl)) + 3, foot - 2
    if hi > lo + 2:
        for y in rng.choice(np.arange(lo, hi), size=2, replace=False):
            x0 = xl + int(rng.integers(0, 2)); x1 = xr - sw - int(rng.integers(0, 2))
            idx[y, x0:x1] = np.where(idx[y, x0:x1] == 1, 2, idx[y, x0:x1])

# ---- apron: a debris fan below each slot, shaded on its right
idx[apron] = 5
for _, xr in BUT:
    top = interp(C, xr) + 1; L = int(rng.integers(8, 14)); hw = L * 0.55
    fan = poly([(xr, top - 1), (xr - hw, top + L), (xr + hw, top + L)]) & apron
    idx[fan & (xx > xr)] = 6
    for s in range(1, L):
        y = int(top + s)
        if s % 3 != 2 and y < H and apron[y, xr]:
            idx[y, xr] = 7

# ---- far plateau: hazed bands, ledge lips only (aerial perspective)
far = land & (xx >= 96)
fade = np.clip((xx - 96) / 20.0, 0, 1)
hz = far & (rng.random((H, W)) < fade)
idx[hz] = 8
for y0, x0, x1 in [(25, 96, 150), (30, 100, 158), (36, 104, 158), (41, 110, 152), (46, 116, 150),
                   (52, 120, 156), (58, 122, 158), (63, 128, 158), (69, 118, 160), (74, 124, 160)]:
    for x in range(x0 + int(rng.integers(0, 4)), x1 - int(rng.integers(0, 6))):
        y = y0 + int(0.02 * (x - x0))
        if y < H and far[y, x] and rng.random() < 0.93:
            idx[y, x] = 9
            if rng.random() < 0.10 and y + 2 < H:
                idx[y + 1:y + 3, x] = 9
out = PAL[idx]
np.save(__file__.replace('.py', '.npy'), idx)
plate = Image.open(__file__.rsplit('/', 2)[0] + '/refs/plates/fig165.png').convert('L')
rows = [[(SH.fit(plate, h=H * 4, smooth=True), 'Imhof Fig.165 (pen)'), (SH.fit(out, 4), 'pixel copy 160x83, 4x')],
        [(SH.fit(out, 1), '1x'), (SH.fit(out, 2), '2x')]]
SH.sheet(rows, 'Copy 4: Fig.165 horizontally bedded rock, pen -> pixel masses (round 3)',
         out=sys.argv[1] if len(sys.argv) > 1 else '(tmp))
Bash: cd ~/work/pixelart-studies/books/imhof; python3 copies/c165_paint.py
cd ~/work/pixelart-studies/books/imhof; python3 copies/c165_paint.py

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 114. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 -c " import sys; sys.path.insert(0,'code') from grid import gridded from PIL import Image im=Image.open('refs/plates/fig165.png'); t
cd ~/work/pixelart-studies/books/imhof; python3 -c "
import sys; sys.path.insert(0,'code')
from grid import gridded
from PIL import Image
im=Image.open('refs/plates/fig165.png'); tw,th=160,83; sx=im.width/tw
gridded(im.crop((0,0,int(90*sx),im.height)),90,83,11).save('(tmp))"

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 115. (tmp)

Write: books/imhof/copies/c165_paint.py
"""Copy 4: Fig.165 (horizontally bedded rock, p.240), pen -> pixel masses, round 4.

Rounds 1-3 failed (a band chart with barcode joints; the strokes transferred one-to-one as graffiti; a
tidier band chart). Looking at the plate at 11 px per target pixel showed what I had not seen: the lower
wall is a row of ROUNDED PILLARS with tombstone tops that bulge up into the bench, and the 'trees' are
gullies: a dark trunk between two pillars that branches upward into the debris of the bench. The cap is
a row of square blocks. So the copy is built from those parts, each placed from the grid (160x83):
pillars (lit left, terminator, shaded right, rounded top lit at its upper left), gully trees, cap blocks,
the bench debris between the tree crowns, apron fans, and the hazed far plateau."""
import sys, numpy as np
from PIL import Image, ImageDraw
sys.path.insert(0, __file__.rsplit('/', 2)[0] + '/code')
import sheet as SH
from pen import Canvas

W, H = 160, 83
PAL = np.array([
    [206, 214, 222],   # 0 sky
    [240, 230, 208],   # 1 lit face
    [214, 202, 182],   # 2 half / crack on lit
    [160, 150, 150],   # 3 shaded face
    [108, 100, 108],   # 4 deep shade / gully
    [224, 208, 180],   # 5 debris lit
    [192, 178, 160],   # 6 debris shade
    [58, 54, 64],      # 7 deepest (gully trunk)
    [226, 224, 220],   # 8 haze lit
    [200, 202, 210],   # 9 haze shade
], np.uint8)
rng = np.random.default_rng(3)
yy, xx = np.mgrid[0:H, 0:W]


def poly(pts):
    im = Image.new('L', (W, H), 0); ImageDraw.Draw(im).polygon([tuple(map(float, p)) for p in pts], fill=1)
    return np.asarray(im).astype(bool)


SIL = [(0, 35), (6, 34), (10, 27), (12, 24), (19, 23), (22, 25), (26, 26), (29, 21), (34, 21), (37, 23),
       (40, 19), (43, 15), (48, 14), (52, 12), (56, 10), (62, 9), (66, 8), (72, 8), (74, 9), (78, 11),
       (84, 13), (87, 16), (89, 15), (94, 15), (96, 19), (101, 21), (107, 22), (113, 24), (123, 25),
       (131, 26), (140, 29), (150, 30), (160, 31), (160, 83), (0, 83)]
land = poly(SIL)
idx = np.where(land, 5, 0).astype(np.uint8)            # base: bench debris everywhere on land

# ---- cap blocks: (x_left, x_right, top, bottom); lit front, shaded right face, dark joint
CAP = [(9, 15, 25, 36), (15, 22, 23, 35), (29, 35, 21, 33), (35, 41, 23, 32), (43, 49, 15, 32),
       (49, 55, 13, 31), (55, 61, 11, 31), (61, 66, 9, 30), (66, 72, 8, 30), (72, 78, 10, 31),
       (78, 84, 12, 31), (84, 89, 15, 33), (89, 95, 16, 35)]
for xl, xr, top, bot in CAP:
    m = land & (xx >= xl) & (xx < xr) & (yy >= top) & (yy < bot + int(rng.integers(-1, 2)))
    idx[m] = 1
    sw = int(rng.choice([1, 2, 2, 3]))
    idx[m & (xx >= xr - sw)] = 3
    idx[m & (xx == xr - 1)] = 4
    for y in (top + int(rng.integers(3, 6)), top + int(rng.integers(8, 12))):   # two bedding cracks
        row = m[y] & (xx[0] < xr - sw - 1) & (xx[0] > xl) if y < H else None
        if row is not None:
            idx[y, row] = 2

# ---- pillars of the lower wall: (x_left, x_right, top of the rounded head)
PIL = [(3, 10, 42), (10, 19, 38), (19, 25, 46), (27, 40, 44), (42, 52, 46), (54, 61, 41), (61, 74, 43),
       (76, 86, 46), (86, 95, 48), (95, 103, 50), (103, 111, 52), (111, 118, 54)]
FOOT = 67
for xl, xr, top in PIL:
    w = xr - xl; cx = (xl + xr) / 2; rad = w / 2
    head = ((xx - cx) / rad) ** 2 + ((yy - (top + rad * 0.8)) / (rad * 0.8)) ** 2 <= 1
    body = (xx >= xl) & (xx < xr) & (yy >= top + rad * 0.8) & (yy < FOOT + int(rng.integers(-1, 2)))
    m = land & (head | body)
    u = (xx - xl) / max(w - 1, 1)                        # across the pillar, 0 = left (lit) edge
    idx[m] = 1
    idx[m & (u > 0.58)] = 2                              # terminator: the turn
    idx[m & (u > 0.72)] = 3                              # shaded side
    idx[m & (u > 0.90)] = 4                              # the core of the shadow against the next gully
    # a vertical crack or two on the lit face (the pen's joint lines)
    for k in range(int(rng.integers(1, 3))):
        x = int(xl + rng.uniform(0.2, 0.5) * w)
        y0 = int(top + rad + rng.integers(2, 6)); y1 = FOOT - int(rng.integers(2, 8))
        for y in range(y0, y1):
            if m[y, x] and idx[y, x] == 1 and (y - y0) % 7 != 6:
                idx[y, x] = 2
    # the rounded head is lit at its upper left (p.261: the lit edge conforms to the shade)
    rim = m & ~np.roll(m, 1, axis=0) & (u < 0.55)
    idx[rim] = 1

# ---- gully trees between pillars: trunk down to the foot, crown branching up into the bench
TREES = [(26, 34), (41, 33), (53, 36), (75, 31), (86, 38)]
C = Canvas(W, H)
for x, top in TREES:
    C.line([(x, top + 4), (x, FOOT)], 3)
    for s, (dx, dy) in enumerate([(-7, -7), (7, -8), (-11, -5), (11, -4), (-4, -10), (5, -11)]):
        y0 = top + 12 + 3 * (s // 2)
        C.line([(x, y0), (x + dx * 0.6, y0 + dy * 0.6), (x + dx, y0 + dy)], 2)
tree = C.a
idx[(tree == 3) & land] = 7
bench_branch = (tree == 2) & land & (idx >= 5)
idx[bench_branch] = 6
lit_side = np.roll(bench_branch, 1, axis=1) & (idx == 6) & ~bench_branch     # each branch: lit on its right
# (branch gullies on the debris: shade line with the debris lit beside it -- already the base tone)

# ---- apron: fans below the gullies, streaks fanning out (light from the left: right halves darker)
for x, _ in TREES + [(10, 0), (19, 0), (61, 0), (95, 0), (103, 0), (111, 0)]:
    L = int(rng.integers(9, 15)); hw = L * 0.7
    fan = poly([(x, FOOT - 1), (x - hw, FOOT + L), (x + hw, FOOT + L)]) & land & (yy >= FOOT)
    idx[fan & (xx > x)] = 6
    for a in (-0.8, -0.3, 0.3, 0.8):
        for s in range(1, L):
            px = int(round(x + a * s * 0.7)); py = FOOT + s
            if 0 <= px < W and py < H and fan[py, px] and s % 4 != 3:
                idx[py, px] = 6 if a < 0 else 4 if s < 3 else 6

# ---- far plateau (right): hazed; lit bands and shaded ledge undersides only
far = land & (xx >= 97) & (yy < 66)
idx[far] = 8
LEDGES = [(25, 97, 150, 2), (31, 100, 158, 1), (37, 104, 158, 2), (42, 112, 152, 1), (47, 118, 150, 2),
          (53, 121, 156, 1), (59, 124, 158, 2), (64, 128, 158, 1)]
for y0, x0, x1, t in LEDGES:
    xs = x0 + int(rng.integers(0, 4)); xe = x1 - int(rng.integers(0, 6))
    for x in range(xs, xe):
        y = y0 + int(0.02 * (x - x0))
        if y + t < H and far[y, x]:
            idx[y:y + t, x] = 9
    for _ in range(int((xe - xs) / 9)):                    # joints notch the band below each ledge
        x = int(rng.integers(xs, xe)); y = y0 + t
        if y + 3 < H and far[y, x]:
            idx[y:y + 3, x] = 9
out = PAL[idx]
np.save(__file__.replace('.py', '.npy'), idx)
plate = Image.open(__file__.rsplit('/', 2)[0] + '/refs/plates/fig165.png').convert('L')
rows = [[(SH.fit(plate, h=H * 4, smooth=True), 'Imhof Fig.165 (pen)'), (SH.fit(out, 4), 'pixel copy 160x83, 4x')],
        [(SH.fit(out, 1), '1x'), (SH.fit(out, 2), '2x')]]
SH.sheet(rows, 'Copy 4: Fig.165 horizontally bedded rock, pen -> pixel masses (round 4: pillars and gully trees)',
         out=sys.argv[1] if len(sys.argv) > 1 else '(tmp))
Bash: cd ~/work/pixelart-studies/books/imhof; python3 copies/c165_paint.py
cd ~/work/pixelart-studies/books/imhof; python3 copies/c165_paint.py

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 116. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 copies/c165_paint.py sheets/copy_165_r4.png; python3 copies/c165_paint_r2.py sheets/copy_165_r2.png 2>/dev/null; ls sheets
cd ~/work/pixelart-studies/books/imhof; python3 copies/c165_paint.py sheets/copy_165_r4.png; python3 copies/c165_paint_r2.py sheets/copy_165_r2.png 2>/dev/null; ls sheets

# output:
_contact_000.png
copy_161.png
copy_165_r2.png
copy_165_r4.png
copy_178_12.png
copy_178_15.png
Bash: cd (tmp); python3 - <<'EOF' import re t=open('caps.xml').read() ids=set(re.findall(r'<ows:Identifier>(ch\.[^<]*)</ows:Identifier>',t)) for i in sorted(ids): if any(k
cd (tmp); python3 - <<'EOF'
import re
t=open('caps.xml').read()
ids=set(re.findall(r'<ows:Identifier>(ch\.[^<]*)</ows:Identifier>',t))
for i in sorted(ids):
    if any(k in i for k in ['hiks','relief','dufour','siegfried','schatt','pk1000','pk500','pk200','hillshade']): print(i)
EOF

# output:
ch.bafu.landesforstinventar-vegetationshoehenmodell_relief
ch.bafu.landesforstinventar-vegetationshoehenmodell_relief_sentinel
ch.bafu.neophyten-carolina_nachtschatten
ch.swisstopo.digitales-hoehenmodell_25_reliefschattierung
ch.swisstopo.hiks-dufour
ch.swisstopo.hiks-siegfried
ch.swisstopo.leichte-basiskarte_reliefschattierung
ch.swisstopo.pixelkarte-farbe-pk1000.noscale
ch.swisstopo.pixelkarte-farbe-pk200.noscale
ch.swisstopo.pixelkarte-farbe-pk500.noscale
ch.swisstopo.pixelkarte-grau-pk1000.noscale
ch.swisstopo.swissalti3d-reliefschattierung
ch.swisstopo.swissalti3d-reliefschattierung_monodirektional
ch.swisstopo.swissaltiregio-reliefschattierung_monodirektional
ch.swisstopo.swissaltiregio-reliefschattierung_multidirektional
ch.swisstopo.swissbathy3d-reliefschattierung
ch.swisstopo.swisssurface3d-reliefschattierung-multidirektional
ch.swisstopo.swisssurface3d-reliefschattierung_monodirektional
Bash: cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF' import re t=open('(tmp)).read() for lay in ['ch.swisstopo.hiks-dufour','ch.swisstopo.pixelkarte-farbe-
cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF'
import re
t=open('(tmp)).read()
for lay in ['ch.swisstopo.hiks-dufour','ch.swisstopo.pixelkarte-farbe-pk500.noscale','ch.swisstopo.digitales-hoehenmodell_25_reliefschattierung']:
    i=t.find('<ows:Identifier>%s</ows:Identifier>'%lay)
    seg=t[i:i+4000]
    print(lay, re.findall(r'<TileMatrixSet>([^<]*)',seg)[:3], re.findall(r'format>([^<]*)',seg)[:2], re.findall(r'Dimension>.*?<Default>([^<]*)',seg,re.S)[:1], re.findall(r'<Value>([^<]*)</Value>',seg)[:6])
EOF

# output:
ch.swisstopo.hiks-dufour ['2056_26'] [] ['current'] ['current', '1949', '1948', '1947', '1946', '1945']
ch.swisstopo.pixelkarte-farbe-pk500.noscale ['2056_26'] [] ['current'] ['current', 'current', '2026', '2025', '2024', '2023']
ch.swisstopo.digitales-hoehenmodell_25_reliefschattierung ['2056_26', '2056_26'] [] ['current'] ['current', 'current']
Bash: cd ~/work/pixelart-studies/books/imhof; for e in png jpeg; do curl -s -o (tmp) -w "$e %{http_code} %{content_type}\n" "https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.h
cd ~/work/pixelart-studies/books/imhof; for e in png jpeg; do curl -s -o (tmp) -w "$e %{http_code} %{content_type}\n" "https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.hiks-dufour/default/current/2056/20/$(( (2710000-2420000)/2560 ))/$(( (1350000-1190000)/2560 )).$e"; done

# output:
png 200 image/png
jpeg 400 application/json
Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/swissfetch.py ch.swisstopo.hiks-dufour 21 2702000 1185000 2716000 1196000 refs/swiss/toedi_dufour_5m.png png && python3 code/sw
cd ~/work/pixelart-studies/books/imhof; python3 code/swissfetch.py ch.swisstopo.hiks-dufour 21 2702000 1185000 2716000 1196000 refs/swiss/toedi_dufour_5m.png png && python3 code/swissfetch.py ch.swisstopo.pixelkarte-farbe-pk500.noscale 18 2690000 1178000 2730000 1206000 refs/swiss/glarus_pk500_50m.png jpeg && python3 -c "
from PIL import Image
a=Image.open('refs/swiss/toedi_dufour_5m.png'); print(a.size); a.crop((600,500,1400,1100)).save('(tmp))"

# output:
(2800, 2200)
Read: (tmp)

(tmp) 117. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 -c " from PIL import Image a=Image.open('refs/swiss/glarus_pk500_50m.png'); print(a.size); a.save('(tmp))"
cd ~/work/pixelart-studies/books/imhof; python3 -c "
from PIL import Image
a=Image.open('refs/swiss/glarus_pk500_50m.png'); print(a.size); a.save('(tmp))"

# output:
(800, 560)
Read: (tmp)

(tmp) 118. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/e1_cone.py sheets/e1_fig33_cone.png && cp sheets/e1_fig33_cone.png (tmp)
cd ~/work/pixelart-studies/books/imhof; python3 code/e1_cone.py sheets/e1_fig33_cone.png && cp sheets/e1_fig33_cone.png (tmp)

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 119. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; cat >> code/hachure.py <<'EOF' def hachure_ink(z, weight, sep=3.0, heavy_side=(0, 1), test=0.6, max_len=40, smooth=1.0, seed=0,
cd ~/work/pixelart-studies/books/imhof; cat >> code/hachure.py <<'EOF'


def hachure_ink(z, weight, sep=3.0, heavy_side=(0, 1), test=0.6, max_len=40, smooth=1.0, seed=0,
                period=6, mask=None, lines=None):
    """Fall-line hachures whose TONE comes from `weight` (0..1, HxW), the pixel form of Lehmann's
    'thickness': along each stroke a dash pattern of `period` px inks round(weight*period) px, starting
    at the stroke's upper end (the top is always emphasized, Fig.178-2); weight > 0.7 adds a second
    pixel on `heavy_side` (row, col offset). Returns (ink HxW uint8: 0 none, 2 stroke, 3 heavy, lines)."""
    rng = np.random.default_rng(seed)
    H, W = z.shape
    if lines is None:
        lines = streamlines(z, sep=sep, test=test, max_len=max_len, smooth=smooth, rng=rng, mask=mask)
    ink = np.zeros((H, W), np.uint8)
    for L in lines:
        # order downhill
        if _bil(z, *L[0]) < _bil(z, *L[-1]):
            L = L[::-1]
        ph = rng.integers(0, period)
        seen = set()
        for i, (r, c) in enumerate(L):
            ri, ci = int(round(r)), int(round(c))
            if (ri, ci) in seen or not (0 <= ri < H and 0 <= ci < W):
                continue
            seen.add((ri, ci))
            w = weight[ri, ci]
            k = len(seen) - 1
            if ((k + ph) % period) < round(w * period) and w > 0.05:
                ink[ri, ci] = max(ink[ri, ci], 2)
                if w > 0.7:
                    r2, c2 = ri + heavy_side[0], ci + heavy_side[1]
                    if 0 <= r2 < H and 0 <= c2 < W:
                        ink[r2, c2] = 3
    return ink, lines
EOF
python3 - <<'EOF'
p='~/work/pixelart-studies/books/imhof/code/e1_cone.py'; s=open(p).read()
s=s.replace("import imhof as I, strokes as S, sheet as SH","import imhof as I, strokes as S, sheet as SH, hachure as HA")
s=s.replace("P.append((ink_on(S.slope_hachures(z, cell, 3.0, 3, w)), '2 slope hachures'))",
"ink2, LINES = HA.hachure_ink(z, w, sep=3.0, max_len=12, mask=I.slope_deg(z, cell) > 3)\nP.append((ink_on(ink2), '2 slope hachures'))")
s=s.replace("P.append((ink_on(S.slope_hachures(z, cell, 3.0, 3, w7)), '7 shadow hachures'))",
"P.append((ink_on(HA.hachure_ink(z, w7, lines=LINES)[0]), '7 shadow hachures'))")
open(p,'w').write(s)
EOF
cd ~/work/pixelart-studies/books/imhof && python3 code/e1_cone.py sheets/e1_fig33_cone.png && cp sheets/e1_fig33_cone.png (tmp)

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 120. (tmp)

Bash: ls ~/work/voxsim-data/places/enchant/ortho | head; ls ~/work/pixelart-studies/elements/rock/refs/Enchantments_Aasgard_Pass_talus | head; ls ~/work/pixelart-studies/elements/rock/re
ls ~/work/voxsim-data/places/enchant/ortho | head; ls ~/work/pixelart-studies/elements/rock/refs/Enchantments_Aasgard_Pass_talus | head; ls ~/work/pixelart-studies/elements/rock/refs/Yakima_River_Canyon_basalt_cliffs | head; ls ~/work/pixelart-studies/elements/rock/refs/Index_Town_Wall_granite | head

# output:
aasgard_pass_1024m.png
colchuck_lake_1024m.png
inspiration_lake_1024m.png
_sheet.jpg
commons_Dragontail_Peak_from_the_way_up_Aasgard_Pass_11__74cb6ee5.jpg
commons_Jabberwocky_Tower_from_the_way_up_Aasgard_Pass_1_ce38588b.jpg
openverse_Aasgard_Pass_with_Goat_46162839.jpg
openverse_Colchuck_Lake_67788308.jpg
openverse_Isolation_Lake_Panorama_739961d3.jpg
openverse_Isolation_Lake_da1a1e68.jpg
openverse_Mountain_Goat_in_Enchantment_2e839b59.jpg
sources.tsv
_sheet.jpg
openverse_Basalt_Formation_in_Umtanum_Canyon_4d904f17.jpg
openverse_Debris_flow_deposits_Holocene_Yakima_River_Canyo_95ca6aa1.jpg
openverse_Heavy_rain_storms_cause_rockslides_to_close_SR_8_bb607d51.jpg
openverse_Umtanum_Recreation_Site_157f95b2.jpg
openverse_Yakima_River_Canyon_4767eaa8.jpg
openverse_Yakima_River_Canyon_92cfff41.jpg
openverse_Yakima_River_Canyon_Map_eb09fcad.jpg
openverse_Yakima_River_Canyon_Scenic_Byway_b438417e.jpg
openverse_Yakima_River_Canyon_at_Milepost_21_74a04f60.jpg
_sheet.jpg
commons_Index_Town_Wall_304761156_jpg_83df755c.jpg
commons_Index_Town_Wall_Trailhead_Index_United_States_jp_8cc09404.jpg
commons_Index_Town_Walls_May_2026_jpg_99c3e722.jpg
commons_S_TN_43_Erattaikoil_Keezhaiyur_Perambalur_jpg_6d5414a0.jpg
commons_Town_wall_Gods_House_to_round_tower_JPG_68d8592c.jpg
openverse_Index_Bush_House_0fdb21a9.jpg
openverse_Index_Town_Wall_f4d05d4c.jpg
openverse_index_town_wall_washington_c1376d39.jpg
openverse_the_index_town_wall_part_ii_0534fed4.jpg
Bash: cd ~/work/pixelart-studies/books/imhof; python3 -c " from PIL import Image a=Image.open('~/work/voxsim-data/places/enchant/ortho/aasgard_pass_1024m.png'); print(a.size, a.mode) a.r
cd ~/work/pixelart-studies/books/imhof; python3 -c "
from PIL import Image
a=Image.open('~/work/voxsim-data/places/enchant/ortho/aasgard_pass_1024m.png'); print(a.size, a.mode)
a.resize((700,700)).save('(tmp))"; grep -i aasgard -A3 ~/work/voxsim-data/places/enchant/pack/../../enchant/*.json 2>/dev/null | head; python3 -c "
import json; d=json.load(open('~/work/voxsim-data/places/enchant/pack/place.json')); print(d.get('spots'))" | head -c 1500

# output:
(1280, 1280) RGB
{'colchuck_lake': [663199.7, 5262238.2], 'aasgard_pass': [664051.4, 5260894.1], 'dragontail': [663273.5, 5260672.2], 'isolation_lake': [664281.7, 5260477.9], 'inspiration_lake': [665636.3, 5261105.6], 'prusik_peak': [666885.7, 5261708.3], 'lake_viviane': [666961.5, 5261165.5], 'snow_lakes': [668899.2, 5261165.4]}
Read: (tmp)

(tmp) 121. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 -c " import dem, numpy as np E,N=664051,5260894 z=dem.wa('enchant',E-512,N-512,E+512,N+512); print(z.shape, np.nanmin(z), np.na
cd ~/work/pixelart-studies/books/imhof/code; python3 -c "
import dem, numpy as np
E,N=664051,5260894
z=dem.wa('enchant',E-512,N-512,E+512,N+512); print(z.shape, np.nanmin(z), np.nanmax(z), np.isnan(z).sum())
np.save('../refs/enchant_aasgard_1m.npy', z.astype(np.float32))
"; ls -la ../refs/

# output:
(1024, 1024) 1877.65 2662.054 0
total 8200
drwxr-xr-x   5 user  staff      160 Sep 26 14:55 .
drwxr-xr-x  10 user  staff      320 Sep 26 14:13 ..
-rw-r--r--   1 user  staff  4194432 Sep 26 14:55 enchant_aasgard_1m.npy
drwxr-xr-x  10 user  staff      320 Sep 26 14:25 plates
drwxr-xr-x  13 user  staff      416 Sep 26 14:50 swiss
Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 -c " import numpy as np, imhof as I, sheet as SH from PIL import Image z=np.load('../refs/enchant_aasgard_1m.npy') zs=z[::2,::2
cd ~/work/pixelart-studies/books/imhof/code; python3 -c "
import numpy as np, imhof as I, sheet as SH
from PIL import Image
z=np.load('../refs/enchant_aasgard_1m.npy')
zs=z[::2,::2]; c=2.0
L=I.lambert(zs,c,292.5,40)
T=I.imhof_tone(zs,c)['t']
T0=I.imhof_tone(zs,c,local_dev=0,big_w=0,ap_low=1)['t']
g=lambda a:(np.clip(a,0,1)*255).astype(np.uint8)
ortho=Image.open('~/work/voxsim-data/places/enchant/ortho/aasgard_pass_1024m.png').resize((512,512),Image.BOX)
rows=[[(SH.fit(ortho,1),'ortho'),(SH.fit(g(L),1),'Lambert 292.5/40'),(SH.fit(g(0.55+0.45*T0),1),'Imhof tone, no local/big/ap'),(SH.fit(g(0.55+0.45*T),1),'Imhof tone full')]]
SH.sheet(rows,'Aasgard 1024 m, 2 m/px: tone layers',out='(tmp))
"

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 122. (tmp)

Write: books/imhof/code/skeleton.py
"""Imhof's form analysis from a DEM (Ch.11 C, p.251): gully lines (negative skeleton), crest lines
(positive skeleton), rock demarcation.

flow_acc(z)            D8 contributing area in cells
gullies(z, cell, ...)  bool mask of gully lines: enough contributing area AND concave at landform scale,
                       thinned, only linked strokes kept ('elongated, always linked', p.252)
crests(z, cell, ...)   bool mask of convex crests ('erratic, broken, interrupted')
rock_mask(z, cell)     steep ground (Imhof's rock is where the slope passes ~35-40 deg)
"""
import numpy as np
from scipy import ndimage as ndi
from skimage.morphology import skeletonize, remove_small_objects

OFF = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]


def flow_acc(z):
    H, W = z.shape
    zp = np.pad(z.astype(np.float64), 1, mode='edge')
    best = np.zeros((H, W)); arg = np.full((H, W), -1)
    for k, (dr, dc) in enumerate(OFF):
        d = (z - zp[1 + dr:1 + dr + H, 1 + dc:1 + dc + W]) / np.hypot(dr, dc)
        m = d > best
        best[m] = d[m]; arg[m] = k
    order = np.argsort(z, axis=None)[::-1]
    acc = np.ones(H * W)
    rr = np.array([o[0] for o in OFF]); cc = np.array([o[1] for o in OFF])
    a = arg.ravel()
    for i in order:
        k = a[i]
        if k < 0:
            continue
        r, c = divmod(i, W)
        r2, c2 = r + rr[k], c + cc[k]
        if 0 <= r2 < H and 0 <= c2 < W:
            acc[r2 * W + c2] += acc[i]
    return acc.reshape(H, W)


def curvature(z, cell, scale_m):
    zs = ndi.gaussian_filter(z.astype(float), scale_m / cell / 2)
    return ndi.laplace(zs) / cell ** 2           # >0 concave (hollow), <0 convex


def gullies(z, cell, area_m2=300.0, scale_m=6.0, conc=0.01, min_len_px=8, acc=None):
    acc = flow_acc(z) if acc is None else acc
    k = curvature(z, cell, scale_m)
    m = (acc * cell * cell >= area_m2) & (k > conc)
    m = skeletonize(m)
    m = remove_small_objects(m, min_len_px, connectivity=2)
    return m, acc


def crests(z, cell, scale_m=6.0, conv=-0.03, min_len_px=6):
    k = curvature(z, cell, scale_m)
    m = skeletonize(k < conv)
    return remove_small_objects(m, min_len_px, connectivity=2)


def rock_mask(z, cell, slope_deg=38.0, smooth_px=1.0):
    zs = ndi.gaussian_filter(z.astype(float), smooth_px)
    gy, gx = np.gradient(zs, cell)
    s = np.degrees(np.arctan(np.hypot(gx, gy)))
    m = s > slope_deg
    m = ndi.binary_opening(m, iterations=1)
    return remove_small_objects(m, 12)
Bash: cd ~/work/pixelart-studies/books/imhof/code; time python3 -c " import numpy as np, skeleton as K z=np.load('../refs/enchant_aasgard_1m.npy')[::2,::2] g,acc=K.gullies(z,2.0); print(
cd ~/work/pixelart-studies/books/imhof/code; time python3 -c "
import numpy as np, skeleton as K
z=np.load('../refs/enchant_aasgard_1m.npy')[::2,::2]
g,acc=K.gullies(z,2.0); print(g.sum(), acc.max())
np.save('(tmp))
"

# output:
10251 68357.0
python3 -c   1.41s user 0.16s system 25% cpu 6.096 total
Write: books/imhof/code/region.py
"""Region-scale (plan / map view) relief painter in Imhof's Swiss style, in pixels.

paint(z, cell, mats=None, **P) -> dict(rgb, idx, layers...)
  z     heightfield (m), rows north->south
  mats  optional uint8 material map: 0 ground, 1 rock, 2 water, 3 snow (default: rock by slope, water by
        flat basins)

Layers (MODEL.md 4, 6.4, 8):
  tone   imhof.imhof_tone: oblique diffuse WNW light over a flat mid tone, local light adjustment,
         vertical angle by slope, large-form emphasis, aerial perspective by elevation
  steps  tone -> 7 ramp steps; ordered dither only in the transitions
  colour ramps per material x haze bin: yellow light, hazy blue-grey shade; rock lit toward pink,
         low ground veiled toward light blue; shading dominates surface colour (p.341)
  rock   skeleton first: gully lines dark with a lit strip on their light side (Fig.178-15), crests
         never outlined; shaded rock faces get fall-line strokes one step darker (rock hachures, only
         where the pitch >= 3 px fits), lit faces stay open (p.262)
"""
import numpy as np
from scipy import ndimage as ndi
import imhof as I, skeleton as K, hachure as HA

# 7-step ramps, index 0 = deepest shade ... 6 = full light (sRGB)
RAMPS = {
    'ground': [(58, 62, 84), (80, 88, 104), (108, 118, 120), (142, 150, 132), (176, 180, 142), (206, 204, 154), (232, 226, 176)],
    'rock':   [(62, 60, 84), (86, 84, 104), (116, 112, 124), (150, 144, 146), (186, 176, 168), (216, 204, 190), (240, 230, 214)],
    'snow':   [(128, 140, 178), (150, 164, 198), (172, 186, 214), (196, 206, 226), (218, 226, 236), (236, 240, 244), (250, 250, 250)],
    'water':  [(24, 44, 58), (30, 54, 68), (36, 64, 78), (44, 74, 88), (54, 86, 98), (66, 98, 108), (80, 110, 118)],
}
HAZE = np.array([196, 210, 226])
MAT_NAMES = ['ground', 'rock', 'water', 'snow']


def palette(nhaze=3, haze_max=0.30):
    """pal[mat, hazebin, step] -> rgb. Haze bin 0 = highest (no veil), last = lowest (veiled, flatter)."""
    pal = np.zeros((4, nhaze, 7, 3))
    for m, name in enumerate(MAT_NAMES):
        r = np.array(RAMPS[name], float)
        for b in range(nhaze):
            a = haze_max * b / max(nhaze - 1, 1)
            mid = r[3]
            rr = mid + (r - mid) * (1 - 0.8 * a)          # contrast falls with depth (p.172)
            pal[m, b] = rr * (1 - a) + HAZE * a
    return np.clip(pal, 0, 255).astype(np.uint8)


def default_mats(z, cell, water_slope=1.0, water_area_m2=1500.0):
    mats = np.zeros(z.shape, np.uint8)
    mats[K.rock_mask(z, cell)] = 1
    s = I.slope_deg(ndi.gaussian_filter(z, 1.0), cell)
    flat = s < water_slope
    lab, n = ndi.label(flat)
    sizes = ndi.sum(flat, lab, range(1, n + 1)) * cell * cell
    big = np.isin(lab, 1 + np.flatnonzero(sizes >= water_area_m2))
    mats[big] = 2
    return mats


def paint(z, cell, mats=None, tone_kw=None, hachures=True, pitch=3.0, gully_area=300.0, seed=0,
          nhaze=3, dither_seam=0.35):
    z = z.astype(float)
    H, W = z.shape
    mats = default_mats(z, cell) if mats is None else mats
    T = I.imhof_tone(z, cell, **(tone_kw or {}))
    t = T['t']
    t = np.where(mats == 2, -0.15, t)                      # water: flat, a little below the land mid tone
    v = I.tone_to_steps(t, 7, mid=3.4)
    steps = I.quantize(np.clip(v, 0, 6), 7, seam=dither_seam)
    h = (z - z.min()) / (z.max() - z.min() + 1e-9)
    hb = np.clip(((1 - h) * nhaze).astype(int), 0, nhaze - 1)
    # ---- rock drawing: skeleton first, then strokes on the shaded faces
    rock = mats == 1
    g, acc = K.gullies(z, cell, area_m2=gully_area)
    g &= rock | ndi.binary_dilation(rock, iterations=2)
    # which side of a gully is lit? the neighbour with the higher tone
    lit_nb = np.zeros_like(g)
    for dr, dc in ((0, 1), (0, -1), (1, 0), (-1, 0)):
        sh = np.roll(np.roll(g, dr, 0), dc, 1)
        brighter = np.roll(np.roll(t, -dr, 0), -dc, 1) < t
        lit_nb |= sh & brighter & ~g
    marks = np.zeros((H, W), np.int8)                      # step offsets
    if hachures and pitch >= 3:
        wt = np.clip((-t + 0.05) / 0.6, 0, 1) * rock      # shaded rock only
        ink, _ = HA.hachure_ink(z, wt, sep=pitch, max_len=30, mask=rock, seed=seed, period=5)
        marks[ink >= 2] = -1
    marks[g] = -2
    steps = np.clip(steps + marks, 0, 6)
    steps[lit_nb & rock] = np.maximum(steps[lit_nb & rock], np.clip(steps[lit_nb & rock] + 1, 0, 6))
    pal = palette(nhaze)
    rgb = pal[np.clip(mats, 0, 3), hb, steps]
    return dict(rgb=rgb, steps=steps, mats=mats, tone=t, gully=g, hb=hb, acc=acc)
Bash: cd ~/work/pixelart-studies/books/imhof/code; time python3 -c " import numpy as np, region as R, sheet as SH, imhof as I from PIL import Image z=np.load('../refs/enchant_aasgard_1m.
cd ~/work/pixelart-studies/books/imhof/code; time python3 -c "
import numpy as np, region as R, sheet as SH, imhof as I
from PIL import Image
z=np.load('../refs/enchant_aasgard_1m.npy')
ortho=Image.open('~/work/voxsim-data/places/enchant/ortho/aasgard_pass_1024m.png')
o=np.asarray(ortho.resize((512,512),Image.BOX)).astype(float)
zs=z[::2,::2]
m=R.default_mats(zs,2.0)
L=o.mean(-1); sat=o.max(-1)-o.min(-1)
m[(L>185)&(sat<40)&(m!=2)]=3
kw=dict(el_steep_shade=45,big_w=0.5,ap_low=0.8)
r=R.paint(zs,2.0,m,tone_kw=kw,hachures=False)
np.save('(tmp))
rows=[[(SH.fit(ortho.resize((512,512),Image.BOX),1),'ortho 2 m/px'),(SH.fit(r['rgb'],1),'Imhof region painter, 2 m/px')]]
SH.sheet(rows,'Aasgard region test v1',out='(tmp))
"

# output:
python3 -c   1.94s user 0.44s system 25% cpu 9.378 total
Read: (tmp)

(tmp) 123. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF' p='region.py'; s=open(p).read() s=s.replace(""" 'ground': [(58, 62, 84), (80, 88, 104), (108, 118, 120), (142, 150
cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF'
p='region.py'; s=open(p).read()
s=s.replace("""    'ground': [(58, 62, 84), (80, 88, 104), (108, 118, 120), (142, 150, 132), (176, 180, 142), (206, 204, 154), (232, 226, 176)],
    'rock':   [(62, 60, 84), (86, 84, 104), (116, 112, 124), (150, 144, 146), (186, 176, 168), (216, 204, 190), (240, 230, 214)],""",
"""    # alpine granite ground (talus, slabs, fresh: rock study L* ~67) and old lichen-dark walls (L* ~46)
    'ground': [(70, 72, 94), (96, 98, 116), (126, 128, 138), (158, 158, 158), (188, 184, 176), (214, 208, 194), (236, 230, 212)],
    'rock':   [(48, 48, 70), (68, 68, 88), (92, 90, 106), (120, 116, 124), (152, 144, 144), (186, 174, 166), (218, 204, 188)],""")
open(p,'w').write(s)
EOF
python3 -c "
import numpy as np, region as R, sheet as SH
from PIL import Image
from scipy import ndimage as ndi
z=np.load('../refs/enchant_aasgard_1m.npy'); zs=z[::2,::2]
ortho=Image.open('~/work/voxsim-data/places/enchant/ortho/aasgard_pass_1024m.png').resize((512,512),Image.BOX)
o=ndi.gaussian_filter(np.asarray(ortho).astype(float).mean(-1),6)
m=R.default_mats(zs,2.0)
gl=(m==2)&(o>150); m[gl]=3        # the flat bright basin is the glacier, not a lake
np.save('../refs/enchant_aasgard_mats2.npy',m)
out=[]
for bw in (0.0,0.3):
  r=R.paint(zs,2.0,m,tone_kw=dict(el_steep_shade=45,big_w=bw,ap_low=0.8),hachures=False)
  out.append((SH.fit(r['rgb'],1),'big_w=%.1f'%bw))
SH.sheet([[(SH.fit(ortho,1),'ortho')]+out],'Aasgard region test v2',out='(tmp))
"

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 124. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF' p='region.py'; s=open(p).read() s=s.replace(" hb = np.clip(((1 - h) * nhaze).astype(int), 0, nhaze - 1)", " hb
cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF'
p='region.py'; s=open(p).read()
s=s.replace("    hb = np.clip(((1 - h) * nhaze).astype(int), 0, nhaze - 1)",
"    hb = I.quantize(np.clip((1 - h) * (nhaze - 1), 0, nhaze - 1), nhaze, seam=0.9)   # dithered: no contour edges")
open(p,'w').write(s)
EOF
python3 -c "
import numpy as np, region as R, sheet as SH, imhof as I
from PIL import Image
from scipy import ndimage as ndi
z=np.load('../refs/enchant_aasgard_1m.npy'); zs=z[::2,::2]
ortho=Image.open('~/work/voxsim-data/places/enchant/ortho/aasgard_pass_1024m.png').resize((512,512),Image.BOX)
o=ndi.gaussian_filter(np.asarray(ortho).astype(float).mean(-1),8)
m=R.default_mats(zs,2.0)
sl=I.slope_deg(ndi.gaussian_filter(zs,3),2.0)
gl=(o>175)&(sl<20)&(m!=2); gl=ndi.binary_opening(gl,iterations=3)
lab,n=ndi.label(gl); sz=ndi.sum(gl,lab,range(1,n+1)); gl=np.isin(lab,1+np.flatnonzero(sz>2000))
m[gl]=3
np.save('../refs/enchant_aasgard_mats2.npy',m)
r0=R.paint(zs,2.0,m,tone_kw=dict(el_steep_shade=45,big_w=0.3,ap_low=0.8),hachures=False)
r1=R.paint(zs,2.0,m,tone_kw=dict(el_steep_shade=45,big_w=0.3,ap_low=0.8),hachures=True,pitch=3)
SH.sheet([[(SH.fit(ortho,1),'ortho'),(SH.fit(r0['rgb'],1),'tone only'),(SH.fit(r1['rgb'],1),'+ rock skeleton + hachures')]],'Aasgard region test v3',out='(tmp))
np.save('(tmp))
"

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 125. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 -c " import numpy as np, sys; sys.path.insert(0,'code'); import sheet as SH from PIL import Image r=np.load('(tmp)
cd ~/work/pixelart-studies/books/imhof; python3 -c "
import numpy as np, sys; sys.path.insert(0,'code'); import sheet as SH
from PIL import Image
r=np.load('(tmp)); o=Image.open('~/work/voxsim-data/places/enchant/ortho/aasgard_pass_1024m.png').resize((512,512),Image.BOX)
c=(slice(20,140),slice(20,200))
SH.sheet([[(SH.fit(np.asarray(o)[c],4),'ortho crop'),(SH.fit(r[c],4),'mine crop 4x')],[(SH.fit(o,1),'ortho'),(SH.fit(r,1),'mine 1x')]],'Aasgard region v3: Dragontail NE face crop',out='sheets/region_aasgard_v3.png')
"; cp sheets/region_aasgard_v3.png (tmp)

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 126. (tmp)

Write: books/imhof/code/rockdraw.py
"""Shaded rock hachuring in pixels (Imhof Ch.11 D.5), plan view, for ~0.5-1.5 m/px.

The lesson of the copies (178-12, 178-15) and of Bietschhorn on the LK25: rock is drawn with strokes on
a light ground, not with soft tone. The tone field only decides the WEIGHT of the strokes:

  - strokes run down the fall line at constant pitch (evenly spaced streamlines);
  - on shaded faces a stroke is long and heavy (2 px on the side away from the light);
  - on lit faces it shrinks to a short tick at its upper end or vanishes ('an extremely light
    distribution of fine hachures', p.262);
  - gully lines (skeleton) are drawn first, dark, each with a scraped lit strip on its light side;
  - crests are never outlined: they appear where the stroke fields of two faces meet.

draw(z, cell, rock, tone, pitch=3.0) -> ink map: 0 none, 1 fine/tick, 2 stroke, 3 heavy, 4 gully,
5 lit strip (scraped)
"""
import numpy as np
from scipy import ndimage as ndi
import hachure as HA, skeleton as K


def draw(z, cell, rock, tone, pitch=3.0, light_rc=(-0.38, -0.92), gully_area=200.0, seed=0,
         lit_cut=0.35, heavy_at=-0.25, tick=2, max_len=24):
    H, W = z.shape
    rng = np.random.default_rng(seed)
    ink = np.zeros((H, W), np.uint8)
    lines = HA.streamlines(z, sep=pitch, test=0.62, max_len=max_len, smooth=0.7, mask=rock, rng=rng,
                           min_len=2.0)
    # heavy side: away from the light, in (row, col) pixel steps
    away = (int(np.sign(-light_rc[0])) if abs(light_rc[0]) > 0.35 else 0,
            int(np.sign(-light_rc[1])) if abs(light_rc[1]) > 0.35 else 0)
    for L in lines:
        if HA._bil(z, *L[0]) < HA._bil(z, *L[-1]):
            L = L[::-1]                                     # top first
        px = []
        for r, c in L:
            p = (int(round(r)), int(round(c)))
            if (not px or px[-1] != p) and 0 <= p[0] < H and 0 <= p[1] < W:
                px.append(p)
        if not px:
            continue
        tt = np.array([tone[p] for p in px])
        tm = float(np.median(tt))
        if tm > lit_cut:
            n = tick if rng.random() < 0.55 else 0          # lit face: a tick under the upper edge, or nothing
        else:
            n = len(px)
        for i, p in enumerate(px[:n]):
            t = tone[p]
            ink[p] = max(ink[p], 2 if t < lit_cut else 1)
            if t < heavy_at and i < 0.8 * n:                 # heavy: thicker toward the top (Fig.178-2)
                q = (p[0] + away[0], p[1] + away[1])
                if 0 <= q[0] < H and 0 <= q[1] < W and rock[q]:
                    ink[q] = 3
    g, _ = K.gullies(z, cell, area_m2=gully_area, scale_m=4.0, conc=0.02, min_len_px=6)
    g &= ndi.binary_dilation(rock, iterations=2)
    # the lit strip: the gully neighbour on the light side is scraped clean
    lr, lc = int(np.round(light_rc[0])), int(np.round(light_rc[1]))
    strip = np.roll(np.roll(g, -lr, 0), -lc, 1) & ~g
    ink[strip] = 5
    ink[g] = 4
    return ink, g
Bash: cd ~/work/pixelart-studies/books/imhof/code; cat > t_rock1m.py <<'EOF' import numpy as np, region as R, sheet as SH, imhof as I, rockdraw as RD, sys from PIL import Image z=np.load
cd ~/work/pixelart-studies/books/imhof/code; cat > t_rock1m.py <<'EOF'
import numpy as np, region as R, sheet as SH, imhof as I, rockdraw as RD, sys
from PIL import Image
z=np.load('../refs/enchant_aasgard_1m.npy')
c=(slice(40,296),slice(40,424))
zc=z[c].astype(float)
kw=dict(el_steep_shade=45,big_w=0.3,ap_low=0.85,landform_m=40)
T=I.imhof_tone(zc,1.0,**kw)['t']
mats=R.default_mats(zc,1.0)
rock=mats==1
ink,g=RD.draw(zc,1.0,rock,T,pitch=3.0)
pal=R.palette(1)
v=I.tone_to_steps(T,7,mid=3.4); st=I.quantize(np.clip(v,0,6),7,seam=0.25)
base=pal[mats,0,st]
# A: rock as strokes on a light ground
stA=st.copy(); stA[rock]=np.where(T[rock]>0.0,6,5)
stA[(ink==1)]=np.maximum(stA[ink==1]-1,0); stA[ink==2]=3; stA[ink==3]=1; stA[ink==4]=0; stA[ink==5]=6
A=pal[mats,0,stA]
# B: rock tone lifted (compressed toward light) + strokes
stB=st.copy(); stB[rock]=np.clip(np.round(3.8+ (st[rock]-3.4)*0.5),0,6).astype(int)
stB[ink==1]-=1; stB[ink==2]-=2; stB[ink==3]-=3; stB[ink==4]=0; stB[ink==5]=np.minimum(stB[ink==5]+2,6)
B=pal[mats,0,np.clip(stB,0,6)]
o=Image.open('~/work/voxsim-data/places/enchant/ortho/aasgard_pass_1024m.png').resize((1024,1024),Image.BOX)
oc=np.asarray(o)[c]
bi=np.asarray(Image.open('../refs/swiss/bietsch_lk25_1m.png').convert('RGB'))[500:756,300:684]
SH.sheet([[(SH.fit(oc,2),'ortho, Aasgard/Dragontail NE, 1 m/px'),(SH.fit(bi,2),'swisstopo LK25, Bietschhorn granite, 1 m/px')],
          [(SH.fit(A,2),'A: rock = strokes on a light ground'),(SH.fit(B,2),'B: rock tone lifted + strokes')],
          [(SH.fit(base,2),'tone only (quantized)')]],'Rock hachuring at 1 m/px, real lidar, 2x',out=sys.argv[1])
np.save('(tmp))
EOF
time python3 t_rock1m.py (tmp)

# output:
python3 t_rock1m.py (tmp)  4.06s user 0.68s system 33% cpu 14.335 total
Read: (tmp)

(tmp) 127. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF' p='rockdraw.py'; s=open(p).read() s=s.replace(" lit_cut=0.35, heavy_at=-0.25, tick=2, max_len=24):", "
cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF'
p='rockdraw.py'; s=open(p).read()
s=s.replace("         lit_cut=0.35, heavy_at=-0.25, tick=2, max_len=24):",
"         lit_cut=0.35, heavy_at=-0.25, tick=2, max_len=24, row_m=None):")
s=s.replace("""        tt = np.array([tone[p] for p in px])""","""        if row_m:
            # Lehmann rows (p.214, and the LK25): cut the stroke where it crosses a row contour, 1 px gap
            zz = np.array([z[p] for p in px]); band = np.floor(zz / row_m)
            cut = np.flatnonzero(np.diff(band) != 0) + 1
            segs = np.split(np.arange(len(px)), cut)
            keep = []
            for sg in segs:
                keep += list(sg[1:] if len(sg) > 2 else sg)
            px = [px[i] for i in keep] if keep else px
        tt = np.array([tone[p] for p in px])""")
open(p,'w').write(s)
EOF
sed -i '' 's/ink,g=RD.draw(zc,1.0,rock,T,pitch=3.0)/ink,g=RD.draw(zc,1.0,rock,T,pitch=3.0,row_m=float(sys.argv[2]) if len(sys.argv)>2 else None)/' t_rock1m.py
python3 t_rock1m.py (tmp) 8

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 128. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 t_rock1m.py ../sheets/rock_1m_aasgard.png 8; cat ~/work/voxsim-data/places/yakima/pack/place.json | python3 -c "import json,sys
cd ~/work/pixelart-studies/books/imhof/code; python3 t_rock1m.py ../sheets/rock_1m_aasgard.png 8; cat ~/work/voxsim-data/places/yakima/pack/place.json | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('spots'))"; python3 -c "import json; d=json.load(open('~/work/voxsim-data/places/skykomish/pack/place.json')); print(d.get('spots'))"; ls ~/work/voxsim-data/places/yakima/ortho ~/work/voxsim-data/places/skykomish/ortho

# output:
{'thrall': [689514.1, 5200227.2], 'reds': [693623.4, 5190748.5], 'roza_dam': [693525.1, 5180545.4]}
{'gold_bar': [597755.3, 5300899.8], 'index': [608208.1, 5297339.3], 'sunset_falls': [608623.4, 5295557.1], 'mount_index': [606332.9, 5292212.7], 'baring': [613996.8, 5291224.3], 'skykomish': [622975.7, 5285292.6], 'stevens_pass': [643233.9, 5289793.5]}
~/work/voxsim-data/places/skykomish/ortho:
index_1024m.png
mount_index_1024m.png
skykomish_1024m.png

~/work/voxsim-data/places/yakima/ortho:
reds_1024m.png
Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 -c " import dem, numpy as np for slug,E,N,name in [('yakima',693623,5190748,'yakima_reds'),('skykomish',606333,5292213,'sky_mti
cd ~/work/pixelart-studies/books/imhof/code; python3 -c "
import dem, numpy as np
for slug,E,N,name in [('yakima',693623,5190748,'yakima_reds'),('skykomish',606333,5292213,'sky_mtindex'),('skykomish',608208,5297339,'sky_index')]:
    z=dem.wa(slug,E-512,N-512,E+512,N+512); print(name, np.nanmin(z), np.nanmax(z), np.isnan(z).sum())
    np.save('../refs/%s_1m.npy'%name, z.astype(np.float32))
"

# output:
yakima_reds 394.2199 601.4714 0
sky_mtindex 921.1276 1829.411 0
sky_index 153.61018 407.54572 0
Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF' p='dem.py'; s=open(p).read() s=s.replace("def _wa_tile(slug, e, n):\n p = f'{PL}/{slug}/pack/elev/{e}_{n}.zst'\n
cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF'
p='dem.py'; s=open(p).read()
s=s.replace("def _wa_tile(slug, e, n):\n    p = f'{PL}/{slug}/pack/elev/{e}_{n}.zst'\n    if not os.path.exists(p): return None\n    raw = zstandard.ZstdDecompressor().decompress(open(p, 'rb').read())\n    return np.frombuffer(raw, np.float32).reshape(1000, 1000)",
"def _wa_tile(slug, e, n, layer='elev', dt=np.float32):\n    p = f'{PL}/{slug}/pack/{layer}/{e}_{n}.zst'\n    if not os.path.exists(p): return None\n    raw = zstandard.ZstdDecompressor().decompress(open(p, 'rb').read())\n    return np.frombuffer(raw, dt).reshape(1000, 1000)")
s=s.replace("def wa(slug, E0, N0, E1, N1):\n    E0, N0, E1, N1 = map(int, (E0, N0, E1, N1))\n    out = np.full((N1 - N0, E1 - E0), np.nan, np.float32)",
"def wa(slug, E0, N0, E1, N1, layer='elev', dt=np.float32):\n    \"\"\"1 m layers: elev (float32), chm (uint8 canopy m), water (uint8), streamda (float32).\"\"\"\n    E0, N0, E1, N1 = map(int, (E0, N0, E1, N1))\n    out = np.full((N1 - N0, E1 - E0), np.nan if dt == np.float32 else 0, dt)")
s=s.replace("            t = _wa_tile(slug, e, n)\n            if t is None: continue\n            # tile rows","            t = _wa_tile(slug, e, n, layer, dt)\n            if t is None: continue\n            # tile rows")
open(p,'w').write(s)
EOF
python3 -c "
import dem, numpy as np
for slug,E,N,name in [('enchant',664051,5260894,'enchant_aasgard'),('yakima',693623,5190748,'yakima_reds'),('skykomish',606333,5292213,'sky_mtindex'),('skykomish',608208,5297339,'sky_index')]:
    c=dem.wa(slug,E-512,N-512,E+512,N+512,'chm',np.uint8); w=dem.wa(slug,E-512,N-512,E+512,N+512,'water',np.uint8)
    print(name,(c>3).mean(), (w>0).mean()); np.save('../refs/%s_chm.npy'%name,c); np.save('../refs/%s_water.npy'%name,w)
"

# output:
enchant_aasgard 0.0 0.08839035034179688
yakima_reds 0.020964622497558594 0.08852005004882812
sky_mtindex 0.04516315460205078 0.0005807876586914062
sky_index 0.7109823226928711 0.038611412048339844
Write: books/imhof/code/place.py
"""Imhof's Swiss style applied to a voxsim place window: plan view and 30 deg iso.

paint_plan(z, cell, water=None, chm=None, snow=None, P) -> dict(rgb, steps, mats, tone, ink)
iso(rgb, z, cell, elev_deg=30, az_view=0) -> rgb of the painted map draped on the terrain, seen from the
    south at 30 deg (orthographic), painter's algorithm column by column.

Rules applied (MODEL.md):

- tone: oblique diffuse light from WNW, flats mid tone, local adjustment, big-form emphasis,
  aerial perspective by elevation (imhof.imhof_tone);
- rock: drawn, not shaded: light ground + fall-line strokes whose weight follows the tone, gully lines
  with lit strips, no crest outlines (rockdraw.draw); only where the stroke pitch (3 px) fits, i.e. the
  rock must be at least ~6 px across, else it falls back to tone (Imhof small-scale rule p.272);
- forest: carries the relief shading (p.332): forest ramp indexed by the same tone steps, crowns as
  single lighter pixels (chm local maxima) only on the lit side;
- water: flat, dark, its shore one step lighter (the lit lip);
- colour: yellow-warm lights, hazy blue-grey shade; haze bins by elevation (dithered).
"""
import numpy as np
from scipy import ndimage as ndi
import imhof as I, rockdraw as RD, region as R

RAMPS = dict(R.RAMPS)
RAMPS['forest'] = [(34, 44, 58), (44, 60, 64), (56, 76, 66), (72, 94, 70), (94, 112, 76), (120, 132, 84), (150, 152, 98)]
RAMPS['meadow'] = [(70, 76, 88), (96, 104, 104), (126, 134, 116), (158, 164, 128), (188, 190, 140), (214, 210, 154), (236, 230, 176)]
NAMES = ['ground', 'rock', 'water', 'snow', 'forest', 'meadow']
HAZE = np.array([196, 210, 226])


def palette(nhaze=3, haze_max=0.30, ramps=None):
    ramps = ramps or RAMPS
    pal = np.zeros((len(NAMES), nhaze, 7, 3))
    for m, name in enumerate(NAMES):
        r = np.array(ramps[name], float)
        for b in range(nhaze):
            a = haze_max * b / max(nhaze - 1, 1)
            rr = r[3] + (r - r[3]) * (1 - 0.8 * a)
            pal[m, b] = rr * (1 - a) + HAZE * a
    return np.clip(pal, 0, 255).astype(np.uint8)


def materials(z, cell, water=None, chm=None, snow=None, rock_slope=38.0, ground='ground'):
    import skeleton as K
    m = np.full(z.shape, NAMES.index(ground), np.uint8)
    if chm is not None:
        f = ndi.binary_opening(chm > 4, iterations=1)
        m[f] = 4
    rock = K.rock_mask(z, cell, rock_slope)
    m[rock & ~(m == 4)] = 1
    if snow is not None:
        m[snow] = 3
    if water is not None:
        m[water > 0] = 2
    return m


def paint_plan(z, cell, water=None, chm=None, snow=None, tone_kw=None, pitch=3.0, row_m=None, seed=0,
               ground='ground', nhaze=3, haze_max=0.3, rock_slope=38.0, ramps=None):
    z = z.astype(float)
    mats = materials(z, cell, water, chm, snow, rock_slope, ground)
    kw = dict(el_steep_shade=45, big_w=0.3, ap_low=0.85, landform_m=max(20.0, 20 * cell))
    kw.update(tone_kw or {})
    T = I.imhof_tone(z, cell, **kw)['t']
    wat = mats == 2
    T = np.where(wat, -0.2, T)
    st = I.quantize(np.clip(I.tone_to_steps(T, 7, mid=3.4), 0, 6), 7, seam=0.3)
    rock = mats == 1
    # rock areas too small to carry strokes fall back to tone
    lab, n = ndi.label(rock)
    if n:
        big = ndi.binary_opening(rock, iterations=2)
        keep = np.isin(lab, np.unique(lab[big]))
    else:
        keep = rock
    ink = np.zeros(z.shape, np.uint8)
    if keep.any():
        ink, _ = RD.draw(z, cell, keep, T, pitch=pitch, row_m=row_m, seed=seed)
        s2 = st.copy()
        s2[keep] = np.where(T[keep] > 0.0, 6, 5)
        s2[keep & (ink == 1)] -= 1
        s2[keep & (ink == 2)] = 3
        s2[keep & (ink == 3)] = 1
        s2[keep & (ink == 4)] = 0
        s2[keep & (ink == 5)] = 6
        st = np.where(keep, s2, st)
    # forest crowns: lit single pixels at canopy maxima facing the light
    if chm is not None:
        f = mats == 4
        c = ndi.gaussian_filter(chm.astype(float), 0.8)
        peak = (c == ndi.maximum_filter(c, 5)) & f & (c > 6)
        st[peak] = np.minimum(st[peak] + 2, 6)
        sh = np.roll(np.roll(peak, 1, 0), 1, 1) & f        # each crown throws a one-pixel shadow SE
        st[sh] = np.maximum(st[sh] - 1, 0)
    # water: lit lip on the shore facing the light
    shore = wat & ~ndi.binary_erosion(wat)
    st[shore] = 4
    h = (z - z.min()) / (z.max() - z.min() + 1e-9)
    hb = I.quantize(np.clip((1 - h) * (nhaze - 1), 0, nhaze - 1), nhaze, seam=0.9)
    pal = palette(nhaze, haze_max, ramps)
    rgb = pal[mats, hb, np.clip(st, 0, 6)]
    return dict(rgb=rgb, steps=st, mats=mats, tone=T, ink=ink)


def iso(rgb, z, cell, elev_deg=30.0, sky=(206, 214, 224), dark=0.0):
    """Orthographic view from the south (bottom of the map) at elev_deg. Each map pixel is a column
    whose screen y = row*sin(e) - height/cell*cos(e); painted far (top rows) to near."""
    H, W = z.shape
    e = np.radians(elev_deg)
    hz = (z - z.min()) / cell
    sy = np.arange(H)[:, None] * np.sin(e) - hz * np.cos(e)
    y0 = int(np.floor(sy.min())); y1 = int(np.ceil(sy.max())) + 2
    out = np.zeros((y1 - y0, W, 3), np.uint8); out[:] = sky
    top = np.full(W, 10 ** 9)
    ys = np.round(sy - y0).astype(int)
    # near rows occlude far rows: paint from far (row 0) to near, filling down to the previous surface
    last = np.full(W, -1)
    for r in range(H):
        y = ys[r]
        for_cols = np.arange(W)
        prev = np.where(last < 0, y, last)
        # fill from y down to the previous row's y (covers steep faces seen from the front)
        lo = np.minimum(y, prev); hi = np.maximum(y, prev)
        for c in range(W):
            out[lo[c]:hi[c] + 1, c] = rgb[r, c]
        last = y
    return out
Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF' p='place.py'; s=open(p).read() s=s.replace(""" lo = np.minimum(y, prev); hi = np.maximum(y, prev) for
cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF'
p='place.py'; s=open(p).read()
s=s.replace("""        lo = np.minimum(y, prev); hi = np.maximum(y, prev)
        for c in range(W):
            out[lo[c]:hi[c] + 1, c] = rgb[r, c]
        last = y""","""        lo = np.minimum(y, prev); hi = np.maximum(y, prev)
        cols = np.arange(W)
        for k in range(int((hi - lo).max()) + 1):
            sel = lo + k <= hi
            out[(lo + k)[sel], cols[sel]] = rgb[r, sel]
        last = y""")
open(p,'w').write(s)
EOF
cat > run_places.py <<'EOF'
"""Apply the method to the four place windows; plan + iso sheets beside the orthos."""
import numpy as np, sys, place as PL, sheet as SH
from PIL import Image
from scipy import ndimage as ndi
ORTHO = '~/work/voxsim-data/places/%s/ortho/%s_1024m.png'
SITES = {
    'enchant_aasgard': ('enchant', 'aasgard_pass', 'ground'),
    'yakima_reds': ('yakima', 'reds', 'meadow'),
    'sky_mtindex': ('skykomish', 'mount_index', 'meadow'),
    'sky_index': ('skykomish', 'index', 'meadow'),
}
names = sys.argv[1:] or list(SITES)
for name in names:
    slug, spot, ground = SITES[name]
    z = np.load(f'../refs/{name}_1m.npy').astype(float)
    chm = np.load(f'../refs/{name}_chm.npy'); wat = np.load(f'../refs/{name}_water.npy')
    ortho = Image.open(ORTHO % (slug, spot)).convert('RGB')
    out = {}
    for cell in (1, 2):
        zs = ndi.zoom(z, 1 / cell, order=1) if cell > 1 else z
        cs = chm[::cell, ::cell]; ws = wat[::cell, ::cell]
        snow = None
        if slug == 'enchant':
            o = ndi.gaussian_filter(np.asarray(ortho.resize(zs.shape[::-1], Image.BOX)).astype(float).mean(-1), 8 / cell)
            from imhof import slope_deg
            sl = slope_deg(ndi.gaussian_filter(zs, 3 / cell), cell)
            snow = ndi.binary_opening((o > 175) & (sl < 20) & (ws == 0), iterations=2)
            lab, n = ndi.label(snow); sz = ndi.sum(snow, lab, range(1, n + 1))
            snow = np.isin(lab, 1 + np.flatnonzero(sz > 2000 / cell ** 2))
        r = PL.paint_plan(zs, float(cell), water=ws, chm=cs if (cs > 4).mean() > 0.001 else None, snow=snow,
                          ground=ground, row_m=8.0 if cell == 1 else None, seed=1)
        out[cell] = r
    # iso of the 2 m map (512 px wide), from the south
    zs = ndi.zoom(z, 0.5, order=1)
    isoimg = PL.iso(out[2]['rgb'], zs, 2.0, 30)
    np.save(f'../refs/{name}_plan1.npy', out[1]['rgb']); np.save(f'../refs/{name}_plan2.npy', out[2]['rgb'])
    np.save(f'../refs/{name}_iso2.npy', isoimg)
    c = (slice(256, 512), slice(256, 640))
    rows = [[(SH.fit(ortho.resize((512, 512), Image.BOX), 1), 'NAIP ortho (2 m/px)'), (SH.fit(out[2]['rgb'], 1), 'Imhof method, plan, 2 m/px')],
            [(SH.fit(np.asarray(ortho.resize((1024, 1024), Image.BOX))[c], 2), 'ortho crop 1 m/px, 2x'), (SH.fit(out[1]['rgb'][c], 2), 'method, 1 m/px, 2x')],
            [(SH.fit(isoimg, 1), '30 deg iso from the south, 2 m/px map draped on the lidar')]]
    SH.sheet(rows, f'{name}: Imhof method on the real 1 m lidar', out=f'../sheets/place_{name}.png')
    print(name, 'done')
EOF
time nice -n 10 python3 run_places.py enchant_aasgard

# output:
Command did not complete within its 120s timeout and was moved to the background (ID: bepfa6r8a). 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/library; directory changes made by the backgrounded command do not apply to subsequent commands.
Write: books/imhof/LESSONS.md
# What Imhof teaches, in pixels

*Notes toward a studio post. The book's model, in its own terms and with page references, is in
[MODEL.md](MODEL.md); my working thread is [NOTEBOOK.md](NOTEBOOK.md). Every sheet named here is in
`sheets/`; every script in `code/` or `copies/`.*

## 0. What I had to work with

- The library's copy of *Cartographic Relief Presentation* (1982 English edition) is a 1-bit scan.
  The line figures survived perfectly: Imhof's pen studies of rock types (Figs 159-175), the good and
  poor rock hachures (Fig.178), hachure systems, contours. **Every halftone and every colour plate is
  gone**, thresholded into blotches: the shading figures (118-120, 130-136) and the colour plates can't
  be seen at all. So for tone and colour I worked from Imhof's text, which is unusually exact, and
  checked the look against swisstopo's maps (the LK25 and LK50 rock drawing, the 1:500k relief, the
  Dufour map), which descend directly from his school. `code/swissfetch.py` fetches them and
  `code/dem.py` fetches the 2 m swissALTI3D DEM under them.
- I OCR'd all 405 pages (`ocr/ALL.txt`, and a copy for the library in `library/text/`) and read the
  whole book, looking at every figure page as I went.

## 1. The model in one paragraph

(From MODEL.md 10.) Terrain is shown by an imagined oblique, **diffuse** light from the west-north-west,
**adjusted locally** so that no important form is expressionless, over a **flat mid tone** that lets lit
slopes rise and shaded slopes fall; contrast grows with height (aerial perspective); **the large forms
are stated first** and the small ones subordinated inside them; **the form lives in the transitions**,
never in outlines. **Rock is drawn by its structure**: first the demarcation, gully and crest skeleton,
then strokes that follow the rock (fall line on steep walls, horizontals on ledges), whose weight
follows the same light, every shadow matched by a light strip, edges made by crisp stroke starts, the
whole light and transparent. Every scale is a new drawing. Colour is light and atmosphere: yellow in the
sun, hazy blue-grey in shade, warmer high, bluer low, and the shading dominates the surface colours.

## 2. Translations, one principle at a time

### 2.1 Fig.33: oblique shading is the only direct form (sheet `e1_fig33_cone.png`)

Imhof's eight renderings of one cone in a ring groove (p.81-83), redone at 96×96 px. The halftone plate
is lost, but the text says what each panel shows, and in pixels every one of his verdicts holds: slope
shading gives the cone and the groove one tone; contours can't say cone or crater; "higher darker" is a
pipe and "higher lighter" a tunnel; shadow hachures and combined shading make the lit wall of the groove
*darker* than the flat beside it (his "shadow reversal"). Only oblique shading over a mid-grey flat shows
the form without a key. The Lambert panel (Wiechel's 1878 formula, which is today's GIS hillshade) is
the oblique panel with no diffuse light, and its shadow side goes nearly black: Imhof's objection to
fixed-formula shading, visible at 3x.

*Carries directly.* The pixel rule: **flat ground is the middle step of the ramp, not the top one.** Lit
slopes rise above it, shaded slopes fall below it. Every studio ramp that puts flat ground at the top
step throws away half the light.

### 2.2 Tone: `imhof.imhof_tone`

Each of his lighting rules became one parameter:

| Imhof (page) | code |
|---|---|
| flats get a medium grey (p.171) | tone 0 = flat; lit and shaded are ± departures |
| diffuse light, "softness and transparency of shadows" (p.168) | `diffuse` share of sky light |
| light from W/WNW, like late afternoon (p.183) | `az=292.5` |
| never parallel to the main ridges | choose `az` per scene |
| local light adjustment ≤ 30° (p.174-175) | `local_light_az`: the landform's facing (read at `landform_m`) pushes the light away from grazing, same on neighbouring parallel slopes |
| vertical angle: < 20° on gentle ground, > 45° on steep shaded slopes, flatter on steep lit ones (p.177) | `el_flat`, `el_steep_shade`, `el_steep_lit` |
| lighten local shadows on the light side of a major watershed, darken the shadow side with a weak overall shadow (p.187) | `big_w`, `big_m` |
| sharpen contrast toward the summits (p.172) | `ap_low` |
| Leonardo: the half and quarter shadows carry the form (p.185) | `quantize`: flat inside a step, ordered dither only in a narrow seam at step boundaries |

On the Aasgard lidar (sheet `region_aasgard_v3.png`) the Imhof tone against plain Lambert does what the
book says: the NE face of Dragontail stays one dark mass but its gullies still read inside it; the shade
never goes black; the basin floor sits at the mid tone. **What did not carry as tone:** the same tone
at 1 m/px, quantised to 7 steps, becomes camouflage — blotchy islands of step 4 and 5 all over the gentle
ground. The fix was not in the tone at all; see 2.4.

### 2.3 Master copies of the rock drawing (sheets `copy_178_12.png`, `copy_178_15.png`, `copy_161.png`, `copy_165_r4.png`)

**Fig.178-12, steep scale-like buttresses** ("ridge crests are not drawn in"). My first copy placed
feathered barbs on hand-drawn spines: a row of combs — rigid, too regular, crests reading as outlines of
strokes. The copy that works came from doing what Imhof tells students to do (p.273): I placed only the
gully lines by hand, let them imply a surface, and drew strokes down that surface's fall line at a
constant pitch, weighted by the surface's light. The strokes then end against each other where they
converge into a gully — the feathering in the plate — and no crest is ever drawn. Measured on the plate:
stroke pitch 13 source px = **2.2-2.9 px at the size I copy**, median stroke 5 px, ink share 0.29; mine
0.36. What it doesn't have yet: his strokes are rough and slightly irregular; mine are clean.

**Fig.178-15, a slab wall channelled by gullies** ("a light medium tone of horizontal form lines; each
gully a crisp illuminated side paralleled by a well defined shadow"). The horizontal form lines are the
contours of the implied surface, broken into dashes at his pitch (~4 px); the gully is a two-pixel shadow
line with the pixel on its light side scraped clean. The chevrons where the form lines kink down into
each gully came for free from the geometry. This is the most transferable pixel mark in the book:
**a gully = dark line + one lit pixel beside it, never a dark line alone** (178-17 is the failure).

**Fig.161, granite** (masses and needles). A pen drawing doesn't copy into pixels stroke for stroke:
reduced photographically (his own Fig.192 failure) it is a tangle; transferred stroke by stroke it is
graffiti. It carries when translated into **values**: I read the masses off the pen's density (lit
fronts open, right flanks in shade), kept only his long flutes, a step darker on lit faces and a crevice
step in shade, each crevice with a lit pixel on its light side, contrast falling toward the foot of the
wall (his aerial perspective, p.263), a garlanded foot where snow tongues reach up the gullies (p.247)
and lit needle edges against the sky. At 1x it reads as a granite range; at 4x the flutes are still a
little mechanical.

**Fig.165, horizontally bedded rock** (the Yakima case). Four rounds. (1) A band chart with barcode
joints. (2) His strokes transferred one-to-one: pen graffiti. (3) A tidier band chart. Then I looked at
the plate at 11 screen px per pixel and saw what I'd missed: the lower wall is a row of **rounded pillars
with tombstone tops** bulging up into the bench, and the "trees" are gullies whose dark trunks branch
upward into the bench debris. (4) Built from those parts, it finally reads as his drawing. It's still the
weakest copy: the bench between the gully trees is too uniform, and the far plateau is too pale.

**What the copies taught, which I could not have got from the text:**

1. Rock in the Swiss style is *strokes on a light ground*. The rock is lighter than you think, and its
   darkness comes from the density of the strokes on the shaded faces.
2. Pitch: 3 px is the finest that reads; 4 px for form lines. At 2 px the strokes merge into tone.
3. The skeleton (gully lines) is the only thing placed by hand; everything else follows from it.
4. A pen drawing is translated through its values; the line itself doesn't survive reduction.

### 2.4 Rock at region scale on real lidar (sheet `rock_1m_aasgard.png`)

`code/rockdraw.py`: evenly spaced fall-line streamlines (Jobard-Lefer) on the rock mask, pitch 3 px; on
shaded faces long strokes, a second pixel on the side away from the light where the face is darkest; on
lit faces a two-pixel tick under the upper edge or nothing ("an extremely light distribution of fine
hachures", p.262); gully lines from flow accumulation × concavity drawn first, with a scraped lit strip;
no crest lines. Option `row_m` breaks the strokes into Lehmann's rows (p.214), as on the LK25.

Beside the swisstopo LK25 Bietschhorn at the same 1 m/px, variant A (strokes on a light ground) is the
one that looks like the Swiss drawing: the NE face of Dragontail becomes a field of feathered gullies,
dark by stroke density, and the lit slabs above stay open. Variant B (tone underneath plus strokes)
turns back into shading with texture on it. Tone only at 1 m/px is the camouflage mentioned above.

## 3. Tests where it matters

(filled in below as the place runs complete)

## 4. What did not carry, and why

- **Hachures as the only tone for everything (Lehmann/Dufour).** A pixel stroke has one weight, maybe
  two; the book's tone-by-stroke-width needs many. Fall-line hachures work for rock in pixels, not for
  meadow and forest, where tone does the job better.
- **Contours.** At 1-2 px/m a contour interval that reads (≥ 4 px apart) is 8-10 m on steep ground and
  vanishes on gentle ground; as a picture element they only work as form lines on rock (178-15).
- **Omitting cast shadows, reflected light and highlights** is a map rule for legibility (p.171-172).
  Our pictures keep them where a painting would (his own Walensee painting deepens cast shadows).
- **The halftone plates.** Not seen; everything about the look of his shading comes from his text and
  from swisstopo.
Bash: cat (tmp) ls -la ~/work/pixelart-studies/books/imhof/sheets/ | tail -3
cat (tmp) ls -la ~/work/pixelart-studies/books/imhof/sheets/ | tail -3

# output:
enchant_aasgard done
nice -n 10 python3 run_places.py enchant_aasgard  21.38s user 1.47s system 13% cpu 2:48.69 total
-rw-r--r--   1 user  staff  1276883 Sep 26 15:08 place_enchant_aasgard.png
-rw-r--r--   1 user  staff   915184 Sep 26 15:01 region_aasgard_v3.png
-rw-r--r--   1 user  staff   623928 Sep 26 15:04 rock_1m_aasgard.png
Read: books/imhof/sheets/place_enchant_aasgard.png

books/imhof/sheets/place_enchant_aasgard.png 129. books/imhof/sheets/place_enchant_aasgard.png

Bash: cd ~/work/pixelart-studies/books/imhof/code; sed -i '' "s/ c = (slice(256, 512), slice(256, 640))/ c = CROPS.get(name, (slice(256, 512), slice(256, 640)))/; s/^names = sys.ar
cd ~/work/pixelart-studies/books/imhof/code; sed -i '' "s/    c = (slice(256, 512), slice(256, 640))/    c = CROPS.get(name, (slice(256, 512), slice(256, 640)))/; s/^names = sys.argv\[1:\] or list(SITES)/CROPS = {'enchant_aasgard': (slice(60, 316), slice(40, 424))}\nnames = sys.argv[1:] or list(SITES)/" run_places.py; nohup nice -n 10 python3 run_places.py yakima_reds sky_mtindex sky_index enchant_aasgard > (tmp) 2>&1 &
echo started

# output:
started
Agent: Cold critic of Imhof copies
Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF' p='place.py'; s=open(p).read() s=s.replace(" ground='ground', nhaze=3, haze_max=0.3, rock_slope=38.0, r
cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF'
p='place.py'; s=open(p).read()
s=s.replace("               ground='ground', nhaze=3, haze_max=0.3, rock_slope=38.0, ramps=None):",
"               ground='ground', nhaze=3, haze_max=0.3, rock_slope=38.0, ramps=None, slab_interval=None):")
s=s.replace("    # forest crowns: lit single pixels","""    # glacier-polished bare rock (roches moutonnees, Fig.188, p.271): 'the lightest, smoothest horizontal
    # hachures possible' -- broken form lines one step down, only where they fall >= 3 px apart
    if slab_interval:
        import strokes as S
        bare = (mats == NAMES.index('ground'))
        sl = I.slope_deg(ndi.gaussian_filter(z, 1.0), cell)
        spacing = slab_interval / np.maximum(np.tan(np.radians(sl)), 1e-3) / cell
        ok = bare & (spacing >= 3.0) & (sl > 4)
        rng = np.random.default_rng(seed + 7)
        for lev, poly in S.contour_lines(ndi.gaussian_filter(z, 0.8), slab_interval):
            pts = S.resample(poly, 0.7)
            dash = int(rng.integers(5, 12)); gap = int(rng.integers(2, 5)); k0 = int(rng.integers(0, dash + gap))
            for i, (r, c) in enumerate(pts):
                ri, ci = int(r), int(c)
                if ((i + k0) % (dash + gap)) < dash and ok[ri, ci]:
                    st[ri, ci] = max(st[ri, ci] - 1, 0)
    # forest crowns: lit single pixels""")
open(p,'w').write(s)
EOF
sed -i '' "s/ground=ground, row_m=8.0 if cell == 1 else None, seed=1)/ground=ground, row_m=8.0 if cell == 1 else None, seed=1, slab_interval=(2.0 * cell if slug == 'enchant' else None))/" run_places.py; grep -n slab run_places.py

# output:
32:                          ground=ground, row_m=8.0 if cell == 1 else None, seed=1, slab_interval=(2.0 * cell if slug == 'enchant' else None))
Bash: cat (tmp)
cat (tmp)

# output:
(Bash completed with no output)
Bash: pkill -f run_places.py; cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF' p='rockdraw.py'; s=open(p).read() s=s.replace(""" if t < heavy_at and i < 0.8 * n:
pkill -f run_places.py; cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF'
p='rockdraw.py'; s=open(p).read()
s=s.replace("""            if t < heavy_at and i < 0.8 * n:                 # heavy: thicker toward the top (Fig.178-2)
                q = (p[0] + away[0], p[1] + away[1])""","""            if t < heavy_at and i < 0.8 * n:                 # heavy: thicker toward the top (Fig.178-2)
                j = min(i + 1, len(px) - 1); j0 = max(i - 1, 0)
                dr, dc = px[j][0] - px[j0][0], px[j][1] - px[j0][1]
                nr, nc = -dc, dr                              # a normal to the stroke
                if nr * light_rc[0] + nc * light_rc[1] > 0:  # pick the side facing away from the light
                    nr, nc = -nr, -nc
                q = (p[0] + int(np.sign(nr)), p[1] + int(np.sign(nc)))""")
s=s.replace("    g &= ndi.binary_dilation(rock, iterations=2)","    g &= rock")
open(p,'w').write(s)
EOF
python3 t_rock1m.py ../sheets/rock_1m_aasgard.png 10 && echo ok

# output:
ok
Bash: cd ~/work/pixelart-studies/books/imhof; python3 -c " from PIL import Image im=Image.open('sheets/rock_1m_aasgard.png'); print(im.size); im.crop((0,590,1580,1140)).save('/tmp/claude
cd ~/work/pixelart-studies/books/imhof; python3 -c "
from PIL import Image
im=Image.open('sheets/rock_1m_aasgard.png'); print(im.size); im.crop((0,590,1580,1140)).save('(tmp))"

# output:
(1578, 1706)
Read: (tmp)

(tmp) 130. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 -c " import numpy as np; from PIL import Image A=np.load('(tmp)); print(A.shape) Image.fromarray(A[150:230,20:130])
cd ~/work/pixelart-studies/books/imhof; python3 -c "
import numpy as np; from PIL import Image
A=np.load('(tmp)); print(A.shape)
Image.fromarray(A[150:230,20:130]).resize((660,480),Image.NEAREST).save('(tmp))"

# output:
(256, 384, 3)
Read: (tmp)

(tmp) 131. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 -c " import numpy as np, imhof as I, hachure as HA, region as R from PIL import Image z=np.load('../refs/enchant_aasgard_1m.npy
cd ~/work/pixelart-studies/books/imhof/code; python3 -c "
import numpy as np, imhof as I, hachure as HA, region as R
from PIL import Image
z=np.load('../refs/enchant_aasgard_1m.npy')[40:296,40:424].astype(float)
rock=R.default_mats(z,1.0)==1
sub=(slice(150,230),slice(20,130))
L=HA.streamlines(z[sub],sep=3.0,mask=rock[sub],max_len=24,smooth=0.7)
img=np.full(z[sub].shape,255,np.uint8)
for l in L:
  for r,c in l: img[int(round(r)),int(round(c))]=0
ur,uc,_=HA.field(z[sub],0.7)
print('mean downhill dir row,col', ur[rock[sub]].mean(), uc[rock[sub]].mean(), len(L))
Image.fromarray(img).resize((660,480),Image.NEAREST).save('(tmp))
"

# output:
mean downhill dir row,col -0.3033344917262404 0.681437618987594 178
Read: (tmp)

(tmp) 132. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof/code; sed -i '' 's/ink,g=RD.draw(zc,1.0,rock,T,pitch=3.0,row_m=float(sys.argv\[2\]) if len(sys.argv)>2 else None)/ink,g=RD.draw(zc,1.0,rock,T
cd ~/work/pixelart-studies/books/imhof/code; sed -i '' 's/ink,g=RD.draw(zc,1.0,rock,T,pitch=3.0,row_m=float(sys.argv\[2\]) if len(sys.argv)>2 else None)/ink,g=RD.draw(zc,1.0,rock,T,pitch=float(sys.argv[3]) if len(sys.argv)>3 else 3.0,row_m=float(sys.argv[2]) if len(sys.argv)>2 else None,heavy_at=float(sys.argv[4]) if len(sys.argv)>4 else -0.25)/' t_rock1m.py; python3 t_rock1m.py (tmp) 8 4 -0.55; python3 -c "
import numpy as np; from PIL import Image
A=np.load('(tmp))
Image.fromarray(A[100:256,0:260]).resize((780,468),Image.NEAREST).save('(tmp))"

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 133. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 t_rock1m.py (tmp) 8 4 -2; python3 -c " import numpy as np; from PIL import Image A=np.load('(tmp)
cd ~/work/pixelart-studies/books/imhof/code; python3 t_rock1m.py (tmp) 8 4 -2; python3 -c "
import numpy as np; from PIL import Image
A=np.load('(tmp))
Image.fromarray(A[100:256,0:260]).resize((780,468),Image.NEAREST).save('(tmp))"

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 134. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF' p='rockdraw.py'; s=open(p).read() i=s.index(" if t < heavy_at and i < 0.8 * n:") j=s.index(" g, _ = K.g
cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF'
p='rockdraw.py'; s=open(p).read()
i=s.index("            if t < heavy_at and i < 0.8 * n:")
j=s.index("    g, _ = K.gullies")
s=s[:i]+"""            if t < heavy_at:
                ink[p] = 3                                     # darkest faces: the dark ink, still 1 px wide
                                                               # (a 2nd pixel reads as '=' bars at pitch 4)
"""+s[j:]
s=s.replace("def draw(z, cell, rock, tone, pitch=3.0,","def draw(z, cell, rock, tone, pitch=4.0,").replace("lit_cut=0.35, heavy_at=-0.25,","lit_cut=0.35, heavy_at=-0.35,")
open(p,'w').write(s)
EOF
sed -i '' 's/ink, _ = RD.draw(z, cell, keep, T, pitch=pitch, row_m=row_m, seed=seed)/ink, _ = RD.draw(z, cell, keep, T, pitch=pitch, row_m=row_m, seed=seed)/; s/def paint_plan(z, cell, water=None, chm=None, snow=None, tone_kw=None, pitch=3.0,/def paint_plan(z, cell, water=None, chm=None, snow=None, tone_kw=None, pitch=4.0,/' place.py
python3 t_rock1m.py ../sheets/rock_1m_aasgard.png 8 4 -0.35; python3 -c "
import numpy as np; from PIL import Image
A=np.load('(tmp))
Image.fromarray(A[100:256,0:260]).resize((780,468),Image.NEAREST).save('(tmp))"

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 135. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof/code; nohup nice -n 10 python3 run_places.py enchant_aasgard yakima_reds sky_mtindex sky_index > (tmp) 2>&1 & echo ok
cd ~/work/pixelart-studies/books/imhof/code; nohup nice -n 10 python3 run_places.py enchant_aasgard yakima_reds sky_mtindex sky_index > (tmp) 2>&1 & echo ok

# output:
ok
Bash: cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF' p='copies/c178_12b.py'; s=open(p).read() s=s.replace("z = -1.2 * yy","z = -1.2 * yy + 0.42 * xx # the wall falls down an
cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF'
p='copies/c178_12b.py'; s=open(p).read()
s=s.replace("z = -1.2 * yy","z = -1.2 * yy + 0.42 * xx   # the wall falls down and to the LEFT (plate strokes lean ~110 deg)")
s=s.replace("""for g in GULLIES:
    C.line(g, 3)""","""for g in GULLIES:                  # only the lower half of each gully is a dark line (its deep outlet);
    n = len(g)                     # upward it dissolves into the converging strokes (critic: spines = herringbone)
    C.line(g[:max(2, (n + 1) // 2)], 3)""")
open(p,'w').write(s)
EOF
python3 copies/c178_12b.py && python3 code/cmp_copy.py refs/plates/fig178_12.png copies/c178_12b.npy (tmp) "Copy 1 v5" 5

# output:
203 strokes
ink share plate 0.289  mine 0.362
Read: (tmp)

(tmp) 136. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; sed -i '' 's/z = -1.2 \* yy + 0.42 \* xx/z = -1.2 * yy + 0.22 * xx/' copies/c178_12b.py; python3 copies/c178_12b.py 7 6 && python3 code/cmp_
cd ~/work/pixelart-studies/books/imhof; sed -i '' 's/z = -1.2 \* yy + 0.42 \* xx/z = -1.2 * yy + 0.22 * xx/' copies/c178_12b.py; python3 copies/c178_12b.py 7 6 && python3 code/cmp_copy.py refs/plates/fig178_12.png copies/c178_12b.npy (tmp) "Copy 1 v6" 5

# output:
240 strokes
ink share plate 0.289  mine 0.352
Read: (tmp)

(tmp) 137. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/cmp_copy.py refs/plates/fig178_12.png copies/c178_12b.npy sheets/copy_178_12.png "Copy 1: Fig.178-12 steep scale-like buttresse
cd ~/work/pixelart-studies/books/imhof; python3 code/cmp_copy.py refs/plates/fig178_12.png copies/c178_12b.npy sheets/copy_178_12.png "Copy 1: Fig.178-12 steep scale-like buttresses (p.260) - skeleton by hand, fall-line strokes (partial: still herringbone)" 5 >/dev/null; cat (tmp)

# output:
(Bash completed with no output)
Bash: cd ~/work/pixelart-studies/books/imhof; sed -i '' 's/ z -= 5.0 \* np.clip(1 - d \/ GW, 0, None) \*\* 0.7/ z -= 9.0 * np.clip(1 - d \/ GW, 0, None) ** 0.7/; s/^GW = 5.0/GW = 6
cd ~/work/pixelart-studies/books/imhof; sed -i '' 's/    z -= 5.0 \* np.clip(1 - d \/ GW, 0, None) \*\* 0.7/    z -= 9.0 * np.clip(1 - d \/ GW, 0, None) ** 0.7/; s/^GW = 5.0/GW = 6.0/' copies/c178_15.py; python3 - <<'EOF'
p='~/work/pixelart-studies/books/imhof/copies/c178_15.py'; s=open(p).read()
s=s.replace("""    C.line([tuple(p + np.array([2.0, 0])) for p in pts[1:-1]], 2)""","""    C.line([tuple(p + np.array([2.0, 0])) for p in pts[:-1]], 3)          # a 2-3 px shadow wedge (critic)
    C.line([tuple(p + np.array([3.0, 0])) for p in pts[1:-1]], 2)""")
open(p,'w').write(s)
EOF
python3 copies/c178_15.py && python3 code/cmp_copy.py refs/plates/fig178_15.png copies/c178_15.npy (tmp) "Copy 2 v3" 5

# output:
ink share plate 0.226  mine 0.337
Read: (tmp)

(tmp) 138. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; python3 code/cmp_copy.py refs/plates/fig178_15.png copies/c178_15.npy sheets/copy_178_15.png "Copy 2: Fig.178-15 slab wall with gullies (p.2
cd ~/work/pixelart-studies/books/imhof; python3 code/cmp_copy.py refs/plates/fig178_15.png copies/c178_15.npy sheets/copy_178_15.png "Copy 2: Fig.178-15 slab wall with gullies (p.260): form lines = contours of the implied surface, chevrons into each gully" 5 >/dev/null; cat (tmp)

# output:
(Bash completed with no output)
ToolSearch: select:Monitor
Monitor: place runs progress
Bash: cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF' p='copies/c165_paint.py'; s=open(p).read() s=s.replace("idx[(tree == 3) & land] = 7","idx[(tree == 3) & land & (yy < FOOT
cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF'
p='copies/c165_paint.py'; s=open(p).read()
s=s.replace("idx[(tree == 3) & land] = 7","idx[(tree == 3) & land & (yy < FOOT - 8)] = 4           # gully: a notched recess, not a pole (critic)")
s=s.replace("# ---- apron: fans below","""# ---- bedding back into the pillars (critic: 'the horizontal bedding is the subject'): at each bed a lit
# lip row with its overhang shadow under it, broken where a joint cuts; slightly irregular per pillar
for yb in (52, 58, 63):
    for xl, xr, top in PIL:
        if top + 6 > yb:
            continue
        y = yb + int(rng.integers(-1, 2))
        for x in range(xl + int(rng.integers(0, 2)), xr - int(rng.integers(0, 2))):
            if land[y, x] and idx[y, x] in (1, 2, 3, 4) and rng.random() < 0.9:
                idx[y + 1, x] = 4 if idx[y + 1, x] in (3, 4) else 3
                if idx[y, x] in (2, 3):
                    idx[y, x] = 2
# ---- apron: fans below""")
open(p,'w').write(s)
EOF
python3 copies/c165_paint.py (tmp)

# output:
(Bash completed with no output)
Read: (tmp)

(tmp) 139. (tmp)

Bash: cd ~/work/pixelart-studies/books/imhof; cp (tmp) sheets/copy_165_r5.png; echo ok
cd ~/work/pixelart-studies/books/imhof; cp (tmp) sheets/copy_165_r5.png; echo ok

# output:
ok
Write: books/imhof/code/README.md
# Imhof method: code

All Python 3 + numpy/scipy/scikit-image/PIL; one core; a 1024x1024 window takes ~1-3 min (the
streamline placement is pure Python).

| file | what |
|---|---|
| `imhof.py` | tone: `imhof_tone(z, cell, **P)` (oblique diffuse light over a flat mid tone, local light adjustment <= 30 deg, vertical angle by slope, large-form emphasis, aerial perspective by elevation); baselines `lambert`, `slope_shading`; `quantize` (flat steps, dither only in the transitions); `tone_to_steps` (flat = middle step) |
| `hachure.py` | `streamlines` (evenly spaced fall-line strokes, Jobard-Lefer); `hachure_ink` (Lehmann-style dashed hachures weighted by a tone field) |
| `skeleton.py` | Imhof's form analysis: `flow_acc`, `gullies` (area x concavity, thinned, linked), `crests`, `rock_mask` |
| `rockdraw.py` | shaded rock hachuring: `draw(z, cell, rock, tone, pitch=4, row_m=None)` -> ink classes (tick / stroke / dark stroke / gully / scraped lit strip) |
| `place.py` | the whole Swiss style on a voxsim window: `paint_plan(z, cell, water, chm, snow, ...)`; `iso(rgb, z, cell, 30)` drapes the painted map on the terrain seen from the south |
| `region.py` | the earlier tone-only region painter (ramps, haze palette) |
| `dem.py` | `wa(slug, E0, N0, E1, N1, layer)` reads the voxsim place packs (1 m elev, chm, water); `swiss(...)` swissALTI3D 2 m |
| `swissfetch.py` | swisstopo WMTS mosaics (LK25/50/100/500, Dufour, relief) for reference |
| `run_places.py` | the tests on the four place windows -> `sheets/place_*.png` |
| `e1_cone.py`, `t_rock1m.py` | exercises -> sheets |
| `sheet.py`, `grid.py`, `pen.py`, `vectorize.py`, `cmp_copy.py`, `measure_ink.py` | tools for the master copies |

Parameters that matter (defaults in `imhof.DEFAULT`, `place.paint_plan`):

- `az` 292.5 (WNW). Turn it so it crosses the main ridge direction at an acute angle.
- `el_flat` 25, `el_steep_shade` 45-55, `el_steep_lit` 30.
- `local_dev` 30 (0 turns local adjustment off), `landform_m` 20-60 (scale of 'the landform').
- `big_w` 0.3, `big_m` 250: large-form emphasis. `ap_low` 0.8-0.85: contrast at the lowest point.
- `pitch` 4 px for rock strokes at 1 m/px (3 is the finest that reads; 2 turns into tone).
- `row_m` 8-10: Lehmann rows (a 1-px break each time a stroke crosses a row contour).
- `rock_slope` 38 deg: what counts as rock when no geology is given.
- `slab_interval` 2 m: glacier-polished slabs get the lightest broken form lines (Fig.188).
Bash: cat (tmp) du -sh ~/work/pixelart-studies/books/imhof
cat (tmp) du -sh ~/work/pixelart-studies/books/imhof

# output:
199M    ~/work/pixelart-studies/books/imhof
Bash: cd ~/work/pixelart-studies/books/imhof; cat >> LESSONS.md <<'EOF' ## 5. The rules, for students and lanes Each rule is Imhof's, with its page; the pixel form is mine. 1. **Flat
cd ~/work/pixelart-studies/books/imhof; cat >> LESSONS.md <<'EOF'

## 5. The rules, for students and lanes

Each rule is Imhof's, with its page; the pixel form is mine.

1. **Flat ground is the middle step** (p.171). Lit slopes step up from it, shaded slopes step down.
   Never put flat ground at the top of the ramp.
2. **Light from the WNW, diffuse, and bent locally** (p.168, 174-177, 183): one main azimuth crossing the
   main ridges at an acute angle; turn it up to 30° per landform so no important slope is lit
   side-on; steep shaded slopes get a higher light (they must not go black), steep lit slopes a flatter
   one (the steepest lit face is the lightest).
3. **Large forms first** (p.187): the light side of a major ridge gets its small shadows lifted, the
   shadow side a weak overall shadow; then the small forms inside.
4. **Contrast grows with height** (p.172, 263): full range on the summits, compressed in the valleys.
   In an oblique view real distance does this; in plan or iso use elevation.
5. **The form is in the transition** (p.170, 262): sharp ridge = the tone jumps across one pixel;
   rounded form = a dithered seam. Planes are flat steps. No outlines on ridges, ever.
6. **Rock is drawn, not shaded** (p.259-263): light ground; strokes down the fall line at a constant
   pitch (4 px at 1 m/px, 3 at the finest); dense and long on shaded faces, a two-pixel tick or nothing
   on lit ones; dark strokes only on the darkest faces, still 1 px wide.
7. **Skeleton first** (p.251-252, 273): gully lines placed before any stroke; each gully is a dark line
   with a lit pixel on its light side (178-15), never a dark line alone (178-17). Crests appear where
   two stroke fields meet.
8. **Rock by its structure** (Figs 160-175): granite = long flutes following the joints and needle
   silhouettes; horizontally bedded rock (CRB) = cliff bands of pillars/columns between ledge lines,
   debris benches and fans below each band; polished slabs = the lightest broken horizontal form lines
   (Fig.188).
9. **Small rock falls back to tone** (p.272): if a rock patch can't hold three strokes across, it's a
   shadow tone, not strokes.
10. **Shadows are hazy blue-grey, lights warm; shading beats surface colour** (p.190, 340-341): ground
    cover tints but must not break the relief; forest carries the same shading steps as the ground
    under it (p.332).
11. **Every scale is a new drawing** (p.188, 272): never box-reduce a detailed drawing; redraw with fewer,
    stronger marks that keep the character (sharp alpine ridges stay sharp).
12. **Economy is uneven** (critic, and p.262): dense where the form turns away from the light, empty where
    it's lit. Even spacing and even widths everywhere read as a machine.
EOF
echo ok

# output:
ok
[This command modified 1 file you've previously read: ../books/imhof/LESSONS.md. Call Read before editing.]

Waiting for the place runs.

Bash: until grep -q "sky_index done\|Traceback" (tmp) do sleep 10; done; cat (tmp)
until grep -q "sky_index done\|Traceback" (tmp) do sleep 10; done; cat (tmp)

# output:
Command running in background with ID: bgf8b4psf. Output is being written to: (tmp) You will be notified when it completes. To check interim output, use Read on that file path.
TaskStop:
Bash: ls -la ~/work/pixelart-studies/books/imhof/sheets/place_*; ps aux | grep run_places | grep -v grep | head -2
ls -la ~/work/pixelart-studies/books/imhof/sheets/place_*; ps aux | grep run_places | grep -v grep | head -2

# output:
-rw-r--r--  1 user  staff  1291545 Sep 26 15:19 ~/work/pixelart-studies/books/imhof/sheets/place_enchant_aasgard.png
-rw-r--r--  1 user  staff   995379 Sep 26 15:20 ~/work/pixelart-studies/books/imhof/sheets/place_yakima_reds.png
user      8372  25.7  2.1 410620800 776640   ??  RN    3:17PM   0:38.14 python3 run_places.py enchant_aasgard yakima_reds sky_mtindex sky_index

Log is buffered; sheets are arriving. Look at Yakima.

Read: books/imhof/sheets/place_yakima_reds.png

books/imhof/sheets/place_yakima_reds.png 140. books/imhof/sheets/place_yakima_reds.png

Bash: cd ~/work/pixelart-studies/books/imhof/code; cat >> rockdraw.py <<'EOF' def draw_bedded(z, cell, rock, tone, bed_m=6.0, pitch=4.0, light_rc=(-0.38, -0.92), seed=0, **kw): """
cd ~/work/pixelart-studies/books/imhof/code; cat >> rockdraw.py <<'EOF'


def draw_bedded(z, cell, rock, tone, bed_m=6.0, pitch=4.0, light_rc=(-0.38, -0.92), seed=0, **kw):
    """Horizontally bedded rock (Columbia River basalt; Imhof Fig.165, p.240, and Fig.178-9): the rock
    wall is a stack of ledges. Each bed contour becomes a ledge: a dark line (the overhang shadow) with a
    lit lip on its upslope side; between ledges short fall-line strokes (the columns / joints), dense on
    shaded faces, sparse on lit ones. Returns the same ink classes as draw()."""
    import strokes as S
    H, W = z.shape
    rng = np.random.default_rng(seed)
    ink, g = draw(z, cell, rock, tone, pitch=pitch, light_rc=light_rc, seed=seed, row_m=bed_m, **kw)
    ink[ink == 4] = 2                                # gullies are notches here, not long dark lines
    zs = ndi.gaussian_filter(z, 0.8)
    for lev, poly in S.contour_lines(zs, bed_m, offset=rng.uniform(0, bed_m)):
        pts = S.resample(poly, 0.6)
        for r, c in pts:
            ri, ci = int(round(r)), int(round(c))
            if 0 <= ri < H - 1 and 0 <= ci < W and rock[ri, ci] and rng.random() < 0.93:
                ink[ri, ci] = 3                        # the ledge shadow
                # lip: the upslope neighbour is lit
                gy, gx = np.gradient(zs[max(ri - 1, 0):ri + 2, max(ci - 1, 0):ci + 2])
                up = (int(np.sign(gy.mean())), int(np.sign(gx.mean())))
                q = (ri + up[0], ci + up[1])
                if 0 <= q[0] < H and 0 <= q[1] < W and rock[q] and ink[q] != 3:
                    ink[q] = 5
    return ink, g
EOF
python3 - <<'EOF'
p='place.py'; s=open(p).read()
s=s.replace("RAMPS['meadow'] =","RAMPS['steppe'] = [(70, 64, 76), (100, 90, 92), (134, 118, 104), (164, 144, 112), (192, 170, 124), (214, 192, 140), (234, 214, 164)]\nRAMPS['meadow'] =")
s=s.replace("NAMES = ['ground', 'rock', 'water', 'snow', 'forest', 'meadow']","NAMES = ['ground', 'rock', 'water', 'snow', 'forest', 'meadow', 'steppe']")
s=s.replace("slab_interval=None):","slab_interval=None, rock_style='strokes', bed_m=6.0):")
s=s.replace("        ink, _ = RD.draw(z, cell, keep, T, pitch=pitch, row_m=row_m, seed=seed)",
"""        if rock_style == 'bedded':
            ink, _ = RD.draw_bedded(z, cell, keep, T, bed_m=bed_m, pitch=pitch, seed=seed)
        else:
            ink, _ = RD.draw(z, cell, keep, T, pitch=pitch, row_m=row_m, seed=seed)""")
open(p,'w').write(s)
p='run_places.py'; s=open(p).read()
s=s.replace("'yakima_reds': ('yakima', 'reds', 'meadow'),","'yakima_reds': ('yakima', 'reds', 'steppe'),")
s=s.replace("CROPS = {'enchant_aasgard': (slice(60, 316), slice(40, 424))}","CROPS = {'enchant_aasgard': (slice(60, 316), slice(40, 424)), 'yakima_reds': (slice(250, 506), slice(0, 384))}")
s=s.replace("slab_interval=(2.0 * cell if slug == 'enchant' else None))","slab_interval=(2.0 * cell if slug == 'enchant' else None),\n                          rock_style=('bedded' if slug == 'yakima' else 'strokes'), bed_m=6.0,\n                          tone_kw=(dict(big_w=0.45, ap_low=0.7) if slug == 'yakima' else None))")
open(p,'w').write(s)
EOF
grep -n "rock_style" run_places.py place.py | head

# output:
run_places.py:33:                          rock_style=('bedded' if slug == 'yakima' else 'strokes'), bed_m=6.0,
place.py:58:               ground='ground', nhaze=3, haze_max=0.3, rock_slope=38.0, ramps=None, slab_interval=None, rock_style='strokes', bed_m=6.0):
place.py:77:        if rock_style == 'bedded':
Bash: while pgrep -f run_places.py >/dev/null; do sleep 5; done; cd ~/work/pixelart-studies/books/imhof/code && nice -n 10 python3 run_places.py yakima_reds 2>&1 | tail -3
while pgrep -f run_places.py >/dev/null; do sleep 5; done; cd ~/work/pixelart-studies/books/imhof/code && nice -n 10 python3 run_places.py yakima_reds 2>&1 | tail -3

# output:
yakima_reds done
Read: books/imhof/sheets/place_yakima_reds.png

books/imhof/sheets/place_yakima_reds.png 141. books/imhof/sheets/place_yakima_reds.png

Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF' p='place.py'; s=open(p).read() s=s.replace(""" s2 = st.copy() s2[keep] = np.where(T[keep] > 0.0, 6, 5)
cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF'
p='place.py'; s=open(p).read()
s=s.replace("""        s2 = st.copy()
        s2[keep] = np.where(T[keep] > 0.0, 6, 5)""","""        s2 = st.copy()
        if rock_style != 'bedded':                         # granite: strokes on a light ground
            s2[keep] = np.where(T[keep] > 0.0, 6, 5)
        else:                                              # basalt is dark rock: keep the tone, lift it a step
            s2[keep] = np.clip(st[keep] + 1, 0, 6)""")
s=s.replace("""        s2[keep & (ink == 2)] = 3""","""        s2[keep & (ink == 2)] = 3 if rock_style != 'bedded' else np.maximum(s2[keep & (ink == 2)] - 2, 0)""")
s=s.replace("""        s2[keep & (ink == 5)] = 6""","""        s2[keep & (ink == 5)] = 6 if rock_style != 'bedded' else np.minimum(s2[keep & (ink == 5)] + 1, 6)""")
open(p,'w').write(s)
EOF
sed -i '' "s/rock_style=('bedded' if slug == 'yakima' else 'strokes'), bed_m=6.0,/rock_style=('bedded' if slug == 'yakima' else 'strokes'), bed_m=9.0,/" run_places.py
nice -n 10 python3 run_places.py yakima_reds && python3 -c "
from PIL import Image; im=Image.open('../sheets/place_yakima_reds.png'); im.crop((780,590,1578,1140)).save('(tmp))"

# output:
yakima_reds done
Read: (tmp)

(tmp) 142. (tmp)

Read: books/imhof/sheets/place_sky_mtindex.png

books/imhof/sheets/place_sky_mtindex.png 143. books/imhof/sheets/place_sky_mtindex.png

Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF' p='place.py'; s=open(p).read() s=s.replace(""" if rock_style != 'bedded': # granite: s
cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF'
p='place.py'; s=open(p).read()
s=s.replace("""        if rock_style != 'bedded':                         # granite: strokes on a light ground
            s2[keep] = np.where(T[keep] > 0.0, 6, 5)
        else:                                              # basalt is dark rock: keep the tone, lift it a step
            s2[keep] = np.clip(st[keep] + 1, 0, 6)""","""        # rock ground = the relief tone lifted one step (Imhof p.340: rock drawing WITH shading tones; a pure
        # light ground loses the large forms on big rock mountains -- Mt Index test), strokes 2 steps down
        s2[keep] = np.clip(st[keep] + 1, 0, 6)""")
s=s.replace("""        s2[keep & (ink == 2)] = 3 if rock_style != 'bedded' else np.maximum(s2[keep & (ink == 2)] - 2, 0)""","""        s2[keep & (ink == 2)] = np.maximum(s2[keep & (ink == 2)] - 2, 0)""")
s=s.replace("""        s2[keep & (ink == 3)] = 1""","""        s2[keep & (ink == 3)] = np.maximum(s2[keep & (ink == 3)] - 3, 0)""")
s=s.replace("""        s2[keep & (ink == 5)] = 6 if rock_style != 'bedded' else np.minimum(s2[keep & (ink == 5)] + 1, 6)""","""        s2[keep & (ink == 5)] = np.minimum(s2[keep & (ink == 5)] + 1, 6)""")
s=s.replace("RAMPS['meadow'] = [(70, 76, 88), (96, 104, 104), (126, 134, 116), (158, 164, 128), (188, 190, 140), (214, 210, 154), (236, 230, 176)]",
"RAMPS['meadow'] = [(62, 70, 84), (86, 98, 96), (112, 124, 104), (140, 148, 112), (168, 170, 124), (196, 192, 142), (222, 214, 166)]")
open(p,'w').write(s)
EOF
grep -n "ink == 1" place.py; nohup nice -n 10 python3 run_places.py > (tmp) 2>&1 &

# output:
85:        s2[keep & (ink == 1)] -= 1
Bash: while pgrep -f run_places.py >/dev/null; do sleep 10; done; cat (tmp)
while pgrep -f run_places.py >/dev/null; do sleep 10; done; cat (tmp)

# output:
enchant_aasgard done
yakima_reds done
sky_mtindex done
sky_index done
Read: books/imhof/sheets/place_sky_mtindex.png

books/imhof/sheets/place_sky_mtindex.png 144. books/imhof/sheets/place_sky_mtindex.png

Read: books/imhof/sheets/place_enchant_aasgard.png

books/imhof/sheets/place_enchant_aasgard.png 145. books/imhof/sheets/place_enchant_aasgard.png

Bash: cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF' p='place.py'; s=open(p).read() s=s.replace(" s2[keep] = np.clip(st[keep] + 1, 0, 6)",""" s2[keep] = np.
cd ~/work/pixelart-studies/books/imhof/code; python3 - <<'EOF'
p='place.py'; s=open(p).read()
s=s.replace("        s2[keep] = np.clip(st[keep] + 1, 0, 6)","""        s2[keep] = np.clip(np.round(4.2 + (st[keep] - 3.4) * 0.55), 0, 6).astype(st.dtype)
        # compressed range (about steps 2.5-5): the large form survives, and a stroke 2-3 steps down still
        # reads on the shaded side (full-range base made dark strokes on dark ground: mud)""")
open(p,'w').write(s)
EOF
nohup nice -n 10 python3 run_places.py > (tmp) 2>&1 & echo ok

# output:
ok
Bash: cd ~/work/pixelart-studies/books/imhof/code; cat > lanes_sheet.py <<'EOF' """Before | after: the place lanes' current renders beside the Imhof method on the same place (iso).""" im
cd ~/work/pixelart-studies/books/imhof/code; cat > lanes_sheet.py <<'EOF'
"""Before | after: the place lanes' current renders beside the Imhof method on the same place (iso)."""
import numpy as np, sheet as SH
from PIL import Image
W = '~/work/voxsim-wt/'
PAIRS = [
    ('enchant_aasgard', W + 'place-enchant/renders/enchant/i2/u_region_iso.png', 'Enchantments (Aasgard, Dragontail NE face)'),
    ('sky_mtindex', W + 'place-skykomish/renders/sky/i4u/i4_mt_index_far.png', 'Skykomish (Mount Index)'),
    ('yakima_reds', W + 'place-yakima/renders/y/p46/y2_hero_cliffband_eve.png', 'Yakima Canyon (Red\'s, basalt rimrock)'),
]
rows = []
for name, lane, lab in PAIRS:
    iso = np.load(f'../refs/{name}_iso2.npy')
    rows.append([(SH.fit(Image.open(lane), h=300, smooth=True), 'BEFORE: place lane render (its own framing) - ' + lab),
                 (SH.fit(iso, h=300), 'AFTER: Imhof method, 2 m/px map draped at 30 deg iso')])
SH.sheet(rows, 'The place lanes now | the Imhof method on the same lidar',
         'Framings differ (the lane cameras are not reproduced); compare how rock, light and large forms are stated.',
         out='../sheets/lanes_before_after.png')
EOF
echo ok

# output:
ok
Bash: while pgrep -f run_places.py >/dev/null; do sleep 10; done; cat (tmp) cd ~/work/pixelart-studies/books/imhof/code && python3 lanes_sheet.py && python3 -c " fr
while pgrep -f run_places.py >/dev/null; do sleep 10; done; cat (tmp) cd ~/work/pixelart-studies/books/imhof/code && python3 lanes_sheet.py && python3 -c "
from PIL import Image; im=Image.open('../sheets/place_enchant_aasgard.png'); im.crop((780,590,1578,1140)).save('(tmp))"

# output:
enchant_aasgard done
yakima_reds done
sky_mtindex done
sky_index done
Read: (tmp)

(tmp) 146. (tmp)

Read: books/imhof/sheets/lanes_before_after.png

books/imhof/sheets/lanes_before_after.png 147. books/imhof/sheets/lanes_before_after.png

Agent: Cold critic of place tests

Meanwhile, update LESSONS with the copies' critic verdicts and section 3.

Read: books/imhof/LESSONS.md

Now rewrite sections 2.3–3 to reflect what actually happened, including the critic.

Bash: cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF' p='LESSONS.md'; s=open(p).read() a=s.index("### 2.3 Master copies"); b=s.index("## 4. What did not carry") new='''### 2.3
cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF'
p='LESSONS.md'; s=open(p).read()
a=s.index("### 2.3 Master copies"); b=s.index("## 4. What did not carry")
new='''### 2.3 Master copies of the rock drawing (sheets `copy_178_12.png`, `copy_178_15.png`, `copy_161.png`, `copy_165_r4.png`, `copy_165_r5.png`)

I copied four of his drawings at pixel size, placing the structure by hand from a gridded plate
(`code/grid.py`), and then asked a fresh critic to judge them cold against the plates. The critic's
ranking was 161 > 178-15 > (the lidar test) > 178-12 > 165, and its main charge ran through all of them:
**everything evenly spaced, the same width, axis-aligned; Imhof's economy is uneven, dense where the
form turns, empty where it is lit.** That is the most useful sentence anyone said about this work.

**Fig.178-12, steep scale-like buttresses** ("ridge crests are not drawn in"). Copy 1 put feathered barbs
on hand-drawn spines: combs. Copy 2 did what Imhof tells students to do (p.273): only the gully lines
by hand, a surface implied by them, strokes down its fall line at constant pitch weighted by its light.
The strokes end against each other where they converge into a gully and no crest is drawn. Measured on
the plate: stroke pitch 13 source px = 2.2-2.9 px at copy size, median stroke 5 px, ink share 0.29
(mine 0.35). **The critic's verdict holds, and it's still partial:** the strokes meet the gullies from
below as barbs, so it reads as herringbone; in the plate they arrive at an acute angle from above and
fan with each buttress. I tilted the surface and shortened the dark gully floors, and it's better but
not right. The plate's structure (which way the scales face) is the thing I still haven't read correctly.

**Fig.178-15, a slab wall channelled by gullies** ("a light medium tone of horizontal form lines; each
gully a crisp illuminated side paralleled by a well defined shadow"). The form lines are the contours of
the implied surface, broken into dashes at his pitch (~4 px); each gully is a 2-3 px shadow wedge with
the pixel on its light side scraped clean. After the critic said the form lines crossed the gullies dead
straight, I deepened the cuts until the lines hook down into every gully (chevrons). Still wrong: the
gully lines are ruler-straight and the dashes too regular. The mark itself is the most transferable in
the book: **a gully = dark line + one lit pixel beside it, never a dark line alone** (178-17 is the
failure).

**Fig.161, granite** (masses and needles). A pen drawing doesn't copy into pixels stroke for stroke:
reduced photographically (his own Fig.192 failure) it's a tangle, and transferred stroke by stroke it's
graffiti. It carries when translated into **values**: masses read off the pen's density (lit fronts,
right flanks in shade), his long flutes kept, one step darker on lit faces and a crevice step in shade,
each crevice with a lit pixel on its light side, contrast falling toward the foot (p.263), a garlanded
foot where snow tongues reach up the gullies (p.247). The critic called it the only copy that works as
pixel art, and named its faults: the shadow flutes are a picket fence of equal bands (his converge,
split and taper), the skyline has gone soft (his gendarmes and notches are smooth cones in mine), and the
flat outlined foreground ridge is another style.

**Fig.165, horizontally bedded rock** (the Yakima case). Five rounds, and the weakest copy. (1) A band chart
with barcode joints. (2) His strokes transferred one-to-one: pen graffiti. (3) A tidier band chart.
(4) Looking at the plate at 11 screen px per pixel showed the lower wall is **rounded pillars with
tombstone tops**, and the "trees" are gullies branching up into the bench. The critic: it now reads as a
fence, the horizontal bedding (the subject!) had become vertical pillars, and the gully "trees" read as
posts. (5) I put the ledge lines back across the pillars and turned the trunks into recesses; better, but
the vertical rhythm still dominates. The honest lesson: **I kept drawing the joints I could see, and lost
the beds that are the structure.** Imhof's text says it outright (Fig.165: "banded, stepped slopes").

**What the copies taught, which I couldn't have got from the text:**

1. In the Swiss style the darkness of rock comes from the density of strokes on the shaded faces; the
   ground under them is lighter than you expect.
2. Pitch: 3 px is the finest that reads, 4 px for form lines; at 2 px strokes merge into tone.
3. Only the skeleton (gully lines) is placed by hand; everything else can follow from it.
4. A pen drawing is translated through its values; the line itself doesn't survive reduction.
5. Uneven economy is the style. Anything evenly spaced reads as a machine.

### 2.4 Rock at region scale on real lidar (sheet `rock_1m_aasgard.png`)

`code/rockdraw.py`: evenly spaced fall-line streamlines (Jobard-Lefer) on the rock mask, pitch 4 px; on
shaded faces long strokes, the dark ink where the face is darkest; on lit faces a two-pixel tick under the
upper edge or nothing ("an extremely light distribution of fine hachures", p.262); gully lines from flow
accumulation × concavity drawn first, each with a scraped lit strip; no crest lines. `row_m` breaks the
strokes into Lehmann's rows (p.214), as on the LK25.

Three things I got wrong on the way, all visible in the sheets' history:

- **Pitch 3 with 2-px heavy strokes read as "=" bars.** On a face whose fall line runs nearly E-W in plan
  (Dragontail's NE face, measured: downhill vector (row −0.30, col +0.68)) the second pixel stacks under
  the first. The strokes had to stay 1 px wide; weight is carried by length and by the darker ink.
- **Strokes outside the rock** (gully lines on meadow) read as hair. Mask them to rock (critic).
- **Light ground vs large forms.** Strokes on a pure light ground look most like the LK25 at 1 m/px, but on
  a whole rock mountain (Mount Index) they erase the big dark flank: Imhof's large-form rule loses to his
  rock-hachure rule. Strokes over the full-range tone turned to mud (dark on dark). What works is the
  tone **compressed** to steps ~2.5-5 under the rock, strokes 2-3 steps below it: the big flank stays dark
  and the strokes still read. This is his p.340 combination ("shaded skeletal outlines or rock hachures
  with corresponding shading tones"), found by failing both ways.

## 3. Tests where it matters (sheets `place_*.png`, `lanes_before_after.png`)

`code/run_places.py` runs `place.paint_plan` on four 1024 m windows of the voxsim place packs (1 m lidar,
canopy height, water), at 1 and 2 m/px, and drapes the 2 m map on the lidar at 30° iso.

- **Enchantments, Aasgard / Dragontail NE face.** At 2 m/px the plan reads as a Swiss relief map: one dark
  NE flank carved by feathered gullies, lit slabs above, the glacier and tarns flat. At 1 m/px the face is a
  field of fall-line strokes gathered into gullies. Weak: the gentle granite ground between (camouflage
  blotches of two tone steps plus the slab form lines), which in the photo is a mottle of snow and slab.
- **Skykomish, Mount Index.** The strongest test: the NE walls are one dark stroked mass, the SW slopes lit
  meadow and rock; the 30° iso gives a convincing peak with rock bands on its flanks. Weak: the meadow is
  one flat yellow-green; the real mountain is darker and more wooded (the canopy layer finds only 4.5 %
  trees there).
- **Yakima Canyon, Red's.** Steppe colours and relief read; the basalt rimrock uses the bedded variant
  (`rockdraw.draw_bedded`: 9 m ledge lines with overhang shadows, lit lips, short column strokes). It reads
  as dark stepped rock at 1 m/px, but at 2 m/px and in iso the rimrock is too small to state the canyon's
  banding; the lane's render states it more strongly with plain black bands. Fig.165 is the right model and
  I haven't carried it well yet (see the copy).
- **Skykomish, Index (valley).** Forest dominates (71 % canopy); forest carries the relief shading steps with
  lit crowns, as Imhof requires (p.332); the Town Wall granite reads as a stroked wall.

Before | after against the lane renders is only roughly comparable (different cameras), but the difference
in how rock and big forms are stated is clear: the Enchantments lane render is a flat-lit white field;
the method gives the peaks a lit and a shaded side and draws the walls.

'''
s=s[:a]+new+s[b:]
open(p,'w').write(s)
EOF
echo ok

# output:
ok
[This command modified 1 file you've previously read: ../books/imhof/LESSONS.md. Call Read before editing.]
Bash: cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF' p='LESSONS.md'; s=open(p).read() s=s.replace("## 4. What did not carry, and why","""### The cold critic on the place tests
cd ~/work/pixelart-studies/books/imhof; python3 - <<'EOF'
p='LESSONS.md'; s=open(p).read()
s=s.replace("## 4. What did not carry, and why","""### The cold critic on the place tests (verbatim in substance)

A fresh critic judged the four place sheets and the before|after sheet against the orthos. Its verdict,
which I accept:

- **Large forms and light: right, and better than the lanes** for Enchantments (the lane render has "no
  light direction and no forms") and Mount Index (the lane shows "a forest carpet and no mountain"). The
  Enchantments iso was its strongest frame.
- **Rock: not yet drawn rock.** "A slope-keyed hatch texture laid over a hillshade": even parallel strokes
  in bands ("corduroy"), no length hierarchy, weak convergence into gullies, **no light/shadow break at the
  crests**, stray ticks on lit slopes. Imhof's crest rule is that the crest is not *outlined* but is made by
  the sharp change from light to shadow (p.262); my tone at the crest is too soft for that change to show.
- **Yakima: the lane render is better for the rock.** My rimrock turns into ladder stitches; the lane's plain
  dark bands say "rimrock canyon". Relief is anaemic in the steppe and at Index (the valley window reads as a
  posterized hillshade: weakest sheet).
- Missing content: Mount Index's snowfields and tarns (my snow mask exists only for the Enchantments), the
  Index gravel bars and town; the rock mask comes from slope alone, and on Mount Index it may be following the
  shadow side more than the real rock.

## 4. What did not carry, and why""")
s+='''
## 6. Where it stands, and what I'd do next

**Carried faithfully:** the tone model (flat = mid step, diffuse WNW light, local adjustment, large-form
emphasis, aerial perspective by elevation, Leonardo's transitions as dither seams); the skeleton-first order
and the gully mark (dark line + lit pixel); fall-line strokes gathered into gullies at a legible pitch; the
compressed tone under rock strokes; forest carrying the shading; the colour recipe (warm lights, hazy
blue-grey shade). The Mount Index and Aasgard plans at 2 m/px read as Swiss relief maps in pixels.

**Not yet:** the rock drawing is still more texture than drawing. In order:

1. **Crests as a hard light/shadow break** (p.170 rule h, p.262): per-facet tone decided from the landform
   on each side of the crest line, so the change happens across one pixel.
2. **Uneven economy** (critic, twice): stroke length and density from the local form (long in gullies, short
   ticks on ribs, none on lit slabs), not one pitch everywhere.
3. **Bedded rock by beds, not joints** (Fig.165): Yakima needs the canyon wall stated as 3-4 dark bands with
   lit ledges at region scale before any column strokes; the lane's black bands are closer to Imhof than my
   stitches.
4. **Rock mask from geology + ortho**, not slope alone; snow masks for every place.
5. Copies: redo 178-12 until the scales face the right way, and 161's flutes as tapering, joining couloirs
   with a hand-drawn gendarme skyline.

**Resources.** Everything ran on one core, `nice -n 10`: a 1024 m window at 1 and 2 m/px plus the iso takes
2-4 minutes (pure-Python streamlines). The study folder is ~200 MB (scanned pages 57 MB, OCR, DEM windows,
swisstopo references).
'''
open(p,'w').write(s)
EOF
du -sh ~/work/pixelart-studies/books/imhof; ls ~/work/pixelart-studies/books/imhof/sheets

# output:
213M    ~/work/pixelart-studies/books/imhof
_contact_000.png
copy_161.png
copy_165_r2.png
copy_165_r4.png
copy_165_r5.png
copy_178_12.png
copy_178_15.png
e1_fig33_cone.png
lanes_before_after.png
place_enchant_aasgard.png
place_sky_index.png
place_sky_mtindex.png
place_yakima_reds.png
region_aasgard_v3.png
rock_1m_aasgard.png
[This command modified 1 file you've previously read: ../books/imhof/LESSONS.md. Call Read before editing.]
Bash: cd ~/work/pixelart-studies/books/imhof; rm -f sheets/_contact_000.png; sed -i '' 's/`copy_165_r4.png`)/`copy_165_r4.png`, `copy_165_r5.png`)/' LESSONS.md; ls copies code | head -40
cd ~/work/pixelart-studies/books/imhof; rm -f sheets/_contact_000.png; sed -i '' 's/`copy_165_r4.png`)/`copy_165_r4.png`, `copy_165_r5.png`)/' LESSONS.md; ls copies code | head -40

# output:
code:
README.md
__pycache__
cmp_copy.py
contact.py
dem.py
e1_cone.py
grid.py
hachure.py
imhof.py
lanes_sheet.py
measure_ink.py
ocr_all.sh
ocr_par.sh
pen.py
place.py
region.py
rockdraw.py
run_places.py
sheet.py
skeleton.py
spread.py
strokes.py
swissfetch.py
t_rock1m.py
vectorize.py
view.py

copies:
c161_paint.npy
c161_paint.py
c165_paint.npy
c165_paint.py
c165_paint_r2.npy
c165_paint_r2.py
c178_12.npy
c178_12.py
c178_12b.npy
c178_12b.py
c178_12b_tone.npy
SubagentHandback:

I've handed the Imhof report to the coordinator.