ridge-bake: move the A+D baker, tests and mutation runner into scripts/
Moved from the mockup workspace: ridge.py, sun.py, places.json, tests. core.py is dropped (ridge.py only imported its save_png, unused; the rest carried hard-coded paths). places.json is read beside ridge.py; tiles and caches come from RIDGE_TILES and RIDGE_WORK. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1e9b68b1ea
commit
bffe988f05
7 changed files with 1647 additions and 0 deletions
BIN
scripts/ridge-bake/__pycache__/ridge.cpython-313.pyc
Normal file
BIN
scripts/ridge-bake/__pycache__/ridge.cpython-313.pyc
Normal file
Binary file not shown.
657
scripts/ridge-bake/places.json
Normal file
657
scripts/ridge-bake/places.json
Normal file
|
|
@ -0,0 +1,657 @@
|
|||
{
|
||||
"_doc": "Place presets for the A+D baker (ridge.py). eye = observer lat/lon; eye_h_m = eye above the local DEM surface (ground_override_m replaces the DEM ground elevation, used for Vienna to keep round 1's 183.3 m); look_at = target lat/lon whose bearing centres the 72 deg window (or az_deg); rmax_km = ring radius (decides the tile list: every 1x1 deg GLO-30 tile whose rectangle intersects the circle); floor_agl_m = authored eye height above the notional valley floor used only for the floor-plane colour gradient; s_ref_deg = front slope that counts as steep for the painter; tz_h = standard-time offset of the local clock on the sun date.",
|
||||
"date": "2026-09-23",
|
||||
"presets": [
|
||||
{
|
||||
"id": "vienna",
|
||||
"name": "Vienna, looking SW to Rax/Schneeberg",
|
||||
"kind": "far range over a flat foreground",
|
||||
"lat": 48.1929826,
|
||||
"lon": 16.3534237,
|
||||
"eye_h_m": 30.0,
|
||||
"ground_override_m": 183.3,
|
||||
"az_deg": 214.0,
|
||||
"rmax_km": 95,
|
||||
"floor_agl_m": 30,
|
||||
"s_ref_deg": 8,
|
||||
"tz_h": 2,
|
||||
"tz_name": "CEST",
|
||||
"viewpoint_source": {
|
||||
"status": "existing observer of the round-1 mockup (the scene camera used by the game), not photo-sourced; eye 30 m above ground as in round 1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "matterhorn",
|
||||
"name": "Matterhorn from the Gornergrat (photo cell, 9.7 km)",
|
||||
"kind": "lone horn, close, 27 deg of relief",
|
||||
"lat": 45.983139,
|
||||
"lon": 7.782466,
|
||||
"eye_h_m": 1.6,
|
||||
"look_at": [
|
||||
45.97666,
|
||||
7.65678
|
||||
],
|
||||
"rmax_km": 60,
|
||||
"floor_agl_m": 992.3298828124998,
|
||||
"s_ref_deg": 30,
|
||||
"tz_h": 2,
|
||||
"tz_name": "CEST",
|
||||
"initial_guess_not_used": {
|
||||
"lat": 45.9856,
|
||||
"lon": 7.7452
|
||||
},
|
||||
"viewpoint_source": {
|
||||
"status": "sourced",
|
||||
"source": "Wikimedia Commons geosearch (namespace 6), geotagged photos",
|
||||
"query": "geosearch ns=6 gslimit=500 gsradius=10000 around 25 centres (peak + 3 rings x 8 bearings, radii 3600-12000 m), then EXIF GPSImgDirection of every file in range via prop=imageinfo&iiprop=commonmetadata",
|
||||
"files_found": 5003,
|
||||
"in_range": 3241,
|
||||
"with_exif_heading": 205,
|
||||
"facing_the_peak": 52,
|
||||
"pool": "photos whose camera heading is within 30 deg of the bearing to the summit",
|
||||
"rule": "densest 1 km cell of photos 3000-12000 m from the summit whose EXIF camera heading faces the summit (within 30 deg) with the summit visible on the DEM (straight line, first 250 m ignored, 8 m clutter allowed); viewpoint = median coordinate of the cell",
|
||||
"median_camera_heading_deg": 245.8,
|
||||
"photos_in_cell": 7,
|
||||
"cells_tried": [
|
||||
{
|
||||
"n_facing": 7,
|
||||
"lat": 45.98314,
|
||||
"lon": 7.78247,
|
||||
"peak_visible": true
|
||||
}
|
||||
],
|
||||
"distance_to_peak_m": 9749,
|
||||
"bearing_to_peak_deg": 265.8,
|
||||
"dem_ground_m": 3090.7,
|
||||
"eye_height_rule": "DEM ground at the viewpoint + 1.6 m (the source gives no eye height)",
|
||||
"sample_files": [
|
||||
{
|
||||
"title": "File:Gornergrat-04.jpg",
|
||||
"lat": 45.983061,
|
||||
"lon": 7.782031,
|
||||
"heading": 256.0,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3AGornergrat-04.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:Gornergrat-03.jpg",
|
||||
"lat": 45.983058,
|
||||
"lon": 7.782038,
|
||||
"heading": 245.8,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3AGornergrat-03.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:Gornergrat-07.jpg",
|
||||
"lat": 45.982653,
|
||||
"lon": 7.782466,
|
||||
"heading": 250.2,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3AGornergrat-07.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:Gornergratsee 2021 10.jpg",
|
||||
"lat": 45.986958,
|
||||
"lon": 7.780303,
|
||||
"heading": 243.4,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3AGornergratsee_2021_10.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:Early morning Eiger.jpg",
|
||||
"lat": 45.983242,
|
||||
"lon": 7.783361,
|
||||
"heading": 254.7,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3AEarly_morning_Eiger.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:Klein Matterhorn mit Wolken an Theodulhorn und Furgghorn \u00fcber dem Oberen Theodulgletscher- a view from Gornergrat 2021 02 (cropped).jpg",
|
||||
"lat": 45.983139,
|
||||
"lon": 7.784172,
|
||||
"heading": 242.1,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3AKlein_Matterhorn_mit_Wolken_an_Theodulhorn_und_Furgghorn_%C3%BCber_dem_Oberen_Theodulgletscher-_a_view_from_Gornergrat_2021_02_%28cropped%29.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:Breithorn and neighbours from Gornergrat 2021 02.jpg",
|
||||
"lat": 45.983139,
|
||||
"lon": 7.784172,
|
||||
"heading": 242.1,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3ABreithorn_and_neighbours_from_Gornergrat_2021_02.jpg"
|
||||
}
|
||||
],
|
||||
"exif_headings": [
|
||||
{
|
||||
"title": "File:Gornergrat-04.jpg",
|
||||
"heading": 256.0
|
||||
},
|
||||
{
|
||||
"title": "File:Gornergrat-03.jpg",
|
||||
"heading": 245.8
|
||||
},
|
||||
{
|
||||
"title": "File:Gornergrat-07.jpg",
|
||||
"heading": 250.2
|
||||
},
|
||||
{
|
||||
"title": "File:Gornergratsee 2021 10.jpg",
|
||||
"heading": 243.4
|
||||
},
|
||||
{
|
||||
"title": "File:Early morning Eiger.jpg",
|
||||
"heading": 254.7
|
||||
},
|
||||
{
|
||||
"title": "File:Klein Matterhorn mit Wolken an Theodulhorn und Furgghorn \u00fcber dem Oberen Theodulgletscher- a view from Gornergrat 2021 02 (cropped).jpg",
|
||||
"heading": 242.1
|
||||
},
|
||||
{
|
||||
"title": "File:Breithorn and neighbours from Gornergrat 2021 02.jpg",
|
||||
"heading": 242.1
|
||||
}
|
||||
],
|
||||
"n_with_exif_heading": 7,
|
||||
"summit_dem_m": 4324.9,
|
||||
"summit_nominal": [
|
||||
45.97639,
|
||||
7.65833
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fuji",
|
||||
"name": "Fuji from the Shiraito Falls side, 13.8 km WSW (photo cell)",
|
||||
"kind": "lone cone, smooth",
|
||||
"lat": 35.312467222222494,
|
||||
"lon": 138.587223194445,
|
||||
"eye_h_m": 1.6,
|
||||
"look_at": [
|
||||
35.36061,
|
||||
138.72731
|
||||
],
|
||||
"rmax_km": 60,
|
||||
"floor_agl_m": 138.6306213378906,
|
||||
"s_ref_deg": 20,
|
||||
"tz_h": 9,
|
||||
"tz_name": "JST",
|
||||
"initial_guess_not_used": {
|
||||
"lat": 35.4994,
|
||||
"lon": 138.7686
|
||||
},
|
||||
"viewpoint_source": {
|
||||
"status": "sourced",
|
||||
"source": "Wikimedia Commons geosearch (namespace 6), geotagged photos",
|
||||
"query": "geosearch ns=6 gslimit=500 gsradius=10000 around 25 centres (peak + 3 rings x 8 bearings, radii 9000-30000 m), then EXIF GPSImgDirection of every file in range via prop=imageinfo&iiprop=commonmetadata",
|
||||
"files_found": 6494,
|
||||
"in_range": 4280,
|
||||
"with_exif_heading": 588,
|
||||
"facing_the_peak": 132,
|
||||
"pool": "photos whose camera heading is within 30 deg of the bearing to the summit",
|
||||
"rule": "densest 1 km cell of photos 8000-30000 m from the summit whose EXIF camera heading faces the summit (within 30 deg) with the summit visible on the DEM (straight line, first 250 m ignored, 8 m clutter allowed); viewpoint = median coordinate of the cell",
|
||||
"median_camera_heading_deg": 64.4,
|
||||
"photos_in_cell": 8,
|
||||
"cells_tried": [
|
||||
{
|
||||
"n_facing": 15,
|
||||
"lat": 35.36248,
|
||||
"lon": 138.86287,
|
||||
"peak_visible": false
|
||||
},
|
||||
{
|
||||
"n_facing": 8,
|
||||
"lat": 35.31247,
|
||||
"lon": 138.58722,
|
||||
"peak_visible": true
|
||||
}
|
||||
],
|
||||
"distance_to_peak_m": 13808,
|
||||
"bearing_to_peak_deg": 67.2,
|
||||
"dem_ground_m": 486.9,
|
||||
"eye_height_rule": "DEM ground at the viewpoint + 1.6 m (the source gives no eye height)",
|
||||
"sample_files": [
|
||||
{
|
||||
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 03.JPG",
|
||||
"lat": 35.31114,
|
||||
"lon": 138.5886,
|
||||
"heading": 48.0,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3A%E5%AF%8C%E5%A3%AB%E5%B1%B1%EF%BC%88%E3%81%B5%E3%81%98%E3%81%95%E3%82%93%E3%80%81%E8%8B%B1%E8%AA%9E%EF%BC%9AMount_Fuji%EF%BC%89_03.JPG"
|
||||
},
|
||||
{
|
||||
"title": "File:Mount Fuji, view from Shiraito no Taki.jpg",
|
||||
"lat": 35.313589,
|
||||
"lon": 138.588394,
|
||||
"heading": 68.8,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3AMount_Fuji%2C_view_from_Shiraito_no_Taki.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 02.JPG",
|
||||
"lat": 35.313248055556,
|
||||
"lon": 138.58760805556,
|
||||
"heading": 64.2,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3A%E5%AF%8C%E5%A3%AB%E5%B1%B1%EF%BC%88%E3%81%B5%E3%81%98%E3%81%95%E3%82%93%E3%80%81%E8%8B%B1%E8%AA%9E%EF%BC%9AMount_Fuji%EF%BC%89_02.JPG"
|
||||
},
|
||||
{
|
||||
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 07.JPG",
|
||||
"lat": 35.312428055556,
|
||||
"lon": 138.58723138889,
|
||||
"heading": 88.8,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3A%E5%AF%8C%E5%A3%AB%E5%B1%B1%EF%BC%88%E3%81%B5%E3%81%98%E3%81%95%E3%82%93%E3%80%81%E8%8B%B1%E8%AA%9E%EF%BC%9AMount_Fuji%EF%BC%89_07.JPG"
|
||||
},
|
||||
{
|
||||
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 08.JPG",
|
||||
"lat": 35.312418055556,
|
||||
"lon": 138.587215,
|
||||
"heading": 64.0,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3A%E5%AF%8C%E5%A3%AB%E5%B1%B1%EF%BC%88%E3%81%B5%E3%81%98%E3%81%95%E3%82%93%E3%80%81%E8%8B%B1%E8%AA%9E%EF%BC%9AMount_Fuji%EF%BC%89_08.JPG"
|
||||
},
|
||||
{
|
||||
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 04.JPG",
|
||||
"lat": 35.312478055556,
|
||||
"lon": 138.58717805556,
|
||||
"heading": 62.2,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3A%E5%AF%8C%E5%A3%AB%E5%B1%B1%EF%BC%88%E3%81%B5%E3%81%98%E3%81%95%E3%82%93%E3%80%81%E8%8B%B1%E8%AA%9E%EF%BC%9AMount_Fuji%EF%BC%89_04.JPG"
|
||||
},
|
||||
{
|
||||
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 06.JPG",
|
||||
"lat": 35.312456388889,
|
||||
"lon": 138.58715805556,
|
||||
"heading": 65.2,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3A%E5%AF%8C%E5%A3%AB%E5%B1%B1%EF%BC%88%E3%81%B5%E3%81%98%E3%81%95%E3%82%93%E3%80%81%E8%8B%B1%E8%AA%9E%EF%BC%9AMount_Fuji%EF%BC%89_06.JPG"
|
||||
},
|
||||
{
|
||||
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 05.JPG",
|
||||
"lat": 35.312479722222,
|
||||
"lon": 138.58714138889,
|
||||
"heading": 64.5,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3A%E5%AF%8C%E5%A3%AB%E5%B1%B1%EF%BC%88%E3%81%B5%E3%81%98%E3%81%95%E3%82%93%E3%80%81%E8%8B%B1%E8%AA%9E%EF%BC%9AMount_Fuji%EF%BC%89_05.JPG"
|
||||
}
|
||||
],
|
||||
"exif_headings": [
|
||||
{
|
||||
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 03.JPG",
|
||||
"heading": 48.0
|
||||
},
|
||||
{
|
||||
"title": "File:Mount Fuji, view from Shiraito no Taki.jpg",
|
||||
"heading": 68.8
|
||||
},
|
||||
{
|
||||
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 02.JPG",
|
||||
"heading": 64.2
|
||||
},
|
||||
{
|
||||
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 07.JPG",
|
||||
"heading": 88.8
|
||||
},
|
||||
{
|
||||
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 08.JPG",
|
||||
"heading": 64.0
|
||||
},
|
||||
{
|
||||
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 04.JPG",
|
||||
"heading": 62.2
|
||||
},
|
||||
{
|
||||
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 06.JPG",
|
||||
"heading": 65.2
|
||||
},
|
||||
{
|
||||
"title": "File:\u5bcc\u58eb\u5c71\uff08\u3075\u3058\u3055\u3093\u3001\u82f1\u8a9e\uff1aMount Fuji\uff09 05.JPG",
|
||||
"heading": 64.5
|
||||
}
|
||||
],
|
||||
"n_with_exif_heading": 8,
|
||||
"summit_dem_m": 3765.5,
|
||||
"summit_nominal": [
|
||||
35.36061,
|
||||
138.72731
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "halfdome",
|
||||
"name": "Half Dome from the Yosemite Valley floor (Sentinel Bridge photo cell)",
|
||||
"kind": "granite wall across a valley",
|
||||
"lat": 37.745833333333,
|
||||
"lon": -119.592606,
|
||||
"eye_h_m": 1.6,
|
||||
"look_at": [
|
||||
37.74435,
|
||||
-119.53426
|
||||
],
|
||||
"rmax_km": 40,
|
||||
"floor_agl_m": 5.5442382812500455,
|
||||
"s_ref_deg": 30,
|
||||
"tz_h": -7,
|
||||
"tz_name": "PDT",
|
||||
"initial_guess_not_used": {
|
||||
"lat": 37.7305,
|
||||
"lon": -119.5734
|
||||
},
|
||||
"viewpoint_source": {
|
||||
"status": "sourced",
|
||||
"source": "Wikimedia Commons geosearch (namespace 6), geotagged photos",
|
||||
"query": "geosearch ns=6 gslimit=500 gsradius=10000 around 25 centres (peak + 3 rings x 8 bearings, radii 2700-9000 m), then EXIF GPSImgDirection of every file in range via prop=imageinfo&iiprop=commonmetadata",
|
||||
"files_found": 2505,
|
||||
"in_range": 1788,
|
||||
"with_exif_heading": 181,
|
||||
"facing_the_peak": 64,
|
||||
"pool": "photos whose camera heading is within 30 deg of the bearing to the summit",
|
||||
"rule": "densest 1 km cell of photos 2000-9000 m from the summit whose EXIF camera heading faces the summit (within 30 deg) with the summit visible on the DEM (straight line, first 250 m ignored, 8 m clutter allowed); viewpoint = median coordinate of the cell",
|
||||
"median_camera_heading_deg": 91.6,
|
||||
"photos_in_cell": 9,
|
||||
"cells_tried": [
|
||||
{
|
||||
"n_facing": 9,
|
||||
"lat": 37.73853,
|
||||
"lon": -119.57124,
|
||||
"peak_visible": false
|
||||
},
|
||||
{
|
||||
"n_facing": 9,
|
||||
"lat": 37.74583,
|
||||
"lon": -119.59261,
|
||||
"peak_visible": true
|
||||
}
|
||||
],
|
||||
"distance_to_peak_m": 5138,
|
||||
"bearing_to_peak_deg": 91.8,
|
||||
"dem_ground_m": 1209.2,
|
||||
"eye_height_rule": "DEM ground at the viewpoint + 1.6 m (the source gives no eye height)",
|
||||
"sample_files": [
|
||||
{
|
||||
"title": "File:Yosemite National Park - 51700634719.jpg",
|
||||
"lat": 37.746606,
|
||||
"lon": -119.590317,
|
||||
"heading": 87.0,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3AYosemite_National_Park_-_51700634719.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:Half Dome, Yosemite National Park - 53324601178.jpg",
|
||||
"lat": 37.748264,
|
||||
"lon": -119.592606,
|
||||
"heading": 98.8,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3AHalf_Dome%2C_Yosemite_National_Park_-_53324601178.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:Half Dome, Yosemite National Park - 53324824955.jpg",
|
||||
"lat": 37.748322,
|
||||
"lon": -119.592736,
|
||||
"heading": 90.1,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3AHalf_Dome%2C_Yosemite_National_Park_-_53324824955.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:2013-09-19 18 35 25 Half Dome and the Merced River in Yosemite National Park.JPG",
|
||||
"lat": 37.7435,
|
||||
"lon": -119.58966666667,
|
||||
"heading": 91.6,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3A2013-09-19_18_35_25_Half_Dome_and_the_Merced_River_in_Yosemite_National_Park.JPG"
|
||||
},
|
||||
{
|
||||
"title": "File:Half Dome from the Yosemite Valley floor in mid-Spring.jpeg",
|
||||
"lat": 37.745833333333,
|
||||
"lon": -119.59233333333,
|
||||
"heading": 92.0,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3AHalf_Dome_from_the_Yosemite_Valley_floor_in_mid-Spring.jpeg"
|
||||
},
|
||||
{
|
||||
"title": "File:Sentinel Bridge & Half Dome.jpg",
|
||||
"lat": 37.743419,
|
||||
"lon": -119.590562,
|
||||
"heading": 86.8,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3ASentinel_Bridge_%26_Half_Dome.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:Half Dome pre Super moon 2.jpg",
|
||||
"lat": 37.745769,
|
||||
"lon": -119.592972,
|
||||
"heading": 106.5,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3AHalf_Dome_pre_Super_moon_2.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:Half Dome pre Super moon.jpg",
|
||||
"lat": 37.745747,
|
||||
"lon": -119.593033,
|
||||
"heading": 97.1,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3AHalf_Dome_pre_Super_moon.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:Yosemite National Park 003.jpg",
|
||||
"lat": 37.750814,
|
||||
"lon": -119.598664,
|
||||
"heading": 80.1,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3AYosemite_National_Park_003.jpg"
|
||||
}
|
||||
],
|
||||
"exif_headings": [
|
||||
{
|
||||
"title": "File:Yosemite National Park - 51700634719.jpg",
|
||||
"heading": 87.0
|
||||
},
|
||||
{
|
||||
"title": "File:Half Dome, Yosemite National Park - 53324601178.jpg",
|
||||
"heading": 98.8
|
||||
},
|
||||
{
|
||||
"title": "File:Half Dome, Yosemite National Park - 53324824955.jpg",
|
||||
"heading": 90.1
|
||||
},
|
||||
{
|
||||
"title": "File:2013-09-19 18 35 25 Half Dome and the Merced River in Yosemite National Park.JPG",
|
||||
"heading": 91.6
|
||||
},
|
||||
{
|
||||
"title": "File:Half Dome from the Yosemite Valley floor in mid-Spring.jpeg",
|
||||
"heading": 92.0
|
||||
},
|
||||
{
|
||||
"title": "File:Sentinel Bridge & Half Dome.jpg",
|
||||
"heading": 86.8
|
||||
},
|
||||
{
|
||||
"title": "File:Half Dome pre Super moon 2.jpg",
|
||||
"heading": 106.5
|
||||
},
|
||||
{
|
||||
"title": "File:Half Dome pre Super moon.jpg",
|
||||
"heading": 97.1
|
||||
},
|
||||
{
|
||||
"title": "File:Yosemite National Park 003.jpg",
|
||||
"heading": 80.1
|
||||
}
|
||||
],
|
||||
"n_with_exif_heading": 9,
|
||||
"summit_dem_m": 2669.1,
|
||||
"summit_nominal": [
|
||||
37.74597,
|
||||
-119.5329
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "amadablam",
|
||||
"name": "Ama Dablam and the Everest massif, 4 km WSW of Tengboche near Khumjung (photo cell)",
|
||||
"kind": "far high range behind a near spire",
|
||||
"lat": 27.822000000000003,
|
||||
"lon": 86.722666666667,
|
||||
"eye_h_m": 1.6,
|
||||
"look_at": [
|
||||
27.8617,
|
||||
86.86079
|
||||
],
|
||||
"rmax_km": 60,
|
||||
"floor_agl_m": 829.8232421875,
|
||||
"s_ref_deg": 30,
|
||||
"tz_h": 5.75,
|
||||
"tz_name": "NPT",
|
||||
"initial_guess_not_used": {
|
||||
"lat": 27.8369,
|
||||
"lon": 86.7643
|
||||
},
|
||||
"viewpoint_source": {
|
||||
"status": "sourced",
|
||||
"source": "Wikimedia Commons geosearch (namespace 6), geotagged photos",
|
||||
"query": "geosearch ns=6 gslimit=500 gsradius=10000 around 25 centres (peak + 3 rings x 8 bearings, radii 4800-16000 m), then EXIF GPSImgDirection of every file in range via prop=imageinfo&iiprop=commonmetadata",
|
||||
"files_found": 1398,
|
||||
"in_range": 1143,
|
||||
"with_exif_heading": 54,
|
||||
"facing_the_peak": 13,
|
||||
"pool": "photos whose camera heading is within 30 deg of the bearing to the summit",
|
||||
"rule": "densest 1 km cell of photos 4000-16000 m from the summit whose EXIF camera heading faces the summit (within 30 deg) with the summit visible on the DEM (straight line, first 250 m ignored, 8 m clutter allowed); viewpoint = median coordinate of the cell",
|
||||
"median_camera_heading_deg": 79.6,
|
||||
"photos_in_cell": 2,
|
||||
"cells_tried": [
|
||||
{
|
||||
"n_facing": 2,
|
||||
"lat": 27.822,
|
||||
"lon": 86.72267,
|
||||
"peak_visible": true
|
||||
}
|
||||
],
|
||||
"distance_to_peak_m": 14299,
|
||||
"bearing_to_peak_deg": 72.0,
|
||||
"dem_ground_m": 3782.0,
|
||||
"eye_height_rule": "DEM ground at the viewpoint + 1.6 m (the source gives no eye height)",
|
||||
"sample_files": [
|
||||
{
|
||||
"title": "File:Thamserku (6).JPG",
|
||||
"lat": 27.822666666667,
|
||||
"lon": 86.722666666667,
|
||||
"heading": 73.6,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3AThamserku_%286%29.JPG"
|
||||
},
|
||||
{
|
||||
"title": "File:Thamserku (7).JPG",
|
||||
"lat": 27.821333333333,
|
||||
"lon": 86.722666666667,
|
||||
"heading": 85.7,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3AThamserku_%287%29.JPG"
|
||||
}
|
||||
],
|
||||
"exif_headings": [
|
||||
{
|
||||
"title": "File:Thamserku (6).JPG",
|
||||
"heading": 73.6
|
||||
},
|
||||
{
|
||||
"title": "File:Thamserku (7).JPG",
|
||||
"heading": 85.7
|
||||
}
|
||||
],
|
||||
"n_with_exif_heading": 2,
|
||||
"summit_dem_m": 6659.6,
|
||||
"summit_nominal": [
|
||||
27.8617,
|
||||
86.8614
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trecime",
|
||||
"name": "Tre Cime di Lavaredo from the Dreizinnenhuette area, 2.4 km N (photo cell)",
|
||||
"kind": "cirque wall, spires",
|
||||
"lat": 46.636906,
|
||||
"lon": 12.310064,
|
||||
"eye_h_m": 1.6,
|
||||
"look_at": [
|
||||
46.61806,
|
||||
12.29583
|
||||
],
|
||||
"rmax_km": 40,
|
||||
"floor_agl_m": 735.6748046875,
|
||||
"s_ref_deg": 30,
|
||||
"tz_h": 2,
|
||||
"tz_name": "CEST",
|
||||
"initial_guess_not_used": {
|
||||
"lat": 46.5833,
|
||||
"lon": 12.2536
|
||||
},
|
||||
"viewpoint_source": {
|
||||
"status": "sourced",
|
||||
"source": "Wikimedia Commons geosearch (namespace 6), geotagged photos",
|
||||
"query": "geosearch ns=6 gslimit=500 gsradius=10000 around 25 centres (peak + 3 rings x 8 bearings, radii 3000-10000 m), then EXIF GPSImgDirection of every file in range via prop=imageinfo&iiprop=commonmetadata",
|
||||
"files_found": 2028,
|
||||
"in_range": 763,
|
||||
"with_exif_heading": 207,
|
||||
"facing_the_peak": 33,
|
||||
"pool": "photos whose camera heading is within 30 deg of the bearing to the summit",
|
||||
"rule": "densest 1 km cell of photos 1500-10000 m from the summit whose EXIF camera heading faces the summit (within 30 deg) with the summit visible on the DEM (straight line, first 250 m ignored, 8 m clutter allowed); viewpoint = median coordinate of the cell",
|
||||
"median_camera_heading_deg": 194.6,
|
||||
"photos_in_cell": 5,
|
||||
"cells_tried": [
|
||||
{
|
||||
"n_facing": 5,
|
||||
"lat": 46.63691,
|
||||
"lon": 12.31006,
|
||||
"peak_visible": true
|
||||
}
|
||||
],
|
||||
"distance_to_peak_m": 2363,
|
||||
"bearing_to_peak_deg": 207.4,
|
||||
"dem_ground_m": 2403.0,
|
||||
"eye_height_rule": "DEM ground at the viewpoint + 1.6 m (the source gives no eye height)",
|
||||
"sample_files": [
|
||||
{
|
||||
"title": "File:Cime e Nuvole.jpg",
|
||||
"lat": 46.63605,
|
||||
"lon": 12.310064,
|
||||
"heading": 200.3,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3ACime_e_Nuvole.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:DreiZinnenP.jpg",
|
||||
"lat": 46.636770555556,
|
||||
"lon": 12.310305555556,
|
||||
"heading": 180.1,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3ADreiZinnenP.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:Soffio di sguardo.jpg",
|
||||
"lat": 46.636906,
|
||||
"lon": 12.311142,
|
||||
"heading": 194.6,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3ASoffio_di_sguardo.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:2405-DreizinnenH.jpg",
|
||||
"lat": 46.637782777778,
|
||||
"lon": 12.309966388889,
|
||||
"heading": 178.4,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3A2405-DreizinnenH.jpg"
|
||||
},
|
||||
{
|
||||
"title": "File:Dolomiten-Fingerkraut in den Sextener Dolomiten - N\u00e4he Drei-Zinnen-H\u00fctte (9801216636).jpg",
|
||||
"lat": 46.642352,
|
||||
"lon": 12.30829,
|
||||
"heading": 200.0,
|
||||
"url": "https://commons.wikimedia.org/wiki/File%3ADolomiten-Fingerkraut_in_den_Sextener_Dolomiten_-_N%C3%A4he_Drei-Zinnen-H%C3%BCtte_%289801216636%29.jpg"
|
||||
}
|
||||
],
|
||||
"exif_headings": [
|
||||
{
|
||||
"title": "File:Cime e Nuvole.jpg",
|
||||
"heading": 200.3
|
||||
},
|
||||
{
|
||||
"title": "File:DreiZinnenP.jpg",
|
||||
"heading": 180.1
|
||||
},
|
||||
{
|
||||
"title": "File:Soffio di sguardo.jpg",
|
||||
"heading": 194.6
|
||||
},
|
||||
{
|
||||
"title": "File:2405-DreizinnenH.jpg",
|
||||
"heading": 178.4
|
||||
},
|
||||
{
|
||||
"title": "File:Dolomiten-Fingerkraut in den Sextener Dolomiten - N\u00e4he Drei-Zinnen-H\u00fctte (9801216636).jpg",
|
||||
"heading": 200.0
|
||||
}
|
||||
],
|
||||
"n_with_exif_heading": 5,
|
||||
"summit_dem_m": 2746.9,
|
||||
"summit_nominal": [
|
||||
46.6186,
|
||||
12.3025
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
507
scripts/ridge-bake/ridge.py
Normal file
507
scripts/ridge-bake/ridge.py
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
"""Round-2 baker: A's painter baked from D's sweep, driven by a place preset from places.json (ray sampling, the round-1 recommendation).
|
||||
Offline baker for the ridge backdrop (scripts/ridge-bake); export.py drives it.
|
||||
|
||||
load_preset(id) -> configure() : observer, tiles, mosaic origin, ring radius, window azimuth, painter constants from places.json
|
||||
stage0(detail) : ray march of all 6400 azimuth columns (curvature+refraction), optional authored sub-DEM foreground detail, cached
|
||||
sweep() : D's per-column scan -> spans (with own height/run = per-peak slope) -> wrapped chains -> 7-knot layer cap
|
||||
set_view(slots) : authored Y mapping: angle -> row, ground plane at the bottom (see set_view)
|
||||
bake(slots) : sun-independent per-layer arrays
|
||||
relight(layers, az, el) : shading + atmosphere + floor + sky
|
||||
Vertical exaggeration is AUTHORED and derived per view by set_view (printed on the page)."""
|
||||
import sys, time, math, json
|
||||
import os, hashlib
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import numpy as np, tifffile
|
||||
|
||||
def _env_dir(name, what):
|
||||
v = os.environ.get(name)
|
||||
if not v: raise RuntimeError(f"{name} is not set: it must name {what} (no default location is assumed)")
|
||||
return Path(v)
|
||||
B = _env_dir('RIDGE_WORK', 'the work directory (the mosaic / ray caches)')
|
||||
COP = _env_dir('RIDGE_TILES', 'the directory of Copernicus GLO-30 tiles (Copernicus_DSM_COG_10_*_DEM.tif)')
|
||||
W = 6400 # ring columns: 360 deg at 17.78 px/deg
|
||||
WIN = 1280 # window = 72 deg of the ring
|
||||
HPX_DEG = W / 360.0
|
||||
H = 600 # frame rows
|
||||
COLW = math.radians(360.0 / W)
|
||||
R_EARTH, KREF = 6371000.0, 0.13
|
||||
PROM_M, NEAR_CAP, MINW, LINK_D, LINK_A, LOOK = 25.0, 4000.0, 50, 0.45, 0.25, 4
|
||||
FOREGROUND_M = 5000.0 # a chain nearer than this that is narrower than MINW is kept only where no kept foreground chain covers its columns (the nearest layer must cover every column; a lone stub beside a long near ridge is an arch)
|
||||
TAPER = 8 # minimum columns over which a chain end dies into its valley (D's 8; round 1 used a fixed 20 AND borrowed the far end's value: the spike)
|
||||
TAPER_K, TAPER_MAX = 30.0, 72 # a taller end falls over more columns: ceil(30 columns per degree of drop), 8..72: at the authored 1.5-4x exaggeration an end leaves the ridge at about 45 deg or less, not a wall
|
||||
def taper_len(drop_deg): return np.clip(np.ceil(np.asarray(drop_deg) * TAPER_K), TAPER, TAPER_MAX)
|
||||
KNOTS = np.array([80, 48, 28, 16, 9, 5, 2.5]) * 1000.0
|
||||
_r5 = ('#a9b9d3', '#8e9fc2', '#6f86a6', '#556f7d', '#3f5546')
|
||||
def hx(h): return np.array([int(h[i:i+2], 16) for i in (1, 3, 5)]) / 255.0
|
||||
def mix(a, b, t): return a + (b - a) * np.asarray(t)[..., None]
|
||||
RAMP = [mix(hx(_r5[min(int(t), 3)]), hx(_r5[min(int(t), 3) + 1]), t - min(int(t), 3)) for t in np.linspace(0, 4, len(KNOTS))]
|
||||
GROUND = hx('#4a5f45'); CONTOUR_NICE = (30.0, 50.0, 100.0, 150.0, 200.0, 300.0, 500.0, 1000.0); CONTOUR_MIN_PX = 9.0 # iso-height interval is the smallest nice value that keeps lines >= 9 px apart (p10 of the layer)
|
||||
ROT = 0 # test hook: frame rotated by ROT columns; noise/azimuth stay tied to the absolute column
|
||||
|
||||
P = {} # configured preset
|
||||
rowang = np.zeros(H); PXDEG_V = 100.0; TOP_DEG = 2.6; BOTTOM_DEG = -3.4; Hc = H
|
||||
|
||||
def load_preset(pid, path=Path(__file__).resolve().parent / 'places.json'):
|
||||
d = json.load(open(path)); p = next(x for x in d['presets'] if x['id'] == pid); p = dict(p); p['date'] = d['date']; return p
|
||||
|
||||
# ====================================================================== configuration, DEM
|
||||
def tiles_for(p):
|
||||
lat, lon, r = p['lat'], p['lon'], p['rmax_km']; dl = r / 111.32; dn = r / (111.32 * math.cos(math.radians(lat))); out = []
|
||||
for la in range(math.floor(lat - dl), math.floor(lat + dl) + 1):
|
||||
for lo in range(math.floor(lon - dn), math.floor(lon + dn) + 1):
|
||||
cy = min(max(lat, la), la + 1); cx = min(max(lon, lo), lo + 1)
|
||||
if math.hypot((cy - lat) * 111.32, (cx - lon) * 111.32 * math.cos(math.radians(lat))) <= r: out.append((la, lo))
|
||||
return out
|
||||
def tile_path(la, lo): return COP / f"Copernicus_DSM_COG_10_{'N' if la >= 0 else 'S'}{abs(la):02d}_00_{'E' if lo >= 0 else 'W'}{abs(lo):03d}_00_DEM.tif"
|
||||
|
||||
def cache_key(*parts):
|
||||
"""Short digest of everything a cached array was computed from. A cache file is read only under the key of its own inputs, so a changed preset field, tile list or baker constant never meets a stale file."""
|
||||
h = hashlib.sha256()
|
||||
for q in parts: h.update(q.tobytes() if isinstance(q, np.ndarray) else repr(q).encode()); h.update(b'|')
|
||||
return h.hexdigest()[:12]
|
||||
def mosaic_key(p): return cache_key('mosaic.v1', sorted(tiles_for(p)), 3600)
|
||||
|
||||
_mos = {}
|
||||
def mosaic(p):
|
||||
"""Tile grid covering the circle (bounding rows/cols), cached as .npy per preset. Missing tiles (open sea has no GLO-30 tile) are zero."""
|
||||
key = (p['id'], mosaic_key(p))
|
||||
if key in _mos: return _mos[key]
|
||||
ts = tiles_for(p); las = [t[0] for t in ts]; los = [t[1] for t in ts]; lat_top = max(las) + 1; lon_left = min(los)
|
||||
c = B / f"mosaic-{p['id']}-{key[1]}.npy"
|
||||
if c.exists(): m = np.load(c, mmap_mode='r')
|
||||
else:
|
||||
rows = []
|
||||
for la in range(max(las), min(las) - 1, -1):
|
||||
rows.append(np.concatenate([tifffile.imread(tile_path(la, lo)) if tile_path(la, lo).exists() else np.zeros((3600, 3600), np.float32) for lo in range(min(los), max(los) + 1)], axis=1))
|
||||
m = np.concatenate(rows, axis=0).astype(np.float32); np.save(c, m)
|
||||
_mos[key] = (m, lat_top, lon_left); return _mos[key]
|
||||
|
||||
def bil(a, lat_top, lon_left, lat, lon):
|
||||
y = (lat_top - lat) * 3600 - 0.5; x = (lon - lon_left) * 3600 - 0.5
|
||||
y0 = np.clip(np.floor(y).astype(int), 0, a.shape[0] - 2); x0 = np.clip(np.floor(x).astype(int), 0, a.shape[1] - 2)
|
||||
fy = np.clip(y - y0, 0, 1); fx = np.clip(x - x0, 0, 1)
|
||||
return (a[y0, x0] * (1 - fx) * (1 - fy) + a[y0, x0 + 1] * fx * (1 - fy) + a[y0 + 1, x0] * (1 - fx) * fy + a[y0 + 1, x0 + 1] * fx * fy)
|
||||
|
||||
def configure(p):
|
||||
"""Set the module state for a preset: observer, eye elevation (DEM ground + eye height, or ground_override), window azimuth."""
|
||||
global P
|
||||
P = p; m, lt, ll = mosaic(p)
|
||||
ground = p['ground_override_m'] if 'ground_override_m' in p else float(bil(m, lt, ll, np.array(p['lat']), np.array(p['lon'])))
|
||||
P['ground_m'] = ground; P['eye_m'] = ground + p['eye_h_m']
|
||||
if 'look_at' in p:
|
||||
la, lo = p['look_at']; P['az_center'] = (math.degrees(math.atan2((lo - p['lon']) * math.cos(math.radians(p['lat'])), la - p['lat'])) + 360) % 360
|
||||
else: P['az_center'] = p['az_deg']
|
||||
P['win0'] = int(round(P['az_center'] / 360 * W - WIN / 2)) % W
|
||||
return P
|
||||
|
||||
def win(img): return np.take(img, (P['win0'] + np.arange(WIN)) % W, axis=1)
|
||||
|
||||
# ====================================================================== authored sub-DEM foreground detail (Aetheria / CultMath ridged multifractal)
|
||||
def _frac(v): return v - np.floor(v)
|
||||
def _hash2(x, y): return _frac(np.sin(x * 127.1 + y * 311.7) * 43758.5453) # cultmath_hash(vec2), as Ratatoskr noise.ts
|
||||
def _vnoise2(x, y): # cultmath_value_noise: 4 hashed corners, smoothstep blend
|
||||
cx, cy = np.floor(x), np.floor(y); u = x - cx; v = y - cy; u = u * u * (3 - 2 * u); v = v * v * (3 - 2 * v)
|
||||
a, b, c, d = _hash2(cx, cy), _hash2(cx + 1, cy), _hash2(cx, cy + 1), _hash2(cx + 1, cy + 1)
|
||||
return (a + (b - a) * u) * (1 - v) + (c + (d - c) * u) * v
|
||||
LACUN, WEIGHT_GAIN, AMP_GAIN, CRAG = 2.07, 2.0, 0.5, 0.5 # ridgeWorld.ts constants
|
||||
DETAIL = dict(cell_m=60.0, octaves=4, amp_m=24.0, fade_from_m=500.0, fade_to_m=4000.0, seed=17)
|
||||
def detail_height(E, N, dist):
|
||||
"""Ridged multifractal (ridgeWorld.ts `profile`, folded value noise weighted by the previous octave) in WORLD metres (east, north of the observer),
|
||||
so it is automatically periodic around the ring. First octave wavelength = 60 m = two GLO-30 cells (nothing coarser than the DEM can resolve is
|
||||
touched), then x2.07. Amplitude fades to zero between fade_from and fade_to metres of distance. Seeded, deterministic, authored."""
|
||||
cfg = DETAIL; e = 0.03 + (1 - CRAG) * 0.4; top = math.sqrt(1 + e * e); s = 0.0; nrm = 0.0; amp = 1.0; f = 1.0 / cfg['cell_m']; w = 1.0
|
||||
for k in range(cfg['octaves']):
|
||||
v = 2 * _vnoise2(E * f + cfg['seed'] * 7.31, N * f + cfg['seed'] * 3.17 + k * 13) - 1
|
||||
sig = (1 - np.sqrt(v * v + e * e) / top) ** 2 * w; w = np.minimum(1.0, sig * WEIGHT_GAIN); s = s + sig * amp; nrm += amp; amp *= AMP_GAIN; f *= LACUN
|
||||
t = np.clip((dist - cfg['fade_from_m']) / (cfg['fade_to_m'] - cfg['fade_from_m']), 0, 1); fade = 1 - t * t * (3 - 2 * t)
|
||||
return cfg['amp_m'] * fade * (s / nrm - 0.3)
|
||||
|
||||
# ====================================================================== stage 0: march
|
||||
def schedule(detail):
|
||||
d = [500.0]
|
||||
while d[-1] < P['rmax_km'] * 1000:
|
||||
d.append(d[-1] + min(250.0, max(10.0 if detail else 40.0, 0.004 * d[-1])))
|
||||
return np.array(d)
|
||||
|
||||
def march(detail=False, curvature=True):
|
||||
m, lt, ll = mosaic(P); az = np.radians((np.arange(W) + 0.5) / W * 360.0); d = schedule(detail); cosl = math.cos(math.radians(P['lat']))
|
||||
A = np.zeros((len(d), W), np.float32); Hh = np.zeros_like(A); sa, ca = np.sin(az), np.cos(az)
|
||||
for k, dk in enumerate(d):
|
||||
lat = P['lat'] + dk * ca / 111320.0; lon = P['lon'] + dk * sa / (111320.0 * cosl)
|
||||
h = bil(m, lt, ll, lat, lon)
|
||||
if detail and dk < DETAIL['fade_to_m']: h = h + detail_height(dk * sa, dk * ca, dk)
|
||||
drop = dk * dk / (2 * R_EARTH) * (1 - KREF) if curvature else 0.0
|
||||
A[k] = np.degrees(np.arctan2(h - P['eye_m'] - drop, dk)); Hh[k] = h
|
||||
return d, A, Hh
|
||||
|
||||
def rays_key(detail):
|
||||
"""Inputs of the ray march: observer, eye elevation, range, the DEM mosaic, the sample schedule (hashed as the array it produces), the Earth / refraction constants, the ring width, and (detail) the foreground detail noise."""
|
||||
return cache_key('rays.v1', P['lat'], P['lon'], P['rmax_km'], round(float(P['eye_m']), 3), mosaic_key(P), schedule(detail), R_EARTH, KREF, W, bool(detail),
|
||||
(sorted(DETAIL.items()), LACUN, WEIGHT_GAIN, AMP_GAIN, CRAG) if detail else None)
|
||||
def stage0(detail=False, force=False):
|
||||
c = B / f"rays-{P['id']}{'-detail' if detail else ''}-{rays_key(detail)}.npz"
|
||||
if c.exists() and not force:
|
||||
z = np.load(c); return z['d'], z['ang'], z['h'], None
|
||||
t0 = time.time(); mosaic(P); t1 = time.time(); d, A, Hh = march(detail); t2 = time.time(); np.savez(c, d=d, ang=A, h=Hh)
|
||||
return d, A, Hh, dict(mosaic=t1 - t0, march=t2 - t1, samples=len(d))
|
||||
|
||||
# ====================================================================== periodic helpers
|
||||
def blurw(a, s):
|
||||
r = int(3 * s) + 1; k = np.exp(-0.5 * (np.arange(-r, r + 1) / s) ** 2); k /= k.sum()
|
||||
a = np.asarray(a, np.float64); xp = np.concatenate([a[..., -r:], a, a[..., :r]], axis=-1); o = np.zeros_like(a)
|
||||
for i, kv in enumerate(k): o += kv * xp[..., i:i + a.shape[-1]]
|
||||
return o
|
||||
def gradw(a): return (np.roll(a, -1) - np.roll(a, 1)) / 2.0
|
||||
def _smooth(f): return f * f * (3 - 2 * f)
|
||||
def pn2(cell, seed):
|
||||
rng = np.random.default_rng(seed); n = max(int(round(W / cell)), 2); gh = int(H / cell) + 3
|
||||
g = rng.random((gh, n)).astype(np.float32)
|
||||
xs = ((np.arange(W) + ROT) % W) * (n / W); x0 = xs.astype(int); fx = _smooth(xs - x0).astype(np.float32); x1 = (x0 + 1) % n
|
||||
ys = np.arange(H) / cell; y0 = ys.astype(int); fy = _smooth(ys - y0).astype(np.float32)
|
||||
tp = g[y0][:, x0] * (1 - fx) + g[y0][:, x1] * fx; bt = g[y0 + 1][:, x0] * (1 - fx) + g[y0 + 1][:, x1] * fx
|
||||
return tp * (1 - fy)[:, None] + bt * fy[:, None]
|
||||
def pn1(cell, seed): return pn2(cell, seed)[0].astype(np.float64)
|
||||
def abscol(): return (np.arange(W) + ROT) % W
|
||||
|
||||
# ====================================================================== sweep (D), wrapped
|
||||
NH = 24 # samples of the face history kept per peak (valley -> crest, uniform in elevation angle)
|
||||
def strip_history(a, dl, hh, k0, k1):
|
||||
"""The marcher's record of one peak strip: the ray samples from the last local minimum (k0) to the peak (k1), reduced to what the eye sees (a sample hides
|
||||
behind any earlier, higher one: running maximum of the angle) and resampled at NH uniform steps of elevation angle. Returns (distance[NH], height[NH]), index 0 = valley, NH-1 = crest."""
|
||||
seg = np.asarray(a[k0:k1 + 1]); runmax = np.maximum.accumulate(seg); vis = np.concatenate([[True], runmax[1:] > runmax[:-1]])
|
||||
va = runmax[vis]; vd = np.asarray(dl[k0:k1 + 1])[vis]; vh = np.asarray(hh[k0:k1 + 1])[vis]
|
||||
if len(va) < 2 or va[-1] <= va[0]: return np.full(NH, vd[-1]), np.full(NH, vh[-1])
|
||||
t = np.linspace(va[0], va[-1], NH); return np.interp(t, va, vd), np.interp(t, va, vh)
|
||||
|
||||
def extract(d, ang, hgt, prom=PROM_M):
|
||||
"""Per column: (peak_ang, valley_ang, peak_d, peak_h, valley_h, valley_d) followed by the face history (NH distances, NH heights) of the strip valley -> peak.
|
||||
A peak commits when the ray has fallen hy[k] below it and it stands `prom` METRES above the lowest ground the strip has seen (the lowest height, not the height of the lowest-ANGLE sample: that one is often near and high)."""
|
||||
K, Wc = ang.shape; hy = np.degrees(prom / d).tolist(); dl = d.tolist(); cols = []
|
||||
for x in range(Wc):
|
||||
a = ang[:, x].tolist(); hh = hgt[:, x].tolist(); out = []
|
||||
def span(pk, lo, pd, ph, vh, vd, k0, k1): out.append((pk, lo, pd, ph, vh, vd) + tuple(np.concatenate(strip_history(a, dl, hh, k0, k1))))
|
||||
runmax = -90.0; mn = a[0]; mnh = hh[0]; mnd = dl[0]; mnk = 0; cand = a[0]; ch = hh[0]; cd = dl[0]; ck = 0; lowh = hh[0]; capped = False # NEAR_CAP is tried once, at the first sample past 4 km
|
||||
for k in range(1, K):
|
||||
v = a[k]; dk = dl[k]; hk = hh[k]; lowh = min(lowh, hk)
|
||||
if dk >= NEAR_CAP and not capped and not out and runmax < -80:
|
||||
capped = True; j = max(range(k), key=a.__getitem__)
|
||||
if hh[j] - lowh >= prom: span(a[j], mn, dl[j], hh[j], mnh, mnd, min(mnk, j), j); runmax = a[j]; mn = cand = v; mnh = ch = hk; mnd = cd = dk; mnk = ck = k; lowh = hk
|
||||
if v < mn: mn = cand = v; mnh = ch = hk; mnd = cd = dk; mnk = ck = k
|
||||
elif v > cand: cand = v; ch = hk; cd = dk; ck = k
|
||||
if cand - v >= hy[k] and ch - lowh >= prom:
|
||||
if cand > runmax: span(cand, mn, cd, ch, mnh, mnd, mnk, ck); runmax = cand
|
||||
mn = cand = v; mnh = ch = hk; mnd = cd = dk; mnk = ck = k; lowh = hk
|
||||
if ch - lowh >= prom and cand > runmax: span(cand, mn, cd, ch, mnh, mnd, mnk, ck)
|
||||
cols.append(out)
|
||||
return cols
|
||||
|
||||
def chains(cols):
|
||||
Wc = len(cols); parent = []; ids = []
|
||||
for sp in cols: ids.append(list(range(len(parent), len(parent) + len(sp)))); parent.extend(range(len(parent), len(parent) + len(sp)))
|
||||
def find(i):
|
||||
while parent[i] != i: parent[i] = parent[parent[i]]; i = parent[i]
|
||||
return i
|
||||
for x, sp in enumerate(cols):
|
||||
for i, s in zip(ids[x], sp):
|
||||
p, dd = s[0], s[2]; best = None
|
||||
for back in range(1, LOOK + 1):
|
||||
xb = (x - back) % Wc
|
||||
for j, s2 in zip(ids[xb], cols[xb]):
|
||||
ld = abs(math.log(dd / s2[2]))
|
||||
if ld < LINK_D and abs(p - s2[0]) < LINK_A * (1 + 0.5 * (back - 1)):
|
||||
sc = ld / LINK_D + abs(p - s2[0]) / LINK_A + 0.3 * back
|
||||
if best is None or sc < best[0]: best = (sc, j)
|
||||
if best: parent[find(i)] = find(best[1])
|
||||
out = {}
|
||||
for x, sp in enumerate(cols):
|
||||
for i, s in zip(ids[x], sp): out.setdefault(find(i), []).append((x,) + tuple(s))
|
||||
return list(out.values())
|
||||
|
||||
def chain_layer(c):
|
||||
"""Chain -> dense per-column arrays (deg / m). The chain is unrolled at its largest circular gap into an arc [0, x1] of local columns; every
|
||||
column outside the arc takes the value of the NEAREST arc end (start end or finish end) and then dies toward that end's valley over TAPER columns."""
|
||||
a = np.array(c); xs = a[:, 0].astype(int); u = np.unique(xs)
|
||||
gaps = np.diff(np.concatenate([u, [u[0] + W]])); start = int(u[(gaps.argmax() + 1) % len(u)]); xl = (xs - start) % W
|
||||
Pk, L, D, PH, VH, VD = (a[:, i] for i in range(1, 7)); HD = a[:, 7:7 + NH]; HH = a[:, 7 + NH:7 + 2 * NH]
|
||||
run = np.maximum(np.abs(D - VD), 300.0); slope = np.degrees(np.arctan2(np.maximum(PH - VH, 0), run))
|
||||
x1 = int(xl.max()); ii = np.arange(0, x1 + 1); res = {}; x = np.arange(W)
|
||||
near_end = np.where(x - x1 <= W - x, x1, 0) # outside the arc: which end is nearer going around the ring
|
||||
dist = np.where(x <= x1, 0, np.minimum(x - x1, W - x)).astype(float)
|
||||
def dense(v):
|
||||
full = np.full(W, np.nan); full[xl] = v; ok = ~np.isnan(full[ii]); full[ii] = np.interp(ii, ii[ok], full[ii][ok]); return np.where(x <= x1, full, full[near_end])
|
||||
for k, v in dict(top=Pk, lo=L, ph=PH, vh=VH, slope=slope, pd=D, vd=VD).items(): res[k] = dense(v)
|
||||
res['hd'] = np.stack([dense(HD[:, j]) for j in range(NH)]); res['hh'] = np.stack([dense(HH[:, j]) for j in range(NH)]) # the face history, one (NH, W) array each: column x carries the history of the peak that owns it
|
||||
drop = res['top'] - np.minimum(res['lo'], res['top'] - 0.4); Tn = taper_len(drop) # end drop in degrees -> taper length at this column
|
||||
t = np.clip(dist / Tn, 0, 1); t = t * t * (3 - 2 * t)
|
||||
res['top'] = res['top'] - drop * t
|
||||
res['wt'] = 1 - np.clip(dist / Tn, 0, 1)
|
||||
for k in ('top', 'lo', 'ph', 'vh', 'slope', 'wt', 'pd', 'vd', 'hd', 'hh'): res[k] = np.roll(res[k], start, axis=-1)
|
||||
res['dist'] = float(np.median(D)); res['n'] = len(c); res['x0'] = start; res['x1'] = (start + x1) % W; res['span'] = x1 + 1
|
||||
res['rise_ang'] = float(np.median(Pk - L)); res['rise_h'] = float(np.median(PH - VH)); res['slope_med'] = float(np.median(slope))
|
||||
return res
|
||||
|
||||
def col_gap(present):
|
||||
"""Circular distance in columns from each column to the nearest present column (0 where present)."""
|
||||
x = np.arange(3 * W); p = np.tile(present, 3); last = np.maximum.accumulate(np.where(p, x, -10 * W)); nxt = np.minimum.accumulate(np.where(p, x, 10 * W)[::-1])[::-1]
|
||||
return np.minimum(x - last, nxt - x)[W:2 * W].astype(float)
|
||||
|
||||
def to_slots(layers):
|
||||
slots = {}
|
||||
for L in layers: slots.setdefault(int(np.argmin(np.abs(np.log(KNOTS) - math.log(L['dist'])))), []).append(L)
|
||||
out = []
|
||||
for s in sorted(slots):
|
||||
g = slots[s]; top = np.stack([np.where(l['wt'] > 0, l['top'], -1e3 - col_gap(l['wt'] > 0)) for l in g]); j = top.argmax(0) # only chains that are present in a column compete; where none is, the chain whose extent is NEAREST stands (a geometric rule: the list order of chains depends on where column 0 is, and absent-column values feed the blurs at every chain end)
|
||||
S = dict(slot=s, dist=float(KNOTS[s]), chains=len(g), dists=[l['dist'] for l in g])
|
||||
for k in ('top', 'lo', 'ph', 'vh', 'slope', 'wt', 'pd', 'vd'): S[k] = np.take_along_axis(np.stack([l[k] for l in g]), j[None], 0)[0]
|
||||
for k in ('hd', 'hh'): S[k] = np.take_along_axis(np.stack([l[k] for l in g]), j[None, None], 0)[0]
|
||||
out.append(S)
|
||||
return out
|
||||
|
||||
def sweep(d, ang, hgt, prom=PROM_M):
|
||||
t0 = time.time(); cols = extract(d, ang, hgt, prom); t1 = time.time()
|
||||
ch = chains(cols); near = lambda c: np.median([t[3] for t in c]) < FOREGROUND_M; keep = [c for c in ch if len(c) >= MINW]
|
||||
def arc(c):
|
||||
u = np.unique([t[0] for t in c]); g = np.diff(np.concatenate([u, [u[0] + W]])); i = g.argmax(); st = u[(i + 1) % len(u)]; return (st + np.arange((u[i] - st) % W + 1)) % W # the columns a chain spans, unrolled at its largest gap
|
||||
cov = np.zeros(W, bool)
|
||||
for c in keep:
|
||||
if near(c): cov[(arc(c)[:, None] + np.arange(-MINW // 4, MINW // 4 + 1)[None, :]).ravel() % W] = True # dilated by MINW/4 columns: a stub that occludes a near ridge for a few columns sits inside it
|
||||
for c in sorted((c for c in ch if len(c) < MINW and near(c)), key=lambda c: -len(c)): # widest first: a stub is judged against the stubs already kept
|
||||
xs = arc(c)
|
||||
if not cov[xs].any(): keep.append(c); cov[xs] = True
|
||||
lay = [chain_layer(c) for c in keep]; slots = to_slots(lay); t2 = time.time()
|
||||
return dict(cols=cols, nchains=len(ch), keep=lay, slots=slots, raw=sum(len(c) for c in cols), t_extract=t1 - t0, t_chain=t2 - t1)
|
||||
|
||||
# ====================================================================== the authored Y mapping
|
||||
def set_view(slots, sky_row=0.04, near_row=0.66):
|
||||
"""AUTHORED vertical mapping, linear in elevation angle: row = (TOP_DEG - angle) * PXDEG_V over the whole 600-row frame (cylindrical, horizontal 17.78 px/deg).
|
||||
Two anchors: the highest ridge top in the window sits at `sky_row` (4%) of the frame height, the LOWEST crest of the nearest layer in the window sits at `near_row` (66%);
|
||||
everything below is that layer's body, its foot, and the ground-plane band. PXDEG_V = (near_row - sky_row) * 600 / (highest top - lowest near crest), clamped to
|
||||
[17.78, 178] px/deg = vertical exaggeration 1x..10x. TOP_DEG = highest top + sky_row*600/PXDEG_V; BOTTOM_DEG = TOP_DEG - 600/PXDEG_V; horizon (angle 0) at row TOP_DEG*PXDEG_V."""
|
||||
global rowang, PXDEG_V, TOP_DEG, BOTTOM_DEG
|
||||
cols = (P['win0'] + np.arange(WIN)) % W; sky = max(float(S['top'][cols][S['wt'][cols] > 0].max()) for S in slots if (S['wt'][cols] > 0).any())
|
||||
near = slots[-1]; act = near['wt'][cols] > 0; c_low = float(blurw(near['top'], 3.0)[cols][act].min()) if act.any() else sky - 2.0
|
||||
ppd = float(np.clip((near_row - sky_row) * H / max(sky - c_low, 0.05), HPX_DEG, 10 * HPX_DEG))
|
||||
PXDEG_V = ppd; TOP_DEG = sky + sky_row * H / ppd; BOTTOM_DEG = TOP_DEG - H / ppd; rowang = TOP_DEG - (np.arange(H) + 0.5) / PXDEG_V
|
||||
return dict(top_deg=TOP_DEG, bottom_deg=BOTTOM_DEG, px_per_deg=PXDEG_V, exaggeration=PXDEG_V / HPX_DEG, horizon_row=TOP_DEG * PXDEG_V, sky_deg=sky, near_crest_low_deg=c_low,
|
||||
sky_row=sky_row, near_row=near_row)
|
||||
|
||||
# ====================================================================== bake (sun-independent), per layer
|
||||
def azrad(): return (abscol() + 0.5) / W * 2 * math.pi
|
||||
|
||||
def face_field(hd, hh, ang, lo, pd, ph, vd, vh):
|
||||
"""Per-pixel face from the marcher's history (hd, hh: NH distances / heights per column, valley -> crest). A pixel at elevation `rowang` of column x sits at fraction
|
||||
f = (rowang - lo) / (ang - lo) of the strip; its distance and height are the history interpolated at f. Front slope s = dh/dd along the ray comes from the history over +-2 nodes
|
||||
(the strip's mean slope where the window has no run); the lateral tilt is the height change between the neighbouring columns at the SAME pixel row, corrected for the radial
|
||||
step (c - a*s) / (tangential length). n ~ (-s r - la t, 1). Returns Z, D, s, la, f as (H, W) arrays."""
|
||||
f = np.clip((rowang[:, None] - lo[None, :]) / np.maximum(ang - lo, 1e-3)[None, :], 0, 1); pos = f * (NH - 1); i0 = np.minimum(pos.astype(int), NH - 2); t = pos - i0; xs = np.arange(W)[None, :]
|
||||
lerp = lambda A: A[i0, xs] * (1 - t) + A[i0 + 1, xs] * t
|
||||
lo_i, hi_i = np.clip(np.arange(NH) - 2, 0, NH - 1), np.clip(np.arange(NH) + 2, 0, NH - 1)
|
||||
dd = hd[hi_i] - hd[lo_i]; mean_s = ((ph - vh) / np.maximum(np.abs(pd - vd), 300.0))[None, :]
|
||||
sn = np.where(dd >= 20.0, (hh[hi_i] - hh[lo_i]) / np.maximum(dd, 20.0), mean_s)
|
||||
Z, Dp, sl = lerp(hh), lerp(hd), lerp(sn)
|
||||
a_ = np.roll(Dp, -1, axis=1) - np.roll(Dp, 1, axis=1); c_ = np.roll(Z, -1, axis=1) - np.roll(Z, 1, axis=1)
|
||||
b_ = np.maximum(2 * COLW * Dp, 1.0); la = np.where(np.abs(a_) > 3 * b_, 0.0, np.clip((c_ - a_ * sl) / b_, -1.5, 1.5)) # neighbours at very different distances are an occlusion edge, not a surface: no tilt
|
||||
return Z.astype(np.float32), Dp, blurw(sl, 2.0).astype(np.float32), blurw(la, 2.0).astype(np.float32), f
|
||||
|
||||
def bake_layer(S, rank, far):
|
||||
b = S['slot']; base = RAMP[b]; reg = int(round((rank - 2) * 1.5)); R_ = lambda v: np.roll(v, reg, axis=-1); px = 1.0 / PXDEG_V
|
||||
s_ref = P.get('s_ref_deg', 8.0)
|
||||
ang = blurw(S['top'], 2.5) + (pn1(38, 100 + b) - 0.5) * 6 * px + (pn1(7, 200 + b) - 0.5) * 2 * px; ang = R_(ang) # edge wobble authored in pixels
|
||||
lo = R_(blurw(S['lo'], 2.5)); ph = R_(blurw(S['ph'], 2.0)); vh = R_(blurw(S['vh'], 2.0))
|
||||
sn = np.clip(R_(blurw(S['slope'], 3.0)) / s_ref, 0, 1); wt = R_(S['wt'])
|
||||
hd = R_(blurw(S['hd'], 2.0)); hhist = R_(blurw(S['hh'], 2.0)); pd_ = R_(blurw(S['pd'], 2.0)); vd_ = R_(blurw(S['vd'], 2.0))
|
||||
top = ang[None, :] - rowang[:, None]; toppx = (top * PXDEG_V).astype(np.float32)
|
||||
m = np.clip(toppx / 1.6 + 0.5, 0, 1) * np.clip(blurw((wt > 0).astype(float), 1.0) * 1.5, 0, 1)[None, :] # a layer is drawn only where a chain exists (or tapers)
|
||||
rise_px = np.clip((ang - blurw(lo, 12.0)) * PXDEG_V, 1, None) # foot length from a smoothed valley: no per-column curtains
|
||||
foot = np.clip(0.85 * rise_px, 28 * np.clip(wt, 0, 1), 210).clip(1) # a tapering end carries no 28 px curtain: the body shrinks with the chain's weight, so a chain end leaves no stub
|
||||
depthpx = np.clip(toppx / foot[None], 0, 1)
|
||||
fade = np.clip((depthpx - 0.6) / 0.4, 0, 1); fade = fade * fade * (3 - 2 * fade); alpha = (m * (1 - fade)).astype(np.float32) # the body dissolves at its foot: what is behind (other layers, floor, sky) shows, so a column boundary leaves no wall down the frame
|
||||
w = (depthpx ** 0.8 * 0.92).astype(np.float32) # weight of the foot colour (floor/haze mix, decided at relight)
|
||||
face = np.broadcast_to((base * 1.12).astype(np.float32), (H, W, 3)).copy()
|
||||
Z, Dp, sl, la, f = face_field(hd, hhist, ang, lo, pd_, ph, vd_, vh)
|
||||
snp = np.clip(np.degrees(np.arctan(sl)) / s_ref, 0, 1) # per-pixel face steepness from the history (the column scalar `sn` stays for stroke weights only)
|
||||
face *= (1.07 - 0.22 * snp)[..., None]
|
||||
mot = 0.55 * pn2(22, 300 + b) + 0.45 * pn2(5, 400 + b)
|
||||
face *= (0.90 + 0.18 * mot[..., None]) * (0.96 + 0.08 * pn2(1, 500 + b)[..., None])
|
||||
echo = np.clip(((ang - 24 * px * (1 + rank * .3))[None, :] - rowang[:, None]) * PXDEG_V / 1.6 + .5, 0, 1)
|
||||
face *= (1 - 0.13 * echo[..., None] * (1 - depthpx[..., None] * .5))
|
||||
ink = base * 0.42 + np.array([0.02, 0.03, 0.08])
|
||||
ew = (0.7 + 1.8 * snp) * (1 - 0.35 * far)
|
||||
edge = np.clip(1 - np.abs(toppx - 0.6 - ew * 0.5) / ew, 0, 1) * (toppx > -0.5)
|
||||
ea = 0.55 * (1 - 0.45 * far) * edge * (0.4 + 0.6 * snp)
|
||||
dzdpx = np.maximum(np.abs(np.roll(Z, -1, axis=0) - np.roll(Z, 1, axis=0)) / 2.0, 1e-3); dzdpx[0] = dzdpx[1]; dzdpx[-1] = dzdpx[-2] # metres of true height per pixel row, per pixel
|
||||
faceok = (f > 0.02) & (f < 0.98) & (wt[None, :] > 0.5)
|
||||
cm = next((c for c in CONTOUR_NICE if c >= CONTOUR_MIN_PX * float(np.percentile(dzdpx[faceok], 75) if faceok.any() else 3.0)), CONTOUR_NICE[-1]) # metres per contour line: lines >= 9 px apart over three quarters of this layer's face
|
||||
p_ = (Z / cm) % 1.0; dpx = (np.minimum(p_, 1 - p_) * cm / dzdpx).astype(np.float32)
|
||||
face_mask = ((top > 0.003) & (rowang[:, None] > lo[None, :])).astype(np.float32)
|
||||
ca = (0.95 * (1 - 0.45 * far) * (1 - depthpx) ** 2.0 * wt[None, :] * (0.6 + 0.4 * snp) * face_mask).astype(np.float32)
|
||||
hatch = np.zeros((H, W), np.float32)
|
||||
if far < 0.8:
|
||||
rng = np.random.default_rng(700 + b); xs = np.cumsum(rng.integers(2, 7, W // 2)); xs = xs[xs < W]
|
||||
up = gradw(ang) * PXDEG_V
|
||||
for i, xa in enumerate(xs):
|
||||
x = (xa - ROT) % W; u1, u2 = rng.random(2)
|
||||
if u1 > 0.30 + 0.70 * sn[x] or wt[x] < 0.5: continue
|
||||
Lpx = (8 + 50 * sn[x] ** 0.7) * (0.55 + 0.7 * u2) * (1 - 0.5 * far)
|
||||
y0 = (rowang[0] - ang[x]) * PXDEG_V; yy = np.arange(H); t = (yy - y0 - 2) / max(Lpx, 1)
|
||||
ok = (t >= 0) & (t < 1); xx = (x + np.round(-np.clip(up[x] * 0.25, -0.6, 0.6) * (yy - y0)).astype(int)) % W
|
||||
hatch[yy[ok], xx[ok]] = np.maximum(hatch[yy[ok], xx[ok]], (1 - t[ok]) ** 1.5)
|
||||
ha = np.clip(ea + hatch * 0.50 * (1 - 0.4 * far), 0, 1).astype(np.float32)
|
||||
rb = (np.clip(1 - toppx / 7.0, 0, 1) * (toppx > -0.5)).astype(np.float32)
|
||||
az = azrad(); dd = np.stack([S['vd'], 0.5 * (S['vd'] + S['pd']), S['pd']]); hh = np.stack([S['vh'], 0.5 * (S['vh'] + S['ph']), S['ph']]) + 3.0 # face samples: valley, middle, crest
|
||||
spts = dict(lat=np.roll(P['lat'] + dd * np.cos(az) / 111320.0, reg, axis=1), lon=np.roll(P['lon'] + dd * np.sin(az) / (111320.0 * math.cos(math.radians(P['lat']))), reg, axis=1), h=np.roll(hh, reg, axis=1))
|
||||
return dict(m=m.astype(np.float32), a=alpha, face=face, w=w, dpx=dpx, ca=ca, ha=ha, rb=rb, mot=mot.astype(np.float32), sl=sl, la=la, ink=ink, far=far, dist=S['dist'], rank=rank, ang=ang, lo=lo, spts=spts)
|
||||
|
||||
def bake(slots):
|
||||
return [bake_layer(S, rank, float(np.clip((math.log(S['dist']) - math.log(2000)) / (math.log(70000) - math.log(2000)), 0, 1))) for rank, S in enumerate(slots)]
|
||||
|
||||
# ====================================================================== cast shadows (sun-dependent): DEM march toward the sun from each layer's face samples
|
||||
SH_STEPS = 40.0 * 1.1 ** np.arange(0, 66) # 40 m .. 20 km, geometric
|
||||
def cast_shadows(layers, az_s, el_s):
|
||||
"""Per layer a (3, W) array in [0,1]: how much of the sun is blocked by the real DEM at that layer's valley / middle / crest sample. One vectorised march for all
|
||||
layers. Soft: the blocked fraction is the terrain excess over the sun ray, over a penumbra of 6 m + 3% of the distance travelled. Sun at or below 0.3 deg: no direct light, no shadow."""
|
||||
if el_s <= 0.3: return [np.zeros((3, W)) for _ in layers]
|
||||
m, lt, ll = mosaic(P); lat = np.concatenate([Ly['spts']['lat'].ravel() for Ly in layers]); lon = np.concatenate([Ly['spts']['lon'].ravel() for Ly in layers]); h = np.concatenate([Ly['spts']['h'].ravel() for Ly in layers])
|
||||
t = math.tan(math.radians(el_s)); cosl = math.cos(math.radians(P['lat'])); ca, sa = math.cos(math.radians(az_s)), math.sin(math.radians(az_s)); sh = np.zeros_like(h)
|
||||
for s in SH_STEPS:
|
||||
terr = bil(m, lt, ll, lat + s * ca / 111320.0, lon + s * sa / (111320.0 * cosl)) - s * s / (2 * R_EARTH)
|
||||
sh = np.maximum(sh, np.clip((terr - (h + s * t)) / (6.0 + 0.03 * s), 0, 1))
|
||||
return [a.reshape(3, W) for a in np.split(sh, len(layers))]
|
||||
|
||||
# ====================================================================== relight (sun-dependent)
|
||||
TAU_Z = np.array([0.09, 0.14, 0.30]); BETA_R = np.array([0.0058, 0.0135, 0.0331]); BETA_M = 0.023; ATM = 0.15
|
||||
ATM_FLOOR = 1.2 # authored: valley haze over the ground plane is heavier than the layer extinction ATM, so the floor thins into haze gradually
|
||||
G_MIE, W_MIE = 0.7, 0.10; REF = dict(az=180.0, el=62.0); REF_AMB = np.array([0.62, 0.74, 1.0])
|
||||
def airmass(el): e = max(el, 0.3); return 1.0 / (math.sin(math.radians(e)) + 0.50572 * (e + 6.07995) ** -1.6364)
|
||||
def sun_colour(el): return np.exp(-TAU_Z * airmass(el))
|
||||
def phase(c):
|
||||
pr = 3.0 / (16 * math.pi) * (1 + c * c); pm = (1 - G_MIE ** 2) / (4 * math.pi * (1 + G_MIE ** 2 - 2 * G_MIE * c) ** 1.5)
|
||||
return (1 - W_MIE) * pr + W_MIE * pm
|
||||
def sun_vec(az, el): a, e = math.radians(az), math.radians(el); return np.array([math.sin(a) * math.cos(e), math.cos(a) * math.cos(e), math.sin(e)])
|
||||
def EX_REF_NORM(Ec, I, amb_k): return float((0.45 * REF_AMB * amb_k + 0.75 * Ec * I * 0.7).mean()) / 0.87
|
||||
def sun_state(az, el):
|
||||
Ec = sun_colour(el) / sun_colour(REF['el']); I = (max(math.sin(math.radians(el)), 0.0) / math.sin(math.radians(REF['el']))) ** 0.5
|
||||
tw = 0.30 * float(np.clip((el + 6) / 8, 0, 1)) ** 2; amb_k = 0.12 + 0.88 * float(np.clip((el + 3) / 45, 0, 1)) ** 0.7
|
||||
ex = 0.45 + 0.55 * float(np.clip((el + 4) / 18, 0, 1)) ** 0.6
|
||||
return dict(az=az, el=el, L=sun_vec(az, el), sun=Ec * I, sky_sun=Ec * (I + tw), amb=REF_AMB * amb_k, amb_k=amb_k, ex=ex / EX_REF_NORM(Ec, I, amb_k), ex_t=ex)
|
||||
def _g0():
|
||||
st = sun_state(REF['az'], REF['el']); d = 0.7; g = (0.45 * st['amb'] + 0.75 * st['sun'] * d) * (np.array([0.90, 0.92, 1.04]) + (np.array([1.03, 1.0, 0.95]) - np.array([0.90, 0.92, 1.04])) * d)
|
||||
return g / g.mean()
|
||||
G0 = _g0()
|
||||
def col_scatter(st):
|
||||
az = azrad(); c = math.cos(math.radians(st['el'])) * np.cos(az - math.radians(st['az']))
|
||||
pr = np.array([phase(float(x)) for x in c]); g = np.clip((pr / phase(0.0)) ** 0.35, 0.5, 2.4)
|
||||
rs = sun_state(REF['az'], REF['el']); ref = 0.8 * rs['sky_sun'] + 0.35 * rs['amb']; ref = ref / ref.mean()
|
||||
col = 0.8 * st['sky_sun'] + 0.35 * st['amb']; col = col / col.mean()
|
||||
return c, g[:, None] * (col / ref)[None, :]
|
||||
|
||||
def hue_gain(st, d):
|
||||
lit_tint = np.array([1.03, 1.0, 0.95]); shd_tint = np.array([0.90, 0.92, 1.04])
|
||||
tint = shd_tint + (lit_tint - shd_tint) * d
|
||||
return (0.45 * st['amb'] + 0.75 * st['sun'] * d) * tint / G0 * st['ex']
|
||||
|
||||
def floor_img(st, hazec, fmot):
|
||||
"""Ground-plane band: below the horizon a flat floor `floor_agl_m` below the eye; plane distance dg = h / tan(depression); colour = ground albedo lit by an
|
||||
up-facing normal, thinned into haze by the same Beer-Lambert extinction as the layers. Above the horizon the same array is the haze (sky covers it)."""
|
||||
a = rowang; dg = P['floor_agl_m'] / np.tan(np.radians(np.clip(-a, 0.002, 89.0)))
|
||||
T = np.exp(-(BETA_R + BETA_M)[None, :] * (dg / 1000.0)[:, None] * ATM_FLOOR); T[a >= 0] = 0.0
|
||||
ground = (GROUND * hue_gain(st, float(np.clip((math.sin(math.radians(st['el'])) + 0.2) / 1.2, 0, 1))))
|
||||
return ground[None, None, :] * fmot[..., None] * T[:, None, :] + hazec[None, :, :] * (1 - T)[:, None, :]
|
||||
|
||||
def ndl_field(Ly, L):
|
||||
"""n . L per pixel from the baked face: n ~ (-sl r - la t, 1), r / t the column's radial / tangential directions. Returns (ndl, |n|-normaliser)."""
|
||||
az = azrad(); sa, ca_ = np.sin(az), np.cos(az); sl, la = Ly['sl'], Ly['la']
|
||||
Lr = (L[0] * sa + L[1] * ca_)[None, :]; Lt = (L[0] * ca_ - L[1] * sa)[None, :]; nrm = np.sqrt(1 + sl * sl + la * la)
|
||||
return (L[2] - sl * Lr - la * Lt) / nrm, nrm
|
||||
|
||||
RELIEF_OFF_DEG, RELIEF_EL = 45.0, 32.0 # authored relief light: RELIEF_OFF_DEG off the view axis, RELIEF_EL up
|
||||
def win_centre_az(): return (P['win0'] + WIN / 2) / W * 360.0
|
||||
def relief_vec(sun_az):
|
||||
"""The authored relief light direction (toward the light). The real sun lights a face the viewer looks at only when it is off to one side; with the sun behind the viewer every face is
|
||||
head-on and the mountain goes flat. Shading n.L (ndl_field, rim, spec) therefore uses this fixed direction: azimuth = the view centre az +/- RELIEF_OFF_DEG, on the left/right side of the view axis
|
||||
that the real sun is on (so the two agree where they can), RELIEF_EL up. Sun colour/strength, haze, colour temperature and cast shadows keep the real sun:
|
||||
the relief only decides where on the form the sun's light falls."""
|
||||
ctr = win_centre_az(); side = 1.0 if math.sin(math.radians(sun_az - ctr)) >= 0 else -1.0
|
||||
return sun_vec(ctr + side * RELIEF_OFF_DEG, RELIEF_EL)
|
||||
|
||||
def fresnel(sl, nrm): return np.clip(1 - sl / nrm, 0, 1) ** 3 # (1 - n . V)^3, V = -r: strongest on faces that lie away from the viewer
|
||||
def spec_field(L, sl, la, nrm, ndl):
|
||||
"""GGX-like highlight (alpha 0.3) of the face (sl, la, nrm as in ndl_field) for the light L: half vector between L and the direction back to the viewer (-r) in every column."""
|
||||
az = azrad(); sa, ca_ = np.sin(az), np.cos(az)
|
||||
Hv = L[None, :] + np.stack([-sa, -ca_, np.zeros(W)], 1); Hv /= np.linalg.norm(Hv, axis=1, keepdims=True)
|
||||
Hr = (Hv[:, 0] * sa + Hv[:, 1] * ca_)[None, :]; Ht = (Hv[:, 0] * ca_ - Hv[:, 1] * sa)[None, :]
|
||||
nh = np.clip((Hv[:, 2][None, :] - sl * Hr - la * Ht) / nrm, 0, 1); al = 0.30
|
||||
D = (al * al) / (1 + nh * nh * (al * al - 1)) ** 2 * al * al
|
||||
return 0.30 * D * np.clip(ndl, 0, 1)
|
||||
|
||||
def relight_layer(Ly, st, hazec, F, sh3=None):
|
||||
"""Shading per PIXEL (ndl_field), not per column. n.L uses the relief light; the sun's colour, strength (st['sun'] scales every direct term, so night and dusk stay dark) and shadows are the real sun's."""
|
||||
L = relief_vec(st['az']); az = azrad(); sa, ca_ = np.sin(az), np.cos(az); sl, la = Ly['sl'], Ly['la']
|
||||
ndl, nrm = ndl_field(Ly, L); d = np.clip((ndl + 0.2) / 1.2, 0, 1)
|
||||
gain = hue_gain(st, d[..., None])
|
||||
rim_dir = (np.stack([sa, ca_], 1) * L[None, :2]).sum(1)[None, :]
|
||||
fres = fresnel(sl, nrm)
|
||||
rim = (0.12 + 1.0 * np.clip(rim_dir, 0, 1)) * fres
|
||||
spec = spec_field(L, sl, la, nrm, ndl)
|
||||
if sh3 is None: sp = np.zeros((1, 1), np.float32); face = Ly['face'] * gain.astype(np.float32)
|
||||
else:
|
||||
sh3 = blurw(sh3, 2.0); f = np.clip((rowang[:, None] - Ly['lo'][None, :]) / np.maximum(Ly['ang'] - Ly['lo'], 1e-3)[None, :], 0, 1) # position on the face: 0 valley .. 1 crest
|
||||
sp = np.where(f < 0.5, sh3[0] + (sh3[1] - sh3[0]) * 2 * f, sh3[1] + (sh3[2] - sh3[1]) * 2 * (f - 0.5)).astype(np.float32)
|
||||
g_amb = hue_gain(st, 0.0)[None, None, :]; face = Ly['face'] * (g_amb + (gain - g_amb) * (1 - sp)[..., None]).astype(np.float32) # shadow removes the direct share only
|
||||
fa = 1 - math.exp(-Ly['dist'] / 20000.0) # valley mist grows with the layer's distance
|
||||
foot = F * (1 - fa) + hazec[None] * fa
|
||||
wf = Ly['w'] * (1 - 0.6 * fa) # far layers keep 60% of their own body colour at the foot
|
||||
wf = 1 - (1 - wf) * np.clip(1 + rowang * PXDEG_V / 40.0, 0, 1)[:, None] # below the horizon every layer's foot has merged into the ground plane within 40 px: no per-slot colour seams down the floor
|
||||
face = face * (1 - wf[..., None]) + foot * wf[..., None]
|
||||
face += (Ly['rb'] * (rim * 0.55) * (1 - sp))[..., None] * (st['sun'] * 1.1 + 0.25)[None, None, :]
|
||||
face += ((spec * (0.5 + Ly['mot'])) * (Ly['m'] > 0.5) * (1 - sp))[..., None] * st['sun'][None, None, :] * 0.5
|
||||
lw = (0.70 + 0.90 * (1 - d)) * (1 - 0.30 * Ly['far'])
|
||||
line = np.clip(1 - Ly['dpx'] / lw, 0, 1) * Ly['ca']
|
||||
ia = np.clip(line + Ly['ha'], 0, 1)
|
||||
ink = Ly['ink'][None, None, :] * (0.6 + 0.4 * gain.mean(-1))[..., None]
|
||||
face = face * (1 - ia[..., None]) + ink * ia[..., None]
|
||||
T = np.exp(-(BETA_R + BETA_M) * Ly['dist'] / 1000.0 * ATM)
|
||||
return face * T[None, None, :] + hazec[None, :, :] * (1 - T)[None, None, :]
|
||||
|
||||
def sky_hz(st, paperf):
|
||||
az = azrad(); c, hg = col_scatter(st)
|
||||
hz = hx('#f5e2c8')[None, :] * hg * st['ex_t']
|
||||
tint = (st['amb'] / st['amb'].mean()) / (REF_AMB / REF_AMB.mean()); zen = hx('#a9bcd6')[None, :] * tint[None, :] * st['ex_t']
|
||||
u = np.clip(rowang / max(TOP_DEG, 2.5), 0, 1)[:, None] ** 0.7
|
||||
img = zen[None] * u[..., None] + hz[None] * (1 - u)[..., None]
|
||||
ev = np.radians(rowang)[:, None]; cs = np.cos(ev) * math.cos(math.radians(st['el'])) * np.cos(az - math.radians(st['az']))[None, :] + np.sin(ev) * math.sin(math.radians(st['el']))
|
||||
ang = np.degrees(np.arccos(np.clip(cs, -1, 1)))
|
||||
glow = 0.95 * np.exp(-(ang / 4.5) ** 2) + 0.55 * np.exp(-(ang / 22.0) ** 2)
|
||||
img = img + glow[..., None] * (st['sky_sun'] * 0.8 * st['ex_t'])[None, None, :]
|
||||
return img * paperf[..., None] / 0.93, hz
|
||||
|
||||
def relight(layers, az, el, paperf, grain, fmot, shadows=True):
|
||||
st = sun_state(az, el); shs = cast_shadows(layers, az, el) if shadows else [None] * len(layers); sky, hz = sky_hz(st, paperf)
|
||||
hazec = hz * (hx('#cdd7e7') / hx('#f5e2c8'))[None, :]
|
||||
F = floor_img(st, hazec, fmot) * (paperf[..., None] / 0.93)
|
||||
img = np.where((rowang >= 0)[:, None, None], sky, F).astype(np.float32)
|
||||
for Ly, sh3 in zip(layers, shs):
|
||||
face = relight_layer(Ly, st, hazec.astype(np.float32), F.astype(np.float32), sh3); face *= (paperf[..., None] / 0.93)
|
||||
img = img * (1 - Ly['a'][..., None]) + face * Ly['a'][..., None]
|
||||
return img * grain[..., None]
|
||||
|
||||
def statics():
|
||||
paperf = 0.93 + 0.09 * (0.5 * pn2(2, 11) + 0.3 * pn2(9, 12) + 0.2 * pn2(40, 13)) + 0.03 * (pn2(1, 14) - .5)
|
||||
return paperf, 0.95 + 0.07 * pn2(1, 900), 0.88 + 0.2 * (0.6 * pn2(30, 810) + 0.4 * pn2(7, 811))
|
||||
|
||||
def debug_spans(cols, win0=None, w=WIN):
|
||||
win0 = P['win0'] if win0 is None else win0
|
||||
img = np.ones((H, w, 3)) * 0.97; ramp = [hx(c) for c in ('#d03030', '#e8a020', '#60b040', '#2090b0', '#6030c0')]
|
||||
order = sorted(((s[2], i, s[0], s[1]) for i in range(w) for s in cols[(win0 + i) % W]), key=lambda t: -t[0])
|
||||
for dd, x, p, l in order:
|
||||
t = np.clip((math.log(dd) - math.log(500)) / (math.log(95000) - math.log(500)), 0, 1) * 4
|
||||
i = min(int(t), 3); col = mix(ramp[i], ramp[i + 1], t - i)
|
||||
y0 = int(np.clip((TOP_DEG - p) * PXDEG_V, 0, H - 1)); y1 = int(np.clip((TOP_DEG - max(l, p - 3.0)) * PXDEG_V, 0, H - 1))
|
||||
img[y0:y1 + 1, x] = col
|
||||
return img
|
||||
|
||||
def apply_view(v):
|
||||
"""re-apply a view returned by set_view (used by the rotation test and the before/after comparison so that frames line up exactly)"""
|
||||
global rowang, PXDEG_V, TOP_DEG, BOTTOM_DEG
|
||||
PXDEG_V = v['px_per_deg']; TOP_DEG = v['top_deg']; BOTTOM_DEG = v['bottom_deg']; rowang = TOP_DEG - (np.arange(H) + 0.5) / PXDEG_V
|
||||
29
scripts/ridge-bake/sun.py
Normal file
29
scripts/ridge-bake/sun.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""Solar position: NOAA general solar position formulas (Spencer/fractional-year low precision: equation of time and declination Fourier series,
|
||||
hour angle, zenith/azimuth), https://gml.noaa.gov/grad/solcalc/solareqns.PDF . Accuracy ~0.1 deg, plenty for a relight. Apparent elevation adds
|
||||
Saemundsson refraction (Meeus ch.16). UTC in, degrees out. Azimuth clockwise from north."""
|
||||
import math
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
def solar(dt_utc, lat, lon):
|
||||
doy = dt_utc.timetuple().tm_yday; hr = dt_utc.hour + dt_utc.minute / 60 + dt_utc.second / 3600
|
||||
g = 2 * math.pi / 365 * (doy - 1 + (hr - 12) / 24)
|
||||
eq = 229.18 * (0.000075 + 0.001868 * math.cos(g) - 0.032077 * math.sin(g) - 0.014615 * math.cos(2 * g) - 0.040849 * math.sin(2 * g))
|
||||
de = (0.006918 - 0.399912 * math.cos(g) + 0.070257 * math.sin(g) - 0.006758 * math.cos(2 * g) + 0.000907 * math.sin(2 * g)
|
||||
- 0.002697 * math.cos(3 * g) + 0.00148 * math.sin(3 * g))
|
||||
tst = hr * 60 + eq + 4 * lon # true solar time, minutes
|
||||
ha = math.radians(tst / 4 - 180); la = math.radians(lat)
|
||||
sin_el = math.sin(la) * math.sin(de) + math.cos(la) * math.cos(de) * math.cos(ha)
|
||||
el = math.degrees(math.asin(sin_el))
|
||||
az = (math.degrees(math.atan2(math.sin(ha), math.cos(ha) * math.sin(la) - math.tan(de) * math.cos(la))) + 180) % 360
|
||||
ref = 1.02 / math.tan(math.radians(el + 10.3 / (el + 5.11))) / 60 if el > -5 else 0.0
|
||||
return az, el + ref, el, math.degrees(de), eq
|
||||
|
||||
def find_el(day, lat, lon, target, lo_h, hi_h):
|
||||
"""bisect the UTC hour in [lo_h, hi_h] where apparent elevation crosses `target`."""
|
||||
f = lambda h: solar(day + timedelta(hours=h), lat, lon)[1] - target
|
||||
a, b = lo_h, hi_h; fa = f(a)
|
||||
for _ in range(50):
|
||||
m = (a + b) / 2; fm = f(m)
|
||||
if (fa < 0) == (fm < 0): a, fa = m, fm
|
||||
else: b = m
|
||||
return (a + b) / 2
|
||||
65
scripts/ridge-bake/tests/mutate.py
Normal file
65
scripts/ridge-bake/tests/mutate.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""Mutation check: re-introduce a cause into a COPY of the baker, run the fixtures against it, and require that at least one fails.
|
||||
venv/bin/python tests/mutate.py (prints one line per mutant; exit 1 if a mutant survives)"""
|
||||
import os, shutil, subprocess, sys, tempfile
|
||||
HERE = os.path.dirname(os.path.abspath(__file__)); SRC = os.path.join(HERE, '..')
|
||||
MUTANTS = {
|
||||
'round-1 bug: both chain sides take the END value (near_end always x1)': ("near_end = np.where(x - x1 <= W - x, x1, 0)", "near_end = np.where(x - x1 <= W - x, x1, x1)"),
|
||||
'linking does not wrap (look-back columns clipped at 0)': ("xb = (x - back) % Wc", "xb = max(x - back, 0)"),
|
||||
'a chain absent from a column still competes in the slot union (held valley value wins)': ("np.where(l['wt'] > 0, l['top'], -1e3 - col_gap(l['wt'] > 0))", "l['top']"),
|
||||
'shadow march goes AWAY from the sun': ("lat + s * ca / 111320.0, lon + s * sa", "lat - s * ca / 111320.0, lon - s * sa"),
|
||||
'shadow ignores the sun elevation (ray stays at the sample height)': ("(h + s * t)", "(h + 0 * t)"),
|
||||
'taper length ignores the drop (always the 8-column minimum)': ("Tn = taper_len(drop)", "Tn = np.full_like(drop, 8.0)"),
|
||||
'shadow march mirrors east/west (longitude sign)': ("lon + s * sa / (111320.0 * cosl)", "lon - s * sa / (111320.0 * cosl)"),
|
||||
'shadow longitude scale ignores cos(lat)': ("lon + s * sa / (111320.0 * cosl)", "lon + s * sa / 111320.0"),
|
||||
'shadow lightens instead of darkens the face': ("(gain - g_amb) * (1 - sp)[..., None]", "(gain - g_amb) * sp[..., None]"),
|
||||
'taper cap lowered to 8 columns': ("TAPER_K, TAPER_MAX = 30.0, 72", "TAPER_K, TAPER_MAX = 30.0, 8"),
|
||||
'taper per degree lowered to 2': ("TAPER_K, TAPER_MAX = 30.0, 72", "TAPER_K, TAPER_MAX = 2.0, 72"),
|
||||
'taper floor ignores the chain end (top held, no fall)': ("res['top'] = res['top'] - drop * t", "res['top'] = res['top']"),
|
||||
'painter shades one normal per column (the column mean of the per-pixel face)': ("sl, la = Ly['sl'], Ly['la']\n Lr", "sl, la = np.broadcast_to(Ly['sl'].mean(0), Ly['sl'].shape), np.broadcast_to(Ly['la'].mean(0), Ly['la'].shape)\n Lr"),
|
||||
'history collapsed to ONE slope per peak (the strip mean)': ("Z, Dp, sl = lerp(hh), lerp(hd), lerp(sn)", "Z, Dp, sl = lerp(hh), lerp(hd), np.broadcast_to(mean_s, Z.shape)"),
|
||||
'history not recorded: only valley and peak samples kept': ("return np.interp(t, va, vd), np.interp(t, va, vh)", "return np.interp(t, va[[0, -1]], vd[[0, -1]]), np.interp(t, va[[0, -1]], vh[[0, -1]])"),
|
||||
'no lateral tilt from the neighbouring columns': ("np.clip((c_ - a_ * sl) / b_, -1.5, 1.5))", "0 * np.clip((c_ - a_ * sl) / b_, -1.5, 1.5))"),
|
||||
'NEAR_CAP shoulder committed without the prominence test': ("if hh[j] - lowh >= prom: span(", "if True: span("),
|
||||
'prominence measured as an angle at the CURRENT sample (round 2)': ("if cand - v >= hy[k] and ch - lowh >= prom:", "if cand - v >= hy[k] and cand - mn >= hy[k]:"),
|
||||
'contour interval chosen from the shallowest quarter of the face (banding)': ("np.percentile(dzdpx[faceok], 75)", "np.percentile(dzdpx[faceok], 10)"),
|
||||
'a tapering end keeps the 28 px minimum body (stub / curtain at every chain end)': ("28 * np.clip(wt, 0, 1)", "28"),
|
||||
'absent columns of a slot take the first chain in list order (rotation-dependent)': ("np.where(l['wt'] > 0, l['top'], -1e3 - col_gap(l['wt'] > 0))", "np.where(l['wt'] > 0, l['top'], -1e9)"),
|
||||
'history keeps samples the eye cannot see (no running maximum)': ("runmax = np.maximum.accumulate(seg)", "runmax = seg"),
|
||||
'lateral tilt scale doubled': ("/ b_, -1.5, 1.5))", "/ b_ * 2, -1.5, 1.5))"),
|
||||
'lateral tilt kept across occlusion edges': ("np.abs(a_) > 3 * b_", "np.abs(a_) > 3e9 * b_"),
|
||||
'every foreground stub kept (round 2 arches)': ("if not cov[xs].any(): keep.append(c)", "if True: keep.append(c)"),
|
||||
'no foreground stub kept (holes in the nearest layer)': ("if not cov[xs].any(): keep.append(c)", "if False: keep.append(c)"),
|
||||
'prominence from the height of the lowest-angle sample (near, high valley)': ("if cand - v >= hy[k] and ch - lowh >= prom:", "if cand - v >= hy[k] and ch - mnh >= prom:"),
|
||||
'layer body painted to the bottom of the frame (no foot fade: walls)': ("alpha = (m * (1 - fade)).astype(np.float32)", "alpha = m.astype(np.float32)"),
|
||||
'relief: shading uses the real sun again (face flat with the sun behind the viewer)': ("L = relief_vec(st['az']); az", "L = st['L']; az"),
|
||||
'relief: 45 deg off the viewer-ward axis (the previous reading) instead of the view axis': ("ctr = win_centre_az(); side", "ctr = (win_centre_az() + 180.0) % 360.0; side"),
|
||||
'relief: always the + side, whatever side the real sun is on': ("side = 1.0 if math.sin(math.radians(sun_az - ctr)) >= 0 else -1.0", "side = 1.0"),
|
||||
'relief: colour / strength / haze frozen at a 40 deg sun (night does not go dark)': ("st = sun_state(az, el); shs", "st = sun_state(az, 40.0); shs"),
|
||||
'relief: cast shadows march toward the relief light instead of the real sun': ("cast_shadows(layers, az, el) if shadows", "cast_shadows(layers, math.degrees(math.atan2(relief_vec(az)[0], relief_vec(az)[1])) % 360.0, RELIEF_EL) if shadows"),
|
||||
|
||||
'compositor (relight) reads the layer mask m instead of its alpha a (walls return)': ("img = img * (1 - Ly['a'][..., None]) + face * Ly['a'][..., None]", "img = img * (1 - Ly['m'][..., None]) + face * Ly['m'][..., None]"),
|
||||
'foot fade starts at 30% of the foot instead of 60%': ("(depthpx - 0.6) / 0.4", "(depthpx - 0.3) / 0.7"),
|
||||
'foot fade starts at 90% of the foot instead of 60%': ("(depthpx - 0.6) / 0.4", "(depthpx - 0.9) / 0.1"),
|
||||
'TAPER_MAX 72 -> 40': ("TAPER_K, TAPER_MAX = 30.0, 72", "TAPER_K, TAPER_MAX = 30.0, 40"),
|
||||
'RELIEF_EL 32 -> 3 (grazing relief light)': ("RELIEF_OFF_DEG, RELIEF_EL = 45.0, 32.0", "RELIEF_OFF_DEG, RELIEF_EL = 45.0, 3.0"),
|
||||
'spec half vector ignores the light': ("Hv = L[None, :] + np.stack(", "Hv = 0 * L[None, :] + np.stack("),
|
||||
'lateral tilt not blurred across columns': ("blurw(la, 2.0).astype", "la.astype"),
|
||||
'contour interval percentile includes the tapering columns (weight condition removed)': ("& (wt[None, :] > 0.5)", ""),
|
||||
'Fresnel sign flipped (rim strongest on faces standing at the viewer)': ("np.clip(1 - sl / nrm, 0, 1) ** 3", "np.clip(1 + sl / nrm, 0, 1) ** 3"),
|
||||
'rays cache key ignores the refraction constant': ("R_EARTH, KREF, W, bool(detail)", "R_EARTH, W, bool(detail)"),
|
||||
'rays cache key ignores the eye elevation': ("round(float(P['eye_m']), 3), ", ""),
|
||||
'mosaic cache key ignores the tile list': ("sorted(tiles_for(p)), 3600", "3600"),
|
||||
}
|
||||
EQUIVALENT = {} # mutant name -> one line on why it is behaviourally equivalent (reported, not run)
|
||||
def run(item):
|
||||
name, (a, b) = item; d = tempfile.mkdtemp(); shutil.copytree(SRC, os.path.join(d, 'src'), ignore=shutil.ignore_patterns('tests', '__pycache__')); p = os.path.join(d, 'src', 'ridge.py'); s = open(p).read(); assert a in s, name
|
||||
open(p, 'w').write(s.replace(a, b))
|
||||
r = subprocess.run([sys.executable, os.path.join(HERE, 'test_ridge.py')], env=dict(os.environ, RIDGE_SRC=os.path.join(d, 'src')), capture_output=True, text=True); shutil.rmtree(d)
|
||||
fails = [l.split('|')[0].strip() for l in r.stdout.splitlines() if l.startswith('FAIL')]
|
||||
return ('KILLED ' if r.returncode else 'SURVIVED ') + name + ' -> ' + (', '.join(fails) or 'no fixture failed'), r.returncode == 0
|
||||
if __name__ == '__main__':
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
with ThreadPoolExecutor(int(os.environ.get('MUTATE_JOBS', '6'))) as ex: res = list(ex.map(run, [(n, v) for n, v in MUTANTS.items() if n not in EQUIVALENT]))
|
||||
for line, _ in res: print(line, flush=True)
|
||||
for n, why in EQUIVALENT.items(): print('EQUIVALENT ' + n + ' -> ' + why)
|
||||
sys.exit(1 if any(sv for _, sv in res) else 0)
|
||||
7
scripts/ridge-bake/tests/run.sh
Executable file
7
scripts/ridge-bake/tests/run.sh
Executable file
|
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh
|
||||
# Usage: RIDGE_PY=<venv python> RIDGE_TILES=<tiles dir> tests/run.sh [test_ridge.py|mutate.py]
|
||||
# RIDGE_WORK (cache dir) defaults to a fresh directory under $TMPDIR.
|
||||
: "${RIDGE_PY:?RIDGE_PY must name the python of the baker venv}"
|
||||
export RIDGE_WORK="${RIDGE_WORK:-${TMPDIR:-/tmp}/ridge-bake-work}"; mkdir -p "$RIDGE_WORK"
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
exec "$RIDGE_PY" -I "$HERE/${1:-test_ridge.py}"
|
||||
382
scripts/ridge-bake/tests/test_ridge.py
Normal file
382
scripts/ridge-bake/tests/test_ridge.py
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
"""Fixtures for the A+D sweep/chain/taper. Plain asserts, no pytest needed: venv/bin/python tests/test_ridge.py (exit code 1 on failure).
|
||||
RIDGE_SRC selects the source directory (default ..) (the mutation runner points it at a patched copy).
|
||||
Synthetic terrain has a known right answer; the Vienna test pins the skyline of the real bake (its rays come from stage0 for the vienna preset: RIDGE_TILES needs the tiles, or a cached rays file must exist)."""
|
||||
import os, sys, math, traceback
|
||||
SRC = os.environ.get('RIDGE_SRC') or os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'); sys.path.insert(0, SRC) # RIDGE_WORK and RIDGE_TILES must be set (tests/run.sh sets them)
|
||||
import numpy as np, ridge
|
||||
from ridge import W, TAPER, TAPER_MAX, taper_len
|
||||
|
||||
EYE = 100.0; GROUND = 50.0 # ground below the eye: every ray climbs, so only the placed blocks make ridges
|
||||
ridge.P = dict(id='synthetic', rmax_km=60)
|
||||
D = ridge.schedule(False)
|
||||
def az_of(x): return (x + 0.5) / W * 360.0
|
||||
def col_of(az): return int(round(az / 360.0 * W - 0.5)) % W
|
||||
|
||||
def rays(hfun):
|
||||
az = (np.arange(W) + 0.5) / W * 360.0; A = np.zeros((len(D), W), np.float32); Hh = np.zeros_like(A)
|
||||
for k, dk in enumerate(D):
|
||||
h = hfun(dk, az); Hh[k] = h; A[k] = np.degrees(np.arctan2(h - EYE - dk * dk / (2 * ridge.R_EARTH) * (1 - ridge.KREF), dk))
|
||||
return A, Hh
|
||||
def theta(h, d): return math.degrees(math.atan2(h - EYE - d * d / (2 * ridge.R_EARTH) * (1 - ridge.KREF), d))
|
||||
def in_arc(az, a0, a1): return ((az - a0) % 360.0) <= ((a1 - a0) % 360.0)
|
||||
def block(a0, a1, d0, d1, h): return lambda dk, az: np.where(in_arc(az, a0, a1) & (dk >= d0) & (dk < d1), h, 0.0)
|
||||
def terrain(*blocks): return lambda dk, az: np.maximum.reduce([np.full_like(az, GROUND)] + [np.asarray(b(dk, az), float) for b in blocks])
|
||||
def slot_at(r, col, ang):
|
||||
"""the slot whose top at `col` is closest to `ang` (the layer that owns the block)"""
|
||||
return min(r['slots'], key=lambda S: abs(S['top'][col] - ang))
|
||||
def jump_bound(S, x, w=26):
|
||||
"""steepest step allowed inside a taper: smoothstep slope 1.5 x the end drop / taper_len(drop) (+ margin), the end drop taken as max((highest top within w columns) - lo, 0.4)"""
|
||||
seg = S['top'][[(x + k) % W for k in range(-w, w + 1)]]; drop = max(seg.max() - S['lo'][x], 0.4); return 1.6 * drop / float(taper_len(drop)) + 0.02 # the taper always falls at least 0.4 deg (top - 0.4 when the valley is shallower)
|
||||
def active_pairs(S): return [x for x in range(W) if S['wt'][x] > 0 and S['wt'][(x + 1) % W] > 0]
|
||||
def test_plateau_has_no_spike_and_no_cliff_beyond_taper():
|
||||
"""Flat-topped plateau, az 100-140, 20-24 km, 900 m. Right answer: top = theta(900, 20 km) inside, nothing above it anywhere, outside the plateau the top only falls
|
||||
(monotonically, within the taper), and no single-column step exceeds the taper bound."""
|
||||
A, Hh = rays(terrain(block(100, 140, 20000, 24000, 900))); r = ridge.sweep(D, A, Hh); th = theta(900, 20000)
|
||||
S = slot_at(r, col_of(120), th); top = S['top']
|
||||
inside = np.arange(col_of(101), col_of(139)); assert np.abs(top[inside] - th).max() < 0.03, ('plateau top not flat/right', np.abs(top[inside] - th).max())
|
||||
assert top.max() <= th + 0.03, ('spike above the plateau', top.max() - th)
|
||||
for side in (-1, 1):
|
||||
x0 = col_of(100) if side < 0 else col_of(140)
|
||||
n = int(taper_len(max(th - S['lo'][col_of(120)], 0.4))); seg = np.array([top[(x0 + side * j) % W] for j in range(1, n)])
|
||||
assert (np.diff(seg) <= 1e-9).all(), ('taper is not monotone away from the plateau', side, np.diff(seg).max())
|
||||
assert seg[-1] < seg[0] - 0.3, 'taper does not die away'
|
||||
assert S['wt'][(x0 + side * (n + 3)) % W] == 0, 'the layer is still drawn far outside the plateau'
|
||||
j = np.abs(np.diff(top, append=top[0])); bad = [(x, round(float(j[x]), 3)) for x in active_pairs(S) if j[x] > jump_bound(S, x)]; assert not bad, ('column jump above the bound', bad[:5])
|
||||
drop = th - S['lo'][col_of(120)]; width = int(((S['wt'] > 0) & (S['wt'] < 1))[col_of(140):col_of(140) + 90].sum()); assert drop > 1.5 and width >= 0.9 * min(72, 30 * drop), ('a tall end must fall over ~30 columns per degree of drop up to 72 (absolute, not the constant) (its drop is %.2f deg)' % drop, width)
|
||||
|
||||
def test_ridge_whose_two_ends_differ_does_not_borrow_the_far_end():
|
||||
"""Plateau az 100-140 whose height climbs 600 -> 1200 m with azimuth. Right answer: left of the left end nothing is higher than the left end's own top, right of the right end
|
||||
nothing is higher than the right end's top (the round-1 bug held the END value on both sides: a 0.9 deg step on the low side)."""
|
||||
hfun = lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 600.0 + 600.0 * np.clip((az - 100.0) / 40.0, 0, 1), 0.0)
|
||||
A, Hh = rays(terrain(hfun)); r = ridge.sweep(D, A, Hh); tl, tr = theta(600, 20000), theta(1200, 20000)
|
||||
S = slot_at(r, col_of(120), (tl + tr) / 2); top = S['top']; xl, xr = col_of(100), col_of(140)
|
||||
left = top[[(xl - j) % W for j in range(0, TAPER_MAX + 3)]]; right = top[[(xr + j) % W for j in range(0, TAPER_MAX + 3)]]
|
||||
assert left.max() <= tl + 0.05, ('left of the low end rises above it', left.max() - tl)
|
||||
assert right.max() <= tr + 0.05 and right[0] >= tr - 0.1, ('right end value wrong', right[:3])
|
||||
assert (np.diff(left) <= 1e-9).all() and (np.diff(right) <= 1e-9).all(), 'tapers are not monotone'
|
||||
|
||||
def test_slot_union_only_lets_present_chains_compete():
|
||||
"""Two chains in one slot: L1 present on cols 0-99 (top 0.2), L2 present on cols 200-299 (top 2.0) but its HELD value elsewhere is 0.5 > 0.2. Right answer: on cols 0-99
|
||||
the slot is L1 (top 0.2, wt 1); a chain that is absent there must not win with its held value."""
|
||||
def lay(c0, c1, top, held):
|
||||
wt = np.zeros(W); wt[c0:c1] = 1.0; tp = np.full(W, held); tp[c0:c1] = top
|
||||
return dict(top=tp, lo=np.full(W, -1.0), ph=np.full(W, 100.0), vh=np.full(W, 0.0), slope=np.full(W, 5.0), wt=wt, pd=np.full(W, 20000.0), vd=np.full(W, 19000.0), hd=np.full((ridge.NH, W), 19500.0), hh=np.full((ridge.NH, W), 50.0), dist=20000.0)
|
||||
S = ridge.to_slots([lay(0, 100, 0.2, -1.0), lay(200, 300, 2.0, 0.5)])
|
||||
assert len(S) == 1 and (S[0]['wt'][0:100] == 1).all() and np.allclose(S[0]['top'][0:100], 0.2), ('absent chain won the slot union', S[0]['top'][:3], S[0]['wt'][:3])
|
||||
assert (S[0]['wt'][200:300] == 1).all() and np.allclose(S[0]['top'][200:300], 2.0)
|
||||
|
||||
def S_ok(S, i): return S['wt'][i] > 0 and S['wt'][(i + 1) % W] > 0
|
||||
def test_step_cliff_is_one_step_and_levels_are_right():
|
||||
"""Two plateaus side by side, 700 m (az 60-180) then 1300 m (180-300). Right answer: level theta_A left, theta_B right, one step at az 180 (col 3200)."""
|
||||
A, Hh = rays(terrain(block(60, 180, 20000, 24000, 700), block(180, 300, 20000, 24000, 1300))); r = ridge.sweep(D, A, Hh)
|
||||
tA, tB = theta(700, 20000), theta(1300, 20000); x = col_of(180)
|
||||
SA = slot_at(r, col_of(120), tA); top = SA['top']
|
||||
assert np.abs(top[col_of(62):x - TAPER_MAX - 2] - tA).max() < 0.03, 'level A wrong'
|
||||
assert np.abs(top[x + 2:col_of(298)] - tB).max() < 0.03, 'level B wrong'
|
||||
assert top.max() <= tB + 0.03, 'spike above the higher plateau'
|
||||
j = np.abs(np.diff(top)); ends = (col_of(60), col_of(300)); big = [i for i in np.where(j > 0.2)[0] if S_ok(SA, i) and not (x - TAPER_MAX - 2 <= i <= x + 2) and min(abs(i - e) for e in ends) > TAPER_MAX + 2]
|
||||
assert not big, ('a jump away from the step and the two outer ends', big[:6])
|
||||
assert (top[x - TAPER_MAX - 2:x + 3].max() - tA) <= (tB - tA) + 0.03
|
||||
|
||||
def test_single_peak_has_one_maximum():
|
||||
"""Gaussian peak 1800 m at az 90, 30 km, sigma 1.5 km. Right answer: one maximum at az 90 +-0.2, monotone either side inside the chain, no secondary bump."""
|
||||
def peak(dk, az):
|
||||
lat = dk * np.sin(np.radians(az - 90.0)); return 1800.0 * np.exp(-(lat ** 2 + (dk - 30000.0) ** 2) / (2 * 1500.0 ** 2))
|
||||
A, Hh = rays(terrain(peak)); r = ridge.sweep(D, A, Hh); top_peak = theta(1800, 30000)
|
||||
S = max(r['slots'], key=lambda S: S['top'][col_of(90)]); top = S['top']
|
||||
xm = int(np.argmax(top[col_of(60):col_of(120)])) + col_of(60); assert abs(az_of(xm) - 90.0) < 0.25, ('maximum at', az_of(xm))
|
||||
assert abs(top[xm] - top_peak) < 0.1, (top[xm], top_peak)
|
||||
seg = top[col_of(60):col_of(120)]; peaks = [i for i in range(1, len(seg) - 1) if seg[i] >= seg[i - 1] and seg[i] > seg[i + 1] and (seg[i] - min(seg[max(0, i - 30):i + 30])) > 0.01]
|
||||
assert len(peaks) == 1, ('secondary local maxima', len(peaks))
|
||||
|
||||
def test_wraparound_ridge_across_north_is_seamless_and_rotation_invariant():
|
||||
"""Plateau az 350 -> 10 (across 0/360). Right answer: flat top, no step across the seam, one chain crossing it, and the result does not depend on where column 0 is."""
|
||||
A, Hh = rays(terrain(block(350, 10, 20000, 24000, 900))); r = ridge.sweep(D, A, Hh); th = theta(900, 20000)
|
||||
S = slot_at(r, 0, th); top = S['top']
|
||||
assert abs(top[W - 1] - top[0]) < 0.003, ('seam step', top[W - 1] - top[0])
|
||||
assert np.abs(top[[col_of(351), col_of(355), W - 1, 0, col_of(5), col_of(9)]] - th).max() < 0.03
|
||||
near20 = [l for l in r['keep'] if abs(math.log(l['dist'] / 20000.0)) < 0.3]
|
||||
assert len(near20) == 1 and near20[0]['x0'] + near20[0]['span'] > W, ('the 20 km plateau must be ONE chain crossing the seam', [(l['x0'], l['span']) for l in near20])
|
||||
R = 1600; r2 = ridge.sweep(D, np.roll(A, -R, axis=1), np.roll(Hh, -R, axis=1))
|
||||
for S1 in r['slots']:
|
||||
S2 = next(s for s in r2['slots'] if s['slot'] == S1['slot']); d = np.abs(np.roll(S2['top'], R) - S1['top']).max(); assert d < 1e-9, ('not rotation invariant', S1['slot'], d)
|
||||
|
||||
def test_vienna_skyline_has_no_invented_jump_and_seam_is_seamless():
|
||||
"""Real bake: no per-column step above the stated bound. In a chain (wt == 1) a step above 0.12 deg is allowed only if both columns carry the raw span peak that wins there (a real step of the DEM, e.g. a roof edge); in a taper the
|
||||
steepest allowed step is 1.6 x (top-lo)/TAPER + 0.02. The envelope across the seam must not jump."""
|
||||
saved = ridge.P
|
||||
ridge.configure(ridge.load_preset('vienna')); d, A, Hh, _ = ridge.stage0(False) # cached under the key of its own inputs (computed once if absent)
|
||||
ridge.P = dict(id='vienna', rmax_km=95); r = ridge.sweep(d, A, Hh); ridge.P = saved
|
||||
worst = []; cols = r['cols']
|
||||
real = lambda x, top: any(abs(s[0] - top) < 1e-4 for s in cols[x % W]) # the top at x is an actual raw span peak: a step between two such columns is a step of the DEM
|
||||
for S in r['slots']:
|
||||
j = np.abs(np.diff(S['top'], append=S['top'][0]))
|
||||
for x in active_pairs(S):
|
||||
bound = jump_bound(S, x) if min(S['wt'][x], S['wt'][(x + 1) % W]) < 1 else 0.12
|
||||
if j[x] > bound and not (S['wt'][x] == 1 and S['wt'][(x + 1) % W] == 1 and real(x, S['top'][x]) and real(x + 1, S['top'][(x + 1) % W])):
|
||||
worst.append((S['slot'], x, round(float(j[x]), 3), round(float(bound), 3), round(float(S['wt'][x]), 2)))
|
||||
assert not worst, ('Vienna jumps above the bound', worst[:6])
|
||||
sky = np.max([S['top'] for S in r['slots']], axis=0); assert abs(sky[W - 1] - sky[0]) < 0.05, ('envelope jumps across the seam', sky[W - 1] - sky[0])
|
||||
for S in r['slots']:
|
||||
if S['wt'][W - 1] > 0 and S['wt'][0] > 0: assert abs(S['top'][W - 1] - S['top'][0]) < 0.05, ('layer jumps across the seam', S['slot'])
|
||||
|
||||
def _shadow_fixture():
|
||||
"""flat 100 m terrain with one 1000 m wall at lat 48.45-48.46 (5.6-6.7 km north of the sample point), sample point lat 48.40 lon 16.5 at 103 m"""
|
||||
m = np.full((3600, 3600), 100.0, np.float32); y0 = int((49 - 48.46) * 3600); y1 = int((49 - 48.45) * 3600); m[y0:y1, :] = 1000.0
|
||||
ridge.P = dict(id='syn', lat=48.40, lon=16.5, rmax_km=60); ridge._mos[('syn', ridge.mosaic_key(ridge.P))] = (m, 49, 16)
|
||||
return [dict(spts=dict(lat=np.full((3, W), 48.40), lon=np.full((3, W), 16.5), h=np.full((3, W), 103.0)))]
|
||||
def test_cast_shadow_follows_the_sun_ray_over_the_dem():
|
||||
"""Right answer: sun due north at 5 deg: the ray is 580 m at the wall, the wall is 1000 m => fully shadowed (1.0). Sun due north at 30 deg clears the wall (0.0). Sun due south: the wall is behind the sun (0.0).
|
||||
Sun at 0.2 deg: no direct light, no shadow (0.0)."""
|
||||
L = _shadow_fixture(); f = lambda az, el: float(ridge.cast_shadows(L, az, el)[0].mean())
|
||||
assert f(0.0, 5.0) > 0.99, ('wall does not shadow a low sun behind it', f(0.0, 5.0))
|
||||
assert f(0.0, 30.0) < 0.01, ('high sun clears the wall', f(0.0, 30.0))
|
||||
assert f(180.0, 5.0) < 0.01, ('wall casts a shadow toward the sun', f(180.0, 5.0))
|
||||
assert f(0.0, 0.2) == 0.0
|
||||
|
||||
def test_cast_shadow_east_west_and_longitude_scale():
|
||||
"""Wall of 1000 m at lon 16.575-16.585 (5.5-6.2 km EAST of the sample point at lat 48.4, cos(lat) matters), flat 100 m elsewhere. Sun due east at 5 deg: shadowed (580 m ray vs 1000 m).
|
||||
Sun due west: clear. Without the cos(lat) factor the wall would sit at 8.3 km and a 6 deg ray (980 m) would barely touch it, so the fully-shadowed assertion at 6 deg pins the scale."""
|
||||
m = np.full((3600, 3600), 100.0, np.float32); x0 = int((16.575 - 16) * 3600); x1 = int((16.585 - 16) * 3600); m[:, x0:x1] = 1000.0
|
||||
ridge.P = dict(id='syn2', lat=48.40, lon=16.5, rmax_km=60); ridge._mos[('syn2', ridge.mosaic_key(ridge.P))] = (m, 49, 16)
|
||||
L = [dict(spts=dict(lat=np.full((3, W), 48.40), lon=np.full((3, W), 16.5), h=np.full((3, W), 103.0)))]; f = lambda az, el: float(ridge.cast_shadows(L, az, el)[0].mean())
|
||||
assert f(90.0, 5.0) > 0.99, ('east wall does not shadow a low east sun', f(90.0, 5.0))
|
||||
assert f(270.0, 5.0) < 0.01, ('east wall shadows a west sun', f(270.0, 5.0))
|
||||
assert f(90.0, 6.0) > 0.99 and f(90.0, 12.0) < 0.01, ('east-wall shadow edge is at the wrong elevation (wall is 5.5 km east of the point: the sun ray at 6 deg is 660 m there, at 12 deg 1.2 km)', f(90.0, 6.0), f(90.0, 12.0))
|
||||
|
||||
def test_shadow_darkens_the_face_it_covers():
|
||||
"""Whole pipeline on a synthetic ramp (slope 0.35, lit by the relief light): with the shadow march forced to 1 every face pixel is darker than with it forced to 0 (the shadow removes direct sun); forced 0 equals shadows off."""
|
||||
A, Hh = rays(terrain(lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 50.0 + 0.35 * (dk - 20000), 0.0))); ridge.P = dict(id='synthetic', rmax_km=60, lat=48.0, lon=16.0, floor_agl_m=30.0, s_ref_deg=8.0, win0=(col_of(120) - 640) % W)
|
||||
r = ridge.sweep(D, A, Hh); ridge.set_view(r['slots']); L = ridge.bake(r['slots']); pf = ridge.statics(); real = ridge.cast_shadows
|
||||
try:
|
||||
ridge.cast_shadows = lambda layers, az, el: [np.ones((3, W)) for _ in layers]; dark = ridge.relight(L, 300.0, 30.0, *pf)
|
||||
ridge.cast_shadows = lambda layers, az, el: [np.zeros((3, W)) for _ in layers]; lit = ridge.relight(L, 300.0, 30.0, *pf)
|
||||
finally: ridge.cast_shadows = real
|
||||
off = ridge.relight(L, 300.0, 30.0, *pf, shadows=False); c = slice(col_of(105), col_of(135)); rows = slice(0, ridge.H); Ly = max(L, key=lambda l: ((l['m'][rows, c] > 0.99) & (l['w'][rows, c] < 0.05)).sum()); m = (Ly['m'][rows, c] > 0.99) & (Ly['w'][rows, c] < 0.05) # body pixels (not the mist foot) of the layer that owns the plateau
|
||||
assert m.any(), 'no solid face pixels to compare'
|
||||
assert dark[rows, c][m].mean() < lit[rows, c][m].mean() - 0.02, ('shadow does not darken the face', dark[rows, c][m].mean(), lit[rows, c][m].mean())
|
||||
assert np.abs(lit - off).max() < 1e-4, 'a zero shadow differs from shadows off'
|
||||
|
||||
# ---------------------------------------------------------------------------------------- shading fixtures (round 3): the face history -> per-pixel normal
|
||||
def _bake_view(hfun, az0):
|
||||
A, Hh = rays(terrain(hfun)); ridge.P = dict(id='synthetic', rmax_km=60, lat=48.0, lon=16.0, floor_agl_m=30.0, s_ref_deg=8.0, win0=(col_of(az0) - 640) % W)
|
||||
r = ridge.sweep(D, A, Hh); ridge.set_view(r['slots']); return r, ridge.bake(r['slots'])
|
||||
def _owner(r, L, col, ang):
|
||||
S = slot_at(r, col, ang); return S, next(Ly for Ly in L if Ly['dist'] == S['dist'])
|
||||
CONE = dict(Dc=20000.0, Rb=6000.0, Hc=1800.0, az0=90.0)
|
||||
def _cone(dk, az):
|
||||
E = dk * np.sin(np.radians(az)) - CONE['Dc'] * math.sin(math.radians(CONE['az0'])); N = dk * np.cos(np.radians(az)) - CONE['Dc'] * math.cos(math.radians(CONE['az0']))
|
||||
return np.where(np.hypot(E, N) < CONE['Rb'], CONE['Hc'] * (1 - np.hypot(E, N) / CONE['Rb']), 0.0)
|
||||
def _cone_pixels(sun):
|
||||
"""(analytic n.L, baked n.L) at pixels of the cone layer: the analytic one marches the ray finely to the first sample whose running-max angle reaches the pixel's elevation and takes the cone's own normal there."""
|
||||
r, L = _bake_view(_cone, CONE['az0']); S, Ly = _owner(r, L, col_of(CONE['az0']), theta(CONE['Hc'], CONE['Dc'])); nd, _ = ridge.ndl_field(Ly, sun); dd = np.arange(500.0, 60000.0, 4.0); m = CONE['Hc'] / CONE['Rb']; out = []
|
||||
for x in range(300, 1000, 7):
|
||||
c = (ridge.P['win0'] + x) % W
|
||||
if S['wt'][c] < 1: continue
|
||||
az = (c + 0.5) / W * 360.0; h = _cone(dd, np.full_like(dd, az)); ang = np.degrees(np.arctan2(h - EYE - dd * dd / (2 * ridge.R_EARTH) * (1 - ridge.KREF), dd)); rm = np.maximum.accumulate(ang)
|
||||
for f in (0.6, 0.7, 0.8, 0.9, 0.95):
|
||||
row = int(round((ridge.TOP_DEG - (Ly['lo'][c] + f * (Ly['ang'][c] - Ly['lo'][c]))) * ridge.PXDEG_V - 0.5)); d = dd[min(np.searchsorted(rm, ridge.rowang[row]), len(dd) - 1)]
|
||||
E = d * math.sin(math.radians(az)) - CONE['Dc'] * math.sin(math.radians(CONE['az0'])); N = d * math.cos(math.radians(az)) - CONE['Dc'] * math.cos(math.radians(CONE['az0'])); rr = math.hypot(E, N)
|
||||
if rr >= 0.85 * CONE['Rb']: continue # on the cone proper, away from the foot kink (the +-2 node slope window rounds the kink)
|
||||
n = np.array([m * E / rr, m * N / rr, 1.0]); n /= np.linalg.norm(n); out.append((float(n @ sun), float(nd[row, c])))
|
||||
return np.array(out), Ly, nd
|
||||
def test_cone_is_shaded_per_pixel_with_the_analytic_normal():
|
||||
"""Cone (apex 1800 m, base radius 6 km, 20 km away), sun from the west at 20 deg. Right answer: the shading of each pixel is the cone's own n.L at the point that pixel sees, so it changes down every
|
||||
column (the radial direction turns as the ray climbs) and across the face; the round-2 baker stored ONE slope per peak and gave every pixel of a column the same value (within-column std 0)."""
|
||||
sun = ridge.sun_vec(250.0, 20.0); v, Ly, nd = _cone_pixels(sun); assert len(v) > 100, len(v)
|
||||
err = np.abs(v[:, 0] - v[:, 1]); assert np.median(err) < 0.05 and err.mean() < 0.07, ('baked n.L is not the analytic one', np.median(err), err.mean())
|
||||
assert np.ptp(v[:, 1]) > 0.8 * np.ptp(v[:, 0]), ('baked shading range is compressed', np.ptp(v[:, 1]), np.ptp(v[:, 0]))
|
||||
cs = [nd[int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 6:int((ridge.TOP_DEG - Ly['lo'][c]) * ridge.PXDEG_V) - 6, c].std() for c in (ridge.P['win0'] + np.arange(400, 900, 20)) % W]
|
||||
assert np.mean(cs) > 0.03, ('shading is constant down a column', np.mean(cs))
|
||||
|
||||
def test_plane_face_has_the_planes_slope_and_no_lateral_tilt():
|
||||
"""Planar ramp facing the viewer, az 60-120, rising 0.35 per metre of distance from 20 km to 24 km, then a drop. Right answer: every face pixel's front slope is 0.35 (19.3 deg) and its lateral tilt is 0,
|
||||
whatever its row (one slope per peak could not tell a plane from a curved face, only the history can)."""
|
||||
ramp = lambda dk, az: np.where(in_arc(az, 60, 120) & (dk >= 20000) & (dk < 24000), 50.0 + 0.35 * (dk - 20000), 0.0)
|
||||
r, L = _bake_view(ramp, 90.0); S, Ly = _owner(r, L, col_of(90), theta(50.0 + 0.35 * 4000, 24000)); cols = (ridge.P['win0'] + np.arange(560, 720)) % W
|
||||
rows = np.arange(int((ridge.TOP_DEG - Ly['ang'][col_of(90)]) * ridge.PXDEG_V) + 8, int((ridge.TOP_DEG - 1.0) * ridge.PXDEG_V)); sl = Ly['sl'][np.ix_(rows, cols)]; la = Ly['la'][np.ix_(rows, cols)]
|
||||
assert len(rows) > 20 and abs(np.median(sl) - 0.35) < 0.03 and np.percentile(np.abs(sl - 0.35), 90) < 0.08, ('front slope is not the plane\'s', np.median(sl), np.percentile(np.abs(sl - 0.35), 90))
|
||||
assert np.abs(la).max() < 0.08, ('lateral tilt on a plane', np.abs(la).max())
|
||||
|
||||
def test_ridge_step_tilts_the_face_only_at_the_step():
|
||||
"""Two plateaus (700 m / 1300 m) meeting at az 180. Right answer: the face is level laterally (la ~ 0) away from the step, and strongly tilted at it (the height changes by 600 m between two neighbouring columns)."""
|
||||
r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 60, 180) & (dk >= 20000) & (dk < 24000), 700.0, np.where(in_arc(az, 180, 300) & (dk >= 20000) & (dk < 24000), 1300.0, 0.0)), 180.0)
|
||||
S, Ly = _owner(r, L, col_of(120), theta(700, 20000)); cols = lambda a, b: (ridge.P['win0'] + np.arange(a, b)) % W; rows = slice(int((ridge.TOP_DEG - theta(1300, 20000)) * ridge.PXDEG_V), int((ridge.TOP_DEG - 0.3) * ridge.PXDEG_V))
|
||||
far = np.abs(Ly['la'][rows, :][:, cols(200, 500)]); assert far.max() < 0.05, ('lateral tilt away from the step', far.max())
|
||||
near = Ly['la'][rows, :][:, cols(620, 660)]; assert np.abs(near).max() > 0.2, ('no tilt at the step', np.abs(near).max())
|
||||
|
||||
def test_roof_height_bump_in_the_foreground_is_not_a_ridge():
|
||||
"""Flat ground with a 19 m x 40 m block 700 m away (a roof / tree in a DSM). Right answer: no layer: a peak needs PROM_M (25 m) of height over its valley, and the 4 km NEAR_CAP shoulder rule obeys the same
|
||||
rule (round 2 committed the shoulder with no prominence test: an arch 66 columns wide stood in the foreground). A 60 m block IS a ridge."""
|
||||
bump = lambda h: (lambda dk, az: np.where(in_arc(az, 88, 92) & (dk >= 700) & (dk < 740), GROUND + h, 0.0))
|
||||
A, Hh = rays(terrain(bump(19.0))); r = ridge.sweep(D, A, Hh); assert not [l for l in r['keep'] if l['dist'] < 4000], ('a 19 m bump became a ridge', [(l['x0'], l['span'], round(l['dist'])) for l in r['keep']])
|
||||
A, Hh = rays(terrain(bump(60.0))); r = ridge.sweep(D, A, Hh); assert [l for l in r['keep'] if l['dist'] < 1000], 'a 60 m block must be a ridge'
|
||||
|
||||
def test_contour_lines_are_never_denser_than_9_px_over_most_of_the_face():
|
||||
"""Right answer (cone): the iso-height lines drawn on a layer are at least CONTOUR_MIN_PX apart over three quarters of its face (round 2 picked the interval from the SHALLOWEST columns, so the steep centre
|
||||
of the mountain carried a line every 2-3 px: the fine horizontal banding)."""
|
||||
r, L = _bake_view(_cone, CONE['az0']); S, Ly = _owner(r, L, col_of(CONE['az0']), theta(CONE['Hc'], CONE['Dc'])); sp = []
|
||||
for c in (ridge.P['win0'] + np.arange(450, 850, 10)) % W:
|
||||
r0, r1 = int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 4, int((ridge.TOP_DEG - Ly['lo'][c]) * ridge.PXDEG_V) - 4; line = np.where(Ly['dpx'][r0:r1, c] < 0.5)[0]
|
||||
if len(line) > 1: g = np.diff(line); sp += list(g[g > 1])
|
||||
assert len(sp) > 20 and np.percentile(sp, 25) >= 8, ('contour lines too dense', np.percentile(sp, 25), len(sp))
|
||||
|
||||
def test_no_layer_paints_a_wall_down_the_frame():
|
||||
"""Right answer: a layer's alpha is 0 further below its own crest than its foot allows (<= 210 px): its column boundary leaves no vertical edge through the floor (round 2 painted the body to the bottom of the
|
||||
frame wherever the chain existed: hard walls below every chain end)."""
|
||||
r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 900.0, 0.0), 120.0)
|
||||
for Ly in L: below = np.arange(ridge.H)[:, None] - (ridge.TOP_DEG - Ly['ang'])[None, :] * ridge.PXDEG_V; assert Ly['a'][below > 215].max() == 0, ('layer painted below its foot', Ly['rank'])
|
||||
S = max(r['slots'], key=lambda S: S['top'][col_of(120)])
|
||||
for Ly in L:
|
||||
if Ly['dist'] != S['dist']: continue
|
||||
end = [c for c in range(W) if 0 < S['wt'][c] < 0.12]; assert end, 'fixture has no taper end'
|
||||
bad = [(c, int((Ly['a'][:, c] > 0.1).sum())) for c in end if (Ly['a'][:, c] > 0.1).sum() > 0.85 * max(Ly['ang'][c] - Ly['lo'][c], 0) * ridge.PXDEG_V + 4]; assert not bad, ('a chain end hangs a curtain thicker than the ridge is tall', bad[:4])
|
||||
|
||||
def test_slot_is_rotation_invariant_in_columns_where_no_chain_is_present():
|
||||
"""Two plateaus in one distance slot (az 100-110 at 700 m, az 200-215 at 1100 m, both 20-24 km). Right answer: rotating the ring by 1600 columns changes nothing, in EVERY column of the slot including those where no chain is present
|
||||
(round 3 first picked the first chain in list order there; list order depends on where column 0 is, and those values feed the blurs at every chain end: Half Dome's rotation test went from 0 to 97 px)."""
|
||||
A, Hh = rays(terrain(block(100, 110, 20000, 24000, 700), block(200, 215, 20000, 24000, 1100))); r = ridge.sweep(D, A, Hh); R = 2800; r2 = ridge.sweep(D, np.roll(A, -R, axis=1), np.roll(Hh, -R, axis=1)) # 2800 columns carries the az 200-215 block through column 0: the chains swap places in list order
|
||||
for S1 in r['slots']:
|
||||
S2 = next(s for s in r2['slots'] if s['slot'] == S1['slot'])
|
||||
for k in ('top', 'lo', 'wt', 'pd', 'ph', 'vh', 'slope'): assert np.abs(np.roll(S2[k], R) - S1[k]).max() < 1e-9, ('slot not rotation invariant', S1['slot'], k, np.abs(np.roll(S2[k], R) - S1[k]).max())
|
||||
for k in ('hd', 'hh'): assert np.abs(np.roll(S2[k], R, axis=1) - S1[k]).max() < 1e-6, ('slot history not rotation invariant', S1['slot'], k)
|
||||
|
||||
def test_prominent_far_ridge_behind_a_basin_is_found():
|
||||
"""Terrace (50 m, eye 100 m) ending at 10 km, a 0 m basin, a 60 m ridge at 30 km (az 80-100). Right answer: the ridge is a layer at 30 km: it stands 0.5 deg above its visible valley, which is 4 times the 25 m / 30 km bar of 0.048 deg.
|
||||
(Round 3's first cut compared raw heights, ridge 60 m vs the valley's 0 m... and lost ridges whose valley sample is near and high.)"""
|
||||
hf = lambda dk, az: np.where(dk < 10000, 50.0, np.where(in_arc(az, 80, 100) & (dk >= 30000) & (dk < 30500), 60.0, np.where(in_arc(az, 80, 100) & (dk >= 50000) & (dk < 55000), 300.0, 0.0))) # the 300 m wall at 50 km keeps the ridge from being committed as the ray's last peak
|
||||
A, Hh = rays(hf); r = ridge.sweep(D, A, Hh); far = [l for l in r['keep'] if abs(l['dist'] - 30000) < 1500]; assert far and far[0]['span'] > 200, ('the 30 km ridge is missing', [(l['x0'], l['span'], round(l['dist'])) for l in r['keep']])
|
||||
|
||||
def test_lone_foreground_stub_is_kept_but_a_stub_inside_a_near_ridge_is_not():
|
||||
"""(a) a 0.5 deg wide, 60 m block at 1.7 km with nothing else near: the nearest layer must cover it (a hole would show the far ridge's body). (b) the same stub in front of a long near ridge (az 100-160, 150 m, 1.5 km):
|
||||
its columns are covered by that ridge, so it is dropped (round 2 kept every foreground stub: lone arches)."""
|
||||
A, Hh = rays(terrain(block(90, 90.5, 1700, 1740, 110))); r = ridge.sweep(D, A, Hh); assert [l for l in r['keep'] if l['dist'] < 2500], 'lone foreground stub dropped: hole in the nearest layer'
|
||||
A, Hh = rays(terrain(block(100, 160, 1500, 1600, 150), block(129, 129.5, 600, 640, 160))); r = ridge.sweep(D, A, Hh); assert not [l for l in r['keep'] if l['dist'] < 1000], ('a stub inside a near ridge became an arch', [(l['x0'], l['span'], round(l['dist'])) for l in r['keep']])
|
||||
|
||||
def test_tilted_plane_has_the_planes_front_slope_and_lateral_tilt():
|
||||
"""Plane h = 50 + 0.3 (E - 20000) - 0.2 N over az 85-95, 20-24 km, then a drop. At az 90 the radial is +E and the tangent is -N, so n ~ (-0.3 r - la t, 1) with la = -(dh/dN) = +0.2. Right answer: front slope 0.3, lateral tilt 0.2, each within 0.04
|
||||
(a lateral scale of 2x, or no tilt, fails)."""
|
||||
def plane(dk, az):
|
||||
E = dk * np.sin(np.radians(az)); N = dk * np.cos(np.radians(az)); return np.where(in_arc(az, 85, 95) & (dk >= 20000) & (dk < 24000), 50.0 + 0.3 * (E - 20000.0) - 0.2 * N, 0.0)
|
||||
r, L = _bake_view(plane, 90.0); S, Ly = _owner(r, L, col_of(90), theta(50.0 + 0.3 * 4000, 24000)); cols = (ridge.P['win0'] + np.arange(600, 680)) % W
|
||||
rows = np.arange(int((ridge.TOP_DEG - Ly['ang'][col_of(90)]) * ridge.PXDEG_V) + 8, int((ridge.TOP_DEG - 1.0) * ridge.PXDEG_V)); sl = Ly['sl'][np.ix_(rows, cols)]; la = Ly['la'][np.ix_(rows, cols)]
|
||||
assert len(rows) > 15 and abs(np.median(sl) - 0.3) < 0.04 and abs(np.median(la) - 0.2) < 0.04, ('plane slope / tilt wrong', np.median(sl), np.median(la))
|
||||
|
||||
def test_history_keeps_only_what_the_eye_sees():
|
||||
"""Samples (angle, distance, height): (0, 100, 0) (2, 200, 10) (1, 300, 5) (1.5, 350, 8) (3, 400, 30). The third and fourth are hidden behind the second (the fourth RISES from the third, so only a running maximum drops it). Right answer: at elevation 2.5 the face is half way between the second and the fourth sample: distance 300, height 20
|
||||
(without the running maximum the non-monotone angles make the interpolation garbage)."""
|
||||
d, h = ridge.strip_history([0.0, 2.0, 1.0, 1.5, 3.0], [100.0, 200.0, 300.0, 350.0, 400.0], [0.0, 10.0, 5.0, 8.0, 30.0], 0, 4); t = np.linspace(0, 3.0, ridge.NH); k = int(np.argmin(np.abs(t - 2.5)))
|
||||
assert abs(d[k] - (200 + 200 * (t[k] - 2) / 1.0)) < 1e-6 and abs(h[k] - (10 + 20 * (t[k] - 2) / 1.0)) < 1e-6 and d[0] == 100 and d[-1] == 400, (d[k], h[k])
|
||||
|
||||
def test_no_lateral_tilt_across_an_occlusion_edge():
|
||||
"""Block A (az 60-130, 15 km out, 700 m) and block B (az 110-180, 21 km out, 940 m): the same top angle, the same distance slot, so the slot owner switches where their tapers cross. Right answer: at every column where the owner's
|
||||
distance jumps by more than 2 km the two sides are not one surface, so the lateral tilt there is 0 (it saturated at the 1.5 clamp over ~8 columns on Matterhorn)."""
|
||||
r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 60, 130) & (dk >= 15000) & (dk < 19000), 700.0, np.where(in_arc(az, 110, 180) & (dk >= 21000) & (dk < 25000), 940.0, 0.0)), 120.0)
|
||||
S, Ly = _owner(r, L, col_of(80), theta(700, 15000)); assert S['dist'] == ridge.KNOTS[3]
|
||||
sw = [x for x in range(W - 1) if S['wt'][x] > 0 and S['wt'][x + 1] > 0 and abs(S['pd'][x + 1] - S['pd'][x]) > 2000]; assert sw, 'fixture has no owner switch'
|
||||
for x in sw:
|
||||
rows = np.arange(max(int((ridge.TOP_DEG - Ly['ang'][x]) * ridge.PXDEG_V) + 4, 0), min(int((ridge.TOP_DEG - Ly['lo'][x]) * ridge.PXDEG_V) - 4, ridge.H)); la = Ly['la'][np.ix_(rows, [x - 1, x, x + 1, x + 2])]
|
||||
assert np.abs(la).max() < 0.5, ('tilt across an occlusion edge at column', x, np.abs(la).max())
|
||||
|
||||
# ---------------------------------------------------------------------------------------- relief light (round 3): n.L uses an authored light, colour / haze / shadows keep the real sun
|
||||
def _gully(dk, az):
|
||||
"""A ramp facing the viewer (0.35 slope, az 50-130, 20-24 km) cut by gullies that run down the slope: height +300 m sin(2 pi N / 3 km), so the lateral tilt swings between -0.63 and +0.63 across the face."""
|
||||
N = dk * np.cos(np.radians(az)); return np.where(in_arc(az, 50, 130) & (dk >= 20000) & (dk < 24000), 50.0 + 0.35 * (dk - 20000) + 300.0 * np.sin(2 * np.pi * N / 3000.0), 0.0)
|
||||
def _relief_probe(az_s, el_s):
|
||||
"""Relight the gullied ramp seen from az 90 with spies on ndl_field (the L it shades with and the n.L it returns for the ramp's layer) and on cast_shadows (the sun it marches toward). Returns (image, Ls, n.L of the ramp layer, shadow suns, layer)."""
|
||||
r, L = _bake_view(_gully, 90.0); pf = ridge.statics(); S = max(r['slots'], key=lambda S: S['top'][col_of(90)]); Ly = next(l for l in L if l['dist'] == S['dist']); real_nd, real_cs = ridge.ndl_field, ridge.cast_shadows; Ls, nds, suns = [], [], []
|
||||
def nd_spy(layer, Lv): out = real_nd(layer, Lv); Ls.append(np.array(Lv)); nds.append((layer is Ly, out[0])); return out
|
||||
def cs_spy(layers, az, el): suns.append((az, el)); return [np.zeros((3, W)) for _ in layers]
|
||||
ridge.ndl_field, ridge.cast_shadows = nd_spy, cs_spy
|
||||
try: img = ridge.relight(L, az_s, el_s, *pf)
|
||||
finally: ridge.ndl_field, ridge.cast_shadows = real_nd, real_cs
|
||||
return img, Ls, next(n for own, n in nds if own), suns, Ly
|
||||
def _face_std(nd, Ly):
|
||||
"""n.L std over the ramp's face pixels (6 px under the crest down to 1 deg elevation), columns 500-780 of the window"""
|
||||
return float(np.concatenate([nd[int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 6:int((ridge.TOP_DEG - 1.0) * ridge.PXDEG_V), c] for c in (ridge.P['win0'] + np.arange(500, 780, 5)) % W]).std())
|
||||
def test_sun_behind_the_viewer_still_models_the_face():
|
||||
"""Gullied ramp seen from az 90, sun directly behind the viewer (az 270, 20 deg up). Lit head-on, a gully's two flanks shade alike (n.L std over the face 0.034 with the real sun as the shading light);
|
||||
the relief light 45 deg off the axis lights one flank and leaves the other dim (0.23). Right answer: std over the face pixels > 0.12."""
|
||||
img, Ls, nd, suns, Ly = _relief_probe(270.0, 20.0); b = _face_std(nd, Ly); assert b > 0.12, ('face is flat with the sun behind the viewer', b)
|
||||
def test_relief_light_is_45_deg_off_the_view_axis_on_the_suns_side():
|
||||
"""View centre az 90 (the viewer looks east). The relief light is the view centre +/- 45 deg on the side of the view axis the real sun is on. Sun at az 300 (north of the axis, the viewer's left) -> relief az 90-45 = 45;
|
||||
sun at az 240 (south, the viewer's right) -> relief az 90+45 = 135. Elevation is 25..40 deg (an absolute range, not the constant) both times, whatever the sun's elevation."""
|
||||
for sun_az, want in ((300.0, 45.0), (240.0, 135.0)):
|
||||
_, Ls, _, _, _ = _relief_probe(sun_az, 25.0); assert Ls, 'no shading call'; L = Ls[0]; az = math.degrees(math.atan2(L[0], L[1])) % 360.0; el = math.degrees(math.asin(L[2]))
|
||||
assert abs(az - want) < 0.5 and 25.0 <= el <= 40.0, ('relief direction wrong', sun_az, az, el)
|
||||
def test_sun_elevation_changes_colour_but_not_the_relief_direction():
|
||||
"""Same sun azimuth, elevation 10 deg vs 40 deg: the relief direction is identical, the picture is not (sun colour / haze / strength change), and at -10 deg (night) the face is darker than at 40."""
|
||||
i1, L1, nd1, _, Ly = _relief_probe(300.0, 10.0); i2, L2, nd2, _, _ = _relief_probe(300.0, 40.0); i3, _, _, _, _ = _relief_probe(300.0, -10.0)
|
||||
assert np.abs(L1[0] - L2[0]).max() < 1e-12 and np.abs(nd1 - nd2).max() < 1e-12, 'the relief direction follows the sun elevation'
|
||||
c = (ridge.P['win0'] + 640) % W; rows = slice(int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 8, int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 40)
|
||||
assert np.abs(i1[rows, 640] - i2[rows, 640]).mean() > 0.01, 'colour does not follow the real sun'; assert i3[rows, 640].mean() < 0.6 * i2[rows, 640].mean(), ('night is not dark', i3[rows, 640].mean(), i2[rows, 640].mean())
|
||||
def test_cast_shadows_march_toward_the_real_sun_not_the_relief_light():
|
||||
"""relight must hand cast_shadows the real sun's azimuth and elevation, not the relief light's."""
|
||||
_, _, _, suns, _ = _relief_probe(300.0, 17.0); assert suns == [(300.0, 17.0)], suns
|
||||
|
||||
# ---------------------------------------------------------------------------------------- Soul round-3 survivors
|
||||
def test_floor_band_has_no_column_wise_luminance_step():
|
||||
"""Plateau az 100-140 (20-24 km) seen from az 120. Right answer: in the floor band (the frame's bottom 80 rows) no column differs from its neighbour by more than the grain (baseline 0.009, a painted wall 0.25): the compositor must use the layer's
|
||||
alpha (body dissolved at the foot), not its mask (body to the bottom of the frame, a wall at every chain end)."""
|
||||
r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 900.0, 0.0), 120.0); img = ridge.relight(L, 200.0, 20.0, *ridge.statics(), shadows=False)
|
||||
lum = img @ np.array([0.2126, 0.7152, 0.0722]); st = np.abs(np.diff(lum[ridge.H - 80:], axis=1)).mean(0); assert st.max() < 0.03, ('wall: column-wise luminance step in the floor band', st.max(), int(np.argmax(st)))
|
||||
def test_foot_fade_starts_at_60_percent_of_the_foot_and_the_body_is_opaque_above_it():
|
||||
"""A layer's alpha is 1 down to 60% of its foot length, then smoothsteps to 0 at the foot. Right answer on a plateau column: rows with alpha > 0.99 / rows with alpha > 0.001 = 0.6 (+-0.04), and it has the full 210 px foot at most
|
||||
(a fade that starts early makes the face translucent, one that starts late leaves a wall)."""
|
||||
r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 900.0, 0.0), 120.0); S, Ly = _owner(r, L, col_of(120), theta(900, 20000)); a = Ly['a'][:, col_of(120)]
|
||||
full, nz = int((a > 0.99).sum()), int((a > 0.001).sum()); assert nz > 100, nz; assert abs(full / nz - 0.6) < 0.04, ('fade does not start at 60% of the foot', full, nz)
|
||||
def test_lateral_tilt_is_smoothed_across_columns():
|
||||
"""Two plateaus (700 / 1300 m) meeting at az 180: the raw lateral tilt is a spike at the step. Right answer: the baked tilt is blurred over a few columns: no column-to-column change above 0.5 (unblurred: 0.9), and the tilt is still > 0.2 at the step."""
|
||||
r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 60, 180) & (dk >= 20000) & (dk < 24000), 700.0, np.where(in_arc(az, 180, 300) & (dk >= 20000) & (dk < 24000), 1300.0, 0.0)), 180.0)
|
||||
S, Ly = _owner(r, L, col_of(120), theta(700, 20000)); rows = slice(int((ridge.TOP_DEG - theta(1300, 20000)) * ridge.PXDEG_V), int((ridge.TOP_DEG - 0.3) * ridge.PXDEG_V)); la = np.abs(Ly['la'][rows, :][:, (ridge.P['win0'] + np.arange(600, 680)) % W]).max(0)
|
||||
assert la.max() > 0.2 and np.abs(np.diff(la)).max() < 0.5, ('tilt not smoothed', la.max(), np.abs(np.diff(la)).max())
|
||||
def test_contour_interval_is_chosen_from_full_weight_columns_only():
|
||||
"""Ramp (slope 1.0, az 110-130, 20-22 km) whose tapering ends carry coarser metres-per-pixel. Right answer: the contour spacing in the core is 9-10 px (interval from the chain's own face), not 13-14 (the taper columns pulled the percentile up). median <= 12 and p25 >= 8."""
|
||||
r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 110, 130) & (dk >= 20000) & (dk < 22000), 50.0 + 1.0 * (dk - 20000), 0.0), 120.0); S, Ly = _owner(r, L, col_of(120), theta(2050, 22000)); sp = []
|
||||
for c in range(col_of(120) - 60, col_of(120) + 61, 6):
|
||||
r0, r1 = int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 4, int((ridge.TOP_DEG - Ly['lo'][c]) * ridge.PXDEG_V) - 4; line = np.where(Ly['dpx'][r0:r1, c] < 0.5)[0]
|
||||
if len(line) > 1: g = np.diff(line); sp += list(g[g > 1])
|
||||
assert len(sp) > 100 and np.percentile(sp, 25) >= 8 and np.median(sp) <= 12, ('contour spacing', len(sp), np.percentile(sp, 25), np.median(sp))
|
||||
def test_spec_highlight_follows_the_half_vector_of_light_and_viewer():
|
||||
"""spec_field against an independent evaluation: half vector H = normalise(L + V), V the unit vector back to the viewer (-r) in every column, n = (-sl r - la t + z)/|.|, GGX alpha 0.3. Two different lights, random faces: the field must match to 1e-9
|
||||
(a half vector that ignores the light, or any change of the lobe, shows)."""
|
||||
rng = np.random.default_rng(5); sl = rng.uniform(-0.3, 1.2, (7, W)); la = rng.uniform(-0.6, 0.6, (7, W)); nrm = np.sqrt(1 + sl * sl + la * la); az = ridge.azrad(); r = np.stack([np.sin(az), np.cos(az), 0 * az], 1); t = np.stack([np.cos(az), -np.sin(az), 0 * az], 1)
|
||||
for laz, lel in ((45.0, 32.0), (200.0, 15.0)):
|
||||
Lv = ridge.sun_vec(laz, lel); ndl = (Lv[2] - sl * (r @ Lv)[None] - la * (t @ Lv)[None]) / nrm; got = ridge.spec_field(Lv, sl, la, nrm, ndl)
|
||||
H = Lv[None, :] - r; H /= np.linalg.norm(H, axis=1, keepdims=True); nh = np.clip((H[:, 2][None] - sl * (r * H).sum(1)[None] - la * (t * H).sum(1)[None]) / nrm, 0, 1)
|
||||
want = 0.30 * 0.0081 / (1 + nh * nh * (0.09 - 1)) ** 2 * np.clip(ndl, 0, 1)
|
||||
assert np.abs(got - want).max() < 1e-9, ('spec does not match the half-vector model', laz, np.abs(got - want).max())
|
||||
def test_fresnel_rim_is_strongest_on_faces_that_lie_away_from_the_viewer():
|
||||
"""fresnel(sl, nrm) = (1 - n.V)^3 with n.V = sl / |n|: 1 for a flat or back-leaning face (sl <= 0), falling monotonically to ~0 for a face that stands up at the viewer (sl = 5: 0.00001)."""
|
||||
s = np.array([-1.0, 0.0, 0.2, 0.5, 1.0, 2.0, 5.0]); f = ridge.fresnel(s, np.sqrt(1 + s * s))
|
||||
assert f[0] == 1.0 and f[1] == 1.0 and (np.diff(f[1:]) < 0).all() and f[-1] < 1e-3, ('fresnel shape', f)
|
||||
def test_cache_keys_follow_every_input_of_the_cached_array():
|
||||
"""mosaic-/rays- cache files are keyed by a digest of their inputs. Right answer: identical inputs -> identical key (the preset id does not enter), a changed preset field (eye height, range, position), tile list or baker constant (refraction, detail noise) -> a different key."""
|
||||
saved = ridge.P
|
||||
try:
|
||||
base = dict(id='k1', lat=47.0, lon=11.0, rmax_km=40, eye_m=2000.0); ridge.P = dict(base); k0 = (ridge.rays_key(False), ridge.rays_key(True), ridge.mosaic_key(ridge.P))
|
||||
ridge.P = dict(base, id='other'); assert (ridge.rays_key(False), ridge.rays_key(True), ridge.mosaic_key(ridge.P)) == k0, 'the preset id changed the key'
|
||||
for fld, v in (('eye_m', 2010.0), ('rmax_km', 41), ('lon', 11.001), ('lat', 47.001)):
|
||||
ridge.P = dict(base, **{fld: v}); assert ridge.rays_key(False) != k0[0], ('rays key ignores', fld)
|
||||
ridge.P = dict(base, lat=47.0, lon=13.5); assert ridge.mosaic_key(ridge.P) != k0[2], 'mosaic key ignores the tile list'
|
||||
ridge.P = dict(base); old = ridge.KREF; ridge.KREF = 0.2
|
||||
try: assert ridge.rays_key(False) != k0[0], 'rays key ignores the refraction constant'
|
||||
finally: ridge.KREF = old
|
||||
old = ridge.DETAIL; ridge.DETAIL = dict(old, amp_m=30.0)
|
||||
try: assert ridge.rays_key(True) != k0[1] and ridge.rays_key(False) == k0[0], 'detail key ignores the noise settings (or the plain key follows them)'
|
||||
finally: ridge.DETAIL = old
|
||||
finally: ridge.P = saved
|
||||
def test_paths_come_from_the_environment_with_no_default():
|
||||
"""Importing the baker without RIDGE_WORK / RIDGE_TILES fails with a message naming the variable (no location is assumed)."""
|
||||
import subprocess; env = {k: v for k, v in os.environ.items() if not k.startswith('RIDGE_')}
|
||||
for var in ('RIDGE_WORK', 'RIDGE_TILES'):
|
||||
e = dict(env); e.update({'RIDGE_WORK': '.', 'RIDGE_TILES': '.'}); del e[var]
|
||||
r = subprocess.run([sys.executable, '-I', '-c', 'import sys; sys.path.insert(0, %r); import ridge' % SRC], env=e, capture_output=True, text=True); assert r.returncode and var in r.stderr and 'not set' in r.stderr, (var, r.stderr[-200:])
|
||||
|
||||
TESTS = [v for k, v in sorted(globals().items()) if k.startswith('test_')]
|
||||
if __name__ == '__main__':
|
||||
fails = 0
|
||||
for t in TESTS:
|
||||
try: t(); print('PASS', t.__name__)
|
||||
except Exception as e: fails += 1; print('FAIL', t.__name__, '|', repr(e)[:300])
|
||||
print('failures:', fails); sys.exit(1 if fails else 0)
|
||||
Loading…
Reference in a new issue