mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
feat(intro): v2 — cities, accelerating sweep, detections, thriller sound design
- City engine: Esri ground, OSM buildings that rise with the scan, road ribbons carrying modelled traffic coloured by congestion, NYC DOT cameras, real ADS-B, MBTA GTFS lines + realtime vehicles, LAX runways with approach strobes, Fort Bragg restricted volumes, Everest terrain with contours. - Indexing keeps accelerating into a 260-capture global sweep while the planet whips a full turn. - The eye opens: aperture blades, sensor array waking, optics calibration, radar sweep, orbiting coordinate ring. - Camera wall with YOLOv8n detections and a city-pulse trace. - Ends on GOD'S EYE VIEW / OMNISCIENCE. - audio/sfx.py: sound design only, built to sit under a music track. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KqP857pippaUs6konSDfe8
This commit is contained in:
30 files changed
+8921
-1442
No files matched your search
@@ -0,0 +1,79 @@
|
||||
"""Render city/<slug>/check.png: ground imagery (left) and imagery + OSM overlay (right) for alignment QA.
|
||||
|
||||
usage: python3 -I tools/city/check.py nyc boston [--crop x y size]
|
||||
"""
|
||||
import sys, os, json
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from PIL import Image, ImageDraw
|
||||
from geo import CITIES, OUT, CACHE
|
||||
|
||||
ROADC = [(255, 60, 60), (255, 150, 40), (255, 220, 40), (200, 255, 80), (255, 255, 255), (160, 160, 160)]
|
||||
|
||||
|
||||
def render(slug, S=1400, crop=None):
|
||||
d = os.path.join(OUT, slug)
|
||||
h = CITIES[slug]['half']
|
||||
cx, cy, cs = crop if crop else (0, 0, h) # center and half-size of view in meters
|
||||
g = Image.open(os.path.join(d, 'ground.jpg')).convert('RGB')
|
||||
G = g.size[0]
|
||||
# crop region of ground in pixels
|
||||
l = (cx - cs + h) / (2 * h) * G
|
||||
t = (h - (cy + cs)) / (2 * h) * G
|
||||
r = (cx + cs + h) / (2 * h) * G
|
||||
b = (h - (cy - cs)) / (2 * h) * G
|
||||
base = g.crop((int(l), int(t), int(r), int(b))).resize((S, S), Image.LANCZOS)
|
||||
|
||||
def P(x, y):
|
||||
return ((x - (cx - cs)) / (2 * cs) * S, ((cy + cs) - y) / (2 * cs) * S)
|
||||
|
||||
ov = base.copy()
|
||||
dr = ImageDraw.Draw(ov, 'RGBA')
|
||||
wp = os.path.join(d, 'water.json')
|
||||
if os.path.exists(wp):
|
||||
for poly in json.load(open(wp))['polys']:
|
||||
for k, ring in enumerate(poly):
|
||||
pts = [P(ring[i], ring[i + 1]) for i in range(0, len(ring), 2)]
|
||||
if len(pts) >= 3:
|
||||
dr.polygon(pts, outline=(60, 160, 255, 255), fill=(30, 90, 255, 70) if k == 0 else None)
|
||||
for bld in json.load(open(os.path.join(d, 'buildings.json')))['b']:
|
||||
hgt = bld[0]
|
||||
pts = [P(bld[i], bld[i + 1]) for i in range(2, len(bld), 2)]
|
||||
a = min(255, 60 + int(hgt))
|
||||
col = (0, 255, 200, 255) if hgt >= 100 else (255, 0, 220, 255)
|
||||
dr.polygon(pts, outline=col, fill=(col[0], col[1], col[2], 40 if hgt < 100 else 90))
|
||||
for rd in json.load(open(os.path.join(d, 'roads.json')))['roads']:
|
||||
pts = [P(rd[i], rd[i + 1]) for i in range(3, len(rd), 2)]
|
||||
dr.line(pts, fill=ROADC[rd[0]] + (230,), width=2 if rd[0] < 3 else 1)
|
||||
rp = os.path.join(d, 'rail.json')
|
||||
if os.path.exists(rp):
|
||||
for ln in json.load(open(rp))['lines']:
|
||||
c = ln[0]
|
||||
col = tuple(int(c[i:i + 2], 16) for i in (1, 3, 5)) + (255,)
|
||||
pts = [P(ln[i], ln[i + 1]) for i in range(1, len(ln), 2)]
|
||||
dr.line(pts, fill=col, width=3)
|
||||
ep = os.path.join(d, 'extra.json')
|
||||
if os.path.exists(ep):
|
||||
for x, y, nm in json.load(open(ep))['helipads']:
|
||||
px, py = P(x, y)
|
||||
dr.ellipse((px - 7, py - 7, px + 7, py + 7), outline=(255, 255, 0, 255), width=3)
|
||||
out = Image.new('RGB', (S * 2 + 10, S), (0, 0, 0))
|
||||
out.paste(base, (0, 0))
|
||||
out.paste(ov, (S + 10, 0))
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = [a for a in sys.argv[1:] if not a.startswith('--')]
|
||||
crop = None
|
||||
if '--crop' in sys.argv:
|
||||
i = sys.argv.index('--crop')
|
||||
crop = tuple(float(v) for v in sys.argv[i + 1:i + 4])
|
||||
args = [a for a in args if a not in sys.argv[i + 1:i + 4]]
|
||||
for slug in args or list(CITIES):
|
||||
im = render(slug, crop=crop)
|
||||
if crop:
|
||||
p = os.path.join(CACHE, f'{slug}_check_crop_{int(crop[0])}_{int(crop[1])}_{int(crop[2])}.png')
|
||||
else:
|
||||
p = os.path.join(OUT, slug, 'check.png')
|
||||
im.save(p, optimize=True)
|
||||
print('wrote', p)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Esri World Imagery mosaic -> local ENU square, 4096^2 + 2048^2 JPEG.
|
||||
|
||||
usage: python3 -I tools/city/fetch_imagery.py nyc boston [--zoom 17]
|
||||
"""
|
||||
import sys, os, math, io, time
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
from scipy.ndimage import map_coordinates
|
||||
from geo import CITIES, OUT, CACHE, Frame
|
||||
|
||||
URL = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
|
||||
TS = 256
|
||||
N = 4096
|
||||
SS = 2 # supersample factor before Lanczos downsample
|
||||
|
||||
|
||||
def merc_px(lat, lon, z):
|
||||
s = TS * 2 ** z
|
||||
x = (lon + 180.0) / 360.0 * s
|
||||
lr = np.radians(lat)
|
||||
y = (1.0 - np.log(np.tan(lr) + 1.0 / np.cos(lr)) / math.pi) / 2.0 * s
|
||||
return x, y
|
||||
|
||||
|
||||
sess = requests.Session()
|
||||
sess.headers['User-Agent'] = 'gods-eye-view-intro/1.0 (film asset build)'
|
||||
|
||||
|
||||
def get_tile(z, x, y):
|
||||
d = os.path.join(CACHE, 'tiles', str(z), str(x))
|
||||
os.makedirs(d, exist_ok=True)
|
||||
p = os.path.join(d, f'{y}.jpg')
|
||||
if os.path.exists(p) and os.path.getsize(p) > 0:
|
||||
return p, 'cache'
|
||||
last = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
r = sess.get(URL.format(z=z, x=x, y=y), timeout=60)
|
||||
if r.status_code == 200 and r.content[:2] in (b'\xff\xd8', b'\x89P'):
|
||||
with open(p, 'wb') as f:
|
||||
f.write(r.content)
|
||||
return p, 'net'
|
||||
last = f'HTTP {r.status_code} {r.headers.get("content-type")}'
|
||||
except Exception as e: # noqa
|
||||
last = str(e)[:120]
|
||||
time.sleep(1.5 * (attempt + 1))
|
||||
raise RuntimeError(f'tile {z}/{x}/{y} failed: {last}')
|
||||
|
||||
|
||||
def build(slug, z):
|
||||
c = CITIES[slug]
|
||||
F = Frame(**c)
|
||||
h = F.half
|
||||
s, w, n, e = F.bbox(h * 1.02)
|
||||
x0, y0 = merc_px(n, w, z)
|
||||
x1, y1 = merc_px(s, e, z)
|
||||
tx0, ty0 = int(x0 // TS), int(y0 // TS)
|
||||
tx1, ty1 = int(x1 // TS), int(y1 // TS)
|
||||
tiles = [(tx, ty) for ty in range(ty0, ty1 + 1) for tx in range(tx0, tx1 + 1)]
|
||||
print(f'== {slug} z{z}: {tx1-tx0+1}x{ty1-ty0+1} = {len(tiles)} tiles')
|
||||
with ThreadPoolExecutor(8) as ex:
|
||||
res = list(ex.map(lambda t: get_tile(z, t[0], t[1]), tiles))
|
||||
print(f' net {sum(1 for r in res if r[1]=="net")} cache {sum(1 for r in res if r[1]=="cache")}')
|
||||
W, H = (tx1 - tx0 + 1) * TS, (ty1 - ty0 + 1) * TS
|
||||
mos = np.zeros((H, W, 3), np.uint8)
|
||||
for (tx, ty), (p, _) in zip(tiles, res):
|
||||
im = np.asarray(Image.open(p).convert('RGB'))
|
||||
mos[(ty - ty0) * TS:(ty - ty0 + 1) * TS, (tx - tx0) * TS:(tx - tx0 + 1) * TS] = im
|
||||
M = N * SS
|
||||
t = (np.arange(M) + 0.5) / M
|
||||
xs = -h + 2 * h * t # east
|
||||
ys = h - 2 * h * t # north, row 0 at top
|
||||
lon = F.lon0 + xs / F.kx
|
||||
lat = F.lat0 + ys / F.ky
|
||||
px, _ = merc_px(np.full_like(lon, F.lat0), lon, z)
|
||||
_, py = merc_px(lat, np.full_like(lat, F.lon0), z)
|
||||
px = px - tx0 * TS - 0.5 # pixel centers
|
||||
py = py - ty0 * TS - 0.5
|
||||
PX, PY = np.meshgrid(px, py)
|
||||
out = np.empty((M, M, 3), np.uint8)
|
||||
for ch in range(3):
|
||||
out[..., ch] = np.clip(map_coordinates(mos[..., ch].astype(np.float32), [PY, PX], order=1,
|
||||
mode='nearest'), 0, 255).astype(np.uint8)
|
||||
del PX, PY
|
||||
img = Image.fromarray(out)
|
||||
if SS != 1:
|
||||
img = img.resize((N, N), Image.LANCZOS)
|
||||
d = os.path.join(OUT, slug)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
img.save(os.path.join(d, 'ground.jpg'), quality=90, optimize=True)
|
||||
img.resize((2048, 2048), Image.LANCZOS).save(os.path.join(d, 'ground_2k.jpg'), quality=90, optimize=True)
|
||||
img.resize((768, 768), Image.LANCZOS).save(os.path.join(CACHE, f'{slug}_ground_preview.jpg'), quality=85)
|
||||
mpp_src = 156543.03392 * math.cos(math.radians(F.lat0)) / 2 ** z
|
||||
print(f' wrote ground.jpg {os.path.getsize(os.path.join(d, "ground.jpg"))/1e6:.1f}MB; '
|
||||
f'source {mpp_src:.2f} m/px, output {2*h/N:.2f} m/px')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = [a for a in sys.argv[1:] if not a.startswith('--')]
|
||||
z = 17
|
||||
if '--zoom' in sys.argv:
|
||||
z = int(sys.argv[sys.argv.index('--zoom') + 1])
|
||||
for slug in args or list(CITIES):
|
||||
build(slug, z)
|
||||
@@ -0,0 +1,495 @@
|
||||
"""Fetch OSM buildings / roads / water / extras for the city scenes and write city/<slug>/*.json.
|
||||
|
||||
usage: python3 -I tools/city/fetch_osm.py nyc boston
|
||||
"""
|
||||
import sys, os, math, re, json
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from geo import (CITIES, OUT, Frame, overpass, rdp, rdp_ring, area, centroid, open_ring,
|
||||
assemble_rings, clip_polyline, clip_polygon, flat, r1, write_json, dedupe)
|
||||
|
||||
FT = 0.3048
|
||||
|
||||
|
||||
def parse_len(v):
|
||||
"""Parse an OSM length tag to meters. Returns None if unparseable."""
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip().lower().replace(',', '.')
|
||||
s = s.split(';')[0].strip()
|
||||
m = re.match(r"^(\d+(?:\.\d+)?)\s*'\s*(?:(\d+(?:\.\d+)?)\s*\"?)?$", s) # 12'6"
|
||||
if m:
|
||||
return float(m.group(1)) * FT + (float(m.group(2)) * 0.0254 if m.group(2) else 0)
|
||||
m = re.match(r'^(-?\d+(?:\.\d+)?)\s*(m|meters?|metres?|ft|feet|foot)?$', s)
|
||||
if not m:
|
||||
return None
|
||||
val = float(m.group(1))
|
||||
u = m.group(2) or 'm'
|
||||
if u in ('ft', 'feet', 'foot'):
|
||||
val *= FT
|
||||
return val
|
||||
|
||||
|
||||
def parse_num(v):
|
||||
if v is None:
|
||||
return None
|
||||
m = re.match(r'^\s*(\d+(?:\.\d+)?)', str(v).replace(',', '.'))
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
|
||||
HOUSE = {'house', 'detached', 'semidetached_house', 'terrace', 'bungalow', 'cabin', 'shed', 'garage',
|
||||
'garages', 'hut', 'carport', 'static_caravan'}
|
||||
|
||||
|
||||
def building_height(t):
|
||||
"""Returns (height, min_height, source) where source in h/lv/def."""
|
||||
h = parse_len(t.get('height'))
|
||||
src = 'h'
|
||||
if h is None or h <= 0:
|
||||
lv = parse_num(t.get('building:levels'))
|
||||
if lv is not None and lv > 0:
|
||||
h = lv * 3.2 + (parse_num(t.get('roof:levels')) or 0) * 3.2
|
||||
src = 'lv'
|
||||
else:
|
||||
b = t.get('building', 'yes')
|
||||
h = 7.0 if b in HOUSE else 12.0
|
||||
if b in ('shed', 'garage', 'garages', 'carport', 'hut'):
|
||||
h = 3.5
|
||||
src = 'def'
|
||||
mh = parse_len(t.get('min_height'))
|
||||
if mh is None:
|
||||
ml = parse_num(t.get('building:min_level'))
|
||||
mh = ml * 3.2 if ml else 0.0
|
||||
mh = max(0.0, mh)
|
||||
if mh >= h:
|
||||
mh = 0.0
|
||||
return h, mh, src
|
||||
|
||||
|
||||
def ll_list(geom):
|
||||
return [(g['lat'], g['lon']) for g in geom if g is not None]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ buildings
|
||||
|
||||
def buildings(slug, F):
|
||||
h = F.half
|
||||
s, w, n, e = F.bbox(h + 60)
|
||||
# tile the bbox to keep each response manageable
|
||||
T = 2 if h > 3000 else 1
|
||||
elems = {}
|
||||
for i in range(T):
|
||||
for j in range(T):
|
||||
ts = s + (n - s) * i / T
|
||||
tn = s + (n - s) * (i + 1) / T
|
||||
tw = w + (e - w) * j / T
|
||||
te = w + (e - w) * (j + 1) / T
|
||||
bb = f'{ts:.6f},{tw:.6f},{tn:.6f},{te:.6f}'
|
||||
q = (f'[out:json][timeout:180];(way["building"]({bb});'
|
||||
f'relation["building"]["type"="multipolygon"]({bb}););out geom;')
|
||||
print(f' buildings tile {i},{j}')
|
||||
d = overpass(q, f'{slug}_bld_{i}{j}')
|
||||
for el in d['elements']:
|
||||
elems[(el['type'], el['id'])] = el
|
||||
print(f' {len(elems)} building elements')
|
||||
out = []
|
||||
stats = dict(h=0, lv=0, df=0, dropped_small=0, dropped_out=0, rel_rings=0)
|
||||
for (typ, _id), el in elems.items():
|
||||
t = el.get('tags', {})
|
||||
if t.get('building') in ('no', 'construction_site'):
|
||||
continue
|
||||
rings = []
|
||||
if typ == 'way':
|
||||
if 'geometry' not in el:
|
||||
continue
|
||||
g = ll_list(el['geometry'])
|
||||
if len(g) >= 4 and g[0] == g[-1]:
|
||||
rings.append(g)
|
||||
else:
|
||||
outers = [ll_list(m['geometry']) for m in el.get('members', [])
|
||||
if m['type'] == 'way' and m.get('role') == 'outer' and 'geometry' in m]
|
||||
rings = assemble_rings(outers)
|
||||
stats['rel_rings'] += len(rings)
|
||||
hgt, mh, src = building_height(t)
|
||||
for g in rings:
|
||||
pts = open_ring([F.xy(la, lo) for la, lo in g])
|
||||
pts = rdp_ring(pts, 0.5)
|
||||
if len(pts) < 3 or abs(area(pts)) < 8:
|
||||
stats['dropped_small'] += 1
|
||||
continue
|
||||
cx, cy = centroid(pts)
|
||||
if abs(cx) > h or abs(cy) > h:
|
||||
stats['dropped_out'] += 1
|
||||
continue
|
||||
stats[{'h': 'h', 'lv': 'lv', 'def': 'df'}[src]] += 1
|
||||
out.append((cx * cx + cy * cy, [r1(hgt), r1(mh)] + flat(pts)))
|
||||
out.sort(key=lambda a: a[0])
|
||||
b = [o[1] for o in out]
|
||||
write_json(os.path.join(OUT, slug, 'buildings.json'), {'count': len(b), 'b': b})
|
||||
hs = sorted(x[0] for x in b)
|
||||
print(f' buildings {len(b)} height-src tag={stats["h"]} levels={stats["lv"]} default={stats["df"]} '
|
||||
f'dropped small={stats["dropped_small"]} outside={stats["dropped_out"]} tallest={hs[-5:]}')
|
||||
return dict(count=len(b), height_from_tag=stats['h'], height_from_levels=stats['lv'],
|
||||
height_default=stats['df'], tallest_m=hs[-1] if hs else None)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ roads
|
||||
|
||||
CLS = {'motorway': 0, 'trunk': 0, 'primary': 1, 'secondary': 2, 'tertiary': 3,
|
||||
'residential': 4, 'unclassified': 4, 'service': 5}
|
||||
DEF_LANES = {0: 3, 1: 2, 2: 2, 3: 2, 4: 1, 5: 1}
|
||||
|
||||
|
||||
def plen(pts):
|
||||
return sum(math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]) for i in range(len(pts) - 1))
|
||||
|
||||
|
||||
def roads(slug, F):
|
||||
h = F.half
|
||||
s, w, n, e = F.bbox(h + 100)
|
||||
bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}'
|
||||
q = (f'[out:json][timeout:180];way["highway"~"^(motorway|trunk|primary|secondary|tertiary|residential|'
|
||||
f'unclassified|service|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link)$"]({bb});out geom;')
|
||||
print(' roads')
|
||||
d = overpass(q, f'{slug}_roads')
|
||||
out = []
|
||||
cnt = [0] * 6
|
||||
tunnels = 0
|
||||
short_service = 0
|
||||
for el in d['elements']:
|
||||
t = el.get('tags', {})
|
||||
hw = t['highway']
|
||||
if t.get('area') == 'yes':
|
||||
continue
|
||||
if t.get('tunnel') in ('yes', 'covered') or t.get('layer', '0').startswith('-') and t.get('tunnel'):
|
||||
tunnels += 1
|
||||
continue
|
||||
pts = [F.xy(la, lo) for la, lo in ll_list(el.get('geometry', []))]
|
||||
pts = dedupe(pts)
|
||||
if len(pts) < 2:
|
||||
continue
|
||||
if hw.endswith('_link'):
|
||||
c = 5
|
||||
else:
|
||||
c = CLS[hw]
|
||||
if hw == 'service' and plen(pts) <= 80:
|
||||
short_service += 1
|
||||
continue
|
||||
ow = t.get('oneway', '')
|
||||
oneway = 0
|
||||
if ow in ('yes', '1', 'true'):
|
||||
oneway = 1
|
||||
elif ow == '-1':
|
||||
oneway = 1
|
||||
pts = pts[::-1]
|
||||
elif ow == 'no':
|
||||
oneway = 0
|
||||
elif hw in ('motorway', 'motorway_link') or t.get('junction') in ('roundabout', 'circular'):
|
||||
oneway = 1
|
||||
ln = parse_num(t.get('lanes'))
|
||||
lanes = int(ln) if ln else DEF_LANES[c]
|
||||
for piece in clip_polyline(pts, -h, -h, h, h):
|
||||
piece = rdp(piece, 1.0)
|
||||
if len(piece) < 2 or plen(piece) < 1:
|
||||
continue
|
||||
out.append([c, oneway, lanes] + flat(piece))
|
||||
cnt[c] += 1
|
||||
write_json(os.path.join(OUT, slug, 'roads.json'), {'roads': out})
|
||||
print(f' roads {len(out)} by class {cnt} (dropped tunnels {tunnels}, short service {short_service})')
|
||||
return dict(count=len(out), by_class=cnt, dropped_tunnel_ways=tunnels, dropped_short_service=short_service)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ water
|
||||
|
||||
def coast_water(slug, F, h):
|
||||
"""Build water polygons inside the square from natural=coastline (land left, water right)."""
|
||||
s, w, n, e = F.bbox(h * 1.6)
|
||||
bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}'
|
||||
q = f'[out:json][timeout:180];way["natural"="coastline"]({bb});out geom;'
|
||||
print(' coastline')
|
||||
d = overpass(q, f'{slug}_coast')
|
||||
ways = [[F.xy(la, lo) for la, lo in ll_list(el['geometry'])] for el in d['elements'] if 'geometry' in el]
|
||||
# directional merge into chains
|
||||
chains = []
|
||||
pool = [wy for wy in ways if len(wy) >= 2]
|
||||
while pool:
|
||||
cur = pool.pop()
|
||||
grown = True
|
||||
while grown and cur[0] != cur[-1]:
|
||||
grown = False
|
||||
for i, wy in enumerate(pool):
|
||||
if wy[0] == cur[-1]:
|
||||
cur = cur + wy[1:]
|
||||
elif wy[-1] == cur[0]:
|
||||
cur = wy + cur[1:]
|
||||
else:
|
||||
continue
|
||||
pool.pop(i)
|
||||
grown = True
|
||||
break
|
||||
chains.append(cur)
|
||||
eps = 1e-6
|
||||
|
||||
def on_edge(p):
|
||||
return abs(abs(p[0]) - h) < 1e-3 or abs(abs(p[1]) - h) < 1e-3
|
||||
|
||||
def spos(p): # clockwise perimeter coordinate from top-left
|
||||
x, y = p
|
||||
if abs(y - h) < 1e-3:
|
||||
return x + h
|
||||
if abs(x - h) < 1e-3:
|
||||
return 2 * h + (h - y)
|
||||
if abs(y + h) < 1e-3:
|
||||
return 4 * h + (h - x)
|
||||
return 6 * h + (y + h)
|
||||
|
||||
corners = [(0.0, (-h, h)), (2 * h, (h, h)), (4 * h, (h, -h)), (6 * h, (-h, -h))]
|
||||
pieces, islands, bad = [], [], 0
|
||||
for ch in chains:
|
||||
closed = ch[0] == ch[-1]
|
||||
inside_all = all(abs(x) <= h and abs(y) <= h for x, y in ch)
|
||||
if closed and inside_all:
|
||||
islands.append(open_ring(ch))
|
||||
continue
|
||||
for pc in clip_polyline(ch, -h, -h, h, h):
|
||||
if on_edge(pc[0]) and on_edge(pc[-1]):
|
||||
pieces.append(pc)
|
||||
else:
|
||||
bad += 1
|
||||
polys = []
|
||||
if pieces:
|
||||
used = [False] * len(pieces)
|
||||
starts = [spos(p[0]) for p in pieces]
|
||||
for k in range(len(pieces)):
|
||||
if used[k]:
|
||||
continue
|
||||
ring = []
|
||||
cur = k
|
||||
guard = 0
|
||||
while True:
|
||||
used[cur] = True
|
||||
ring += pieces[cur]
|
||||
se = spos(pieces[cur][-1])
|
||||
# next entry clockwise from se
|
||||
best, bd = None, None
|
||||
for m in range(len(pieces)):
|
||||
dd = (starts[m] - se) % (8 * h)
|
||||
if bd is None or dd < bd:
|
||||
best, bd = m, dd
|
||||
# add corners passed between se and se+bd
|
||||
for cs, cp in sorted(corners, key=lambda c: (c[0] - se) % (8 * h)):
|
||||
if 0 < (cs - se) % (8 * h) < bd:
|
||||
ring.append(cp)
|
||||
cur = best
|
||||
guard += 1
|
||||
if cur == k or guard > len(pieces) + 2:
|
||||
break
|
||||
if used[cur]:
|
||||
break
|
||||
polys.append([dedupe(open_ring(ring))])
|
||||
# attach islands (land holes) to the containing water polygon
|
||||
def pip(pt, ring):
|
||||
x, y = pt
|
||||
c = False
|
||||
for i in range(len(ring)):
|
||||
x1, y1 = ring[i]
|
||||
x2, y2 = ring[i - 1]
|
||||
if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:
|
||||
c = not c
|
||||
return c
|
||||
for isl in islands:
|
||||
if abs(area(isl)) < 50:
|
||||
continue
|
||||
for p in polys:
|
||||
if pip(isl[0], p[0]):
|
||||
p.append(isl)
|
||||
break
|
||||
print(f' coastline chains {len(chains)} pieces {len(pieces)} islands {len(islands)} '
|
||||
f'water polys {len(polys)} (unclosed pieces skipped {bad})')
|
||||
return polys, bad
|
||||
|
||||
|
||||
def water(slug, F):
|
||||
h = F.half
|
||||
s, w, n, e = F.bbox(h + 50)
|
||||
bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}'
|
||||
q = (f'[out:json][timeout:180];(way["natural"="water"]({bb});relation["natural"="water"]({bb});'
|
||||
f'way["waterway"="riverbank"]({bb});relation["waterway"="riverbank"]({bb}););out geom;')
|
||||
print(' water areas')
|
||||
d = overpass(q, f'{slug}_water')
|
||||
polys = []
|
||||
for el in d['elements']:
|
||||
t = el.get('tags', {})
|
||||
if t.get('water') in ('pond', 'reflecting_pool', 'fountain', 'basin', 'reservoir') and el['type'] == 'way':
|
||||
pass # keep small ponds too; they are real water
|
||||
if el['type'] == 'way':
|
||||
if 'geometry' not in el:
|
||||
continue
|
||||
g = ll_list(el['geometry'])
|
||||
if g[0] != g[-1]:
|
||||
continue
|
||||
outers, inners = [g], []
|
||||
else:
|
||||
mem = [m for m in el.get('members', []) if m['type'] == 'way' and 'geometry' in m]
|
||||
outers = assemble_rings([ll_list(m['geometry']) for m in mem if m.get('role') == 'outer'])
|
||||
inners = assemble_rings([ll_list(m['geometry']) for m in mem if m.get('role') == 'inner'])
|
||||
inn_xy = [open_ring([F.xy(*p) for p in r]) for r in inners]
|
||||
for o in outers:
|
||||
ring = open_ring([F.xy(*p) for p in o])
|
||||
ring = clip_polygon(ring, -h, -h, h, h)
|
||||
ring = rdp_ring(open_ring(ring), 1.0)
|
||||
if len(ring) < 3 or abs(area(ring)) < 20:
|
||||
continue
|
||||
p = [ring]
|
||||
for ir in inn_xy:
|
||||
ir2 = rdp_ring(open_ring(clip_polygon(ir, -h, -h, h, h)), 1.0)
|
||||
if len(ir2) >= 3 and abs(area(ir2)) >= 20:
|
||||
p.append(ir2)
|
||||
polys.append(('water', p))
|
||||
n_area = len(polys)
|
||||
cpolys, bad = coast_water(slug, F, h)
|
||||
for p in cpolys:
|
||||
p2 = [rdp_ring(r, 1.0) for r in p]
|
||||
p2 = [r for r in p2 if len(r) >= 3]
|
||||
if p2:
|
||||
polys.append(('coast', p2))
|
||||
out = [[flat(r) for r in p] for _, p in polys]
|
||||
src = [s_ for s_, _ in polys]
|
||||
write_json(os.path.join(OUT, slug, 'water.json'),
|
||||
{'count': len(out), 'format': 'polys[i] = [outerRing, holeRing...], rings flat x,y', 'src': src,
|
||||
'polys': out})
|
||||
print(f' water polys {len(out)} (osm water areas {n_area}, coastline-derived {len(cpolys)})')
|
||||
return dict(count=len(out), water_areas=n_area, coastline_polys=len(cpolys), coastline_unclosed_pieces=bad)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ extras
|
||||
|
||||
KNOWN_HELI = ['Downtown Manhattan Heliport', 'West 30th Street Heliport', 'East 34th Street Heliport']
|
||||
|
||||
|
||||
def nyc_extra(slug, F):
|
||||
s, w, n, e = F.bbox(F.half + 2000)
|
||||
bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}'
|
||||
q = (f'[out:json][timeout:180];(nwr["aeroway"~"^(helipad|heliport)$"]({bb});'
|
||||
f'nwr["name"~"heliport|helistop|helipad",i]({bb}););out center tags;')
|
||||
print(' helipads')
|
||||
d = overpass(q, f'{slug}_heli2')
|
||||
pads = []
|
||||
for el in d['elements']:
|
||||
t = el.get('tags', {})
|
||||
if el['type'] == 'node':
|
||||
la, lo = el['lat'], el['lon']
|
||||
else:
|
||||
c = el.get('center')
|
||||
if not c:
|
||||
continue
|
||||
la, lo = c['lat'], c['lon']
|
||||
x, y = F.xy(la, lo)
|
||||
name = t.get('name') or t.get('operator') or ''
|
||||
kind = t.get('aeroway') or ('heliport' if 'heliport' in name.lower() else 'named')
|
||||
pads.append(dict(x=r1(x), y=r1(y), name=name, kind=kind, ref=t.get('ref', ''), osm=f"{el['type']}/{el['id']}",
|
||||
tags={k: v for k, v in t.items() if k in ('aeroway', 'faa', 'operator', 'man_made', 'amenity')},
|
||||
inside=abs(x) <= F.half and abs(y) <= F.half))
|
||||
pads.sort(key=lambda p: (p['kind'] != 'heliport', p['x'] ** 2 + p['y'] ** 2))
|
||||
for p in pads:
|
||||
print(f" {p['kind']:9s} {p['x']:8.1f} {p['y']:8.1f} {'in ' if p['inside'] else 'OUT'} {p['name']}")
|
||||
found = {k: [p for p in pads if p['kind'] == 'heliport' and k.split()[0].lower() in p['name'].lower()
|
||||
and 'heliport' in p['name'].lower()] for k in KNOWN_HELI}
|
||||
helipads = [[p['x'], p['y'], p['name'] or p['kind']] for p in pads]
|
||||
write_json(os.path.join(OUT, slug, 'extra.json'),
|
||||
{'helipads': helipads,
|
||||
'detail': pads,
|
||||
'note': 'aeroway=helipad|heliport from OSM in a bbox 2 km wider than the scene; '
|
||||
'detail[].inside says whether the point is inside the scene square'})
|
||||
return dict(helipad_points=len(pads), named_heliports={k: (v[0]['x'], v[0]['y'], v[0]['inside']) if v else None
|
||||
for k, v in found.items()})
|
||||
|
||||
|
||||
def boston_rail(slug, F):
|
||||
h = F.half
|
||||
s, w, n, e = F.bbox(h + 100)
|
||||
bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}'
|
||||
q = (f'[out:json][timeout:180];(relation["route"~"^(subway|light_rail|train|tram)$"]({bb});'
|
||||
f'way["railway"~"^(subway|light_rail|rail|tram)$"]({bb}););out geom;')
|
||||
print(' rail')
|
||||
d = overpass(q, f'{slug}_rail')
|
||||
ways = {el['id']: el for el in d['elements'] if el['type'] == 'way'}
|
||||
rels = [el for el in d['elements'] if el['type'] == 'relation']
|
||||
lines, seen = [], set()
|
||||
rel_names = []
|
||||
for r in rels:
|
||||
t = r.get('tags', {})
|
||||
col = (t.get('colour') or t.get('color') or '').strip()
|
||||
if not re.match(r'^#?[0-9a-fA-F]{6}$', col):
|
||||
col = {'subway': '#888888', 'light_rail': '#00843D', 'tram': '#00843D', 'train': '#80276C'}[t['route']]
|
||||
if not col.startswith('#'):
|
||||
col = '#' + col
|
||||
col = col.upper()
|
||||
rel_names.append(f"{t.get('route')}:{t.get('name', t.get('ref', ''))}:{col}")
|
||||
for m in r.get('members', []):
|
||||
if m['type'] != 'way' or 'geometry' not in m or m.get('role') in ('platform', 'stop'):
|
||||
continue
|
||||
wt = ways.get(m['ref'], {}).get('tags', {})
|
||||
if wt.get('public_transport') or wt.get('railway') == 'platform':
|
||||
continue
|
||||
key = (m['ref'], col)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
pts = [F.xy(la, lo) for la, lo in ll_list(m['geometry'])]
|
||||
for pc in clip_polyline(dedupe(pts), -h, -h, h, h):
|
||||
pc = rdp(pc, 1.0)
|
||||
if len(pc) >= 2:
|
||||
lines.append([col] + flat(pc))
|
||||
covered = {k[0] for k in seen}
|
||||
extra = 0
|
||||
for wid, el in ways.items():
|
||||
t = el.get('tags', {})
|
||||
if wid in covered or t.get('service') or 'geometry' not in el:
|
||||
continue
|
||||
pts = [F.xy(la, lo) for la, lo in ll_list(el['geometry'])]
|
||||
for pc in clip_polyline(dedupe(pts), -h, -h, h, h):
|
||||
pc = rdp(pc, 1.0)
|
||||
if len(pc) >= 2:
|
||||
lines.append(['#7F7F7F'] + flat(pc))
|
||||
extra += 1
|
||||
write_json(os.path.join(OUT, slug, 'rail.json'),
|
||||
{'lines': lines, 'routes': sorted(set(rel_names)),
|
||||
'note': 'OSM route relations (subway/light_rail/train/tram) colour tags; #7F7F7F = railway way not in any route relation'})
|
||||
print(f' rail lines {len(lines)} from {len(rels)} route relations (+{extra} unassigned way pieces)')
|
||||
for nm in sorted(set(rel_names)):
|
||||
print(' ', nm)
|
||||
return dict(polylines=len(lines), route_relations=len(rels), unassigned_pieces=extra)
|
||||
|
||||
|
||||
def main():
|
||||
slugs = sys.argv[1:] or list(CITIES)
|
||||
report = {}
|
||||
for slug in slugs:
|
||||
c = CITIES[slug]
|
||||
F = Frame(**c)
|
||||
print(f'== {slug}')
|
||||
write_json(os.path.join(OUT, slug, 'meta.json'),
|
||||
{'slug': slug, 'name': c['name'], 'lat0': c['lat0'], 'lon0': c['lon0'], 'half': c['half'],
|
||||
'frame': 'ENU m',
|
||||
'projection': 'x=(lon-lon0)*cos(lat0)*111320, y=(lat-lat0)*110540',
|
||||
'source': 'OpenStreetMap contributors (ODbL) via Overpass; imagery Esri World Imagery'})
|
||||
r = {}
|
||||
r['buildings'] = buildings(slug, F)
|
||||
r['roads'] = roads(slug, F)
|
||||
try:
|
||||
r['water'] = water(slug, F)
|
||||
except Exception as ex: # noqa
|
||||
print(' WATER FAILED', ex)
|
||||
r['water'] = f'failed: {ex}'
|
||||
if slug == 'nyc':
|
||||
r['extra'] = nyc_extra(slug, F)
|
||||
if slug == 'boston':
|
||||
r['rail'] = boston_rail(slug, F)
|
||||
report[slug] = r
|
||||
print(json.dumps(report, indent=1))
|
||||
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cache', 'osm_report.json'), 'w') as f:
|
||||
json.dump(report, f, indent=1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Shared helpers for the city scene geodata fetchers (ENU frame, Overpass, geometry)."""
|
||||
import json, math, os, time, hashlib
|
||||
import requests
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, '..', '..'))
|
||||
OUT = os.path.join(ROOT, 'city')
|
||||
CACHE = os.path.join(HERE, 'cache')
|
||||
os.makedirs(CACHE, exist_ok=True)
|
||||
|
||||
CITIES = {
|
||||
'nyc': dict(name='New York — Midtown / Chelsea / Lower Manhattan', lat0=40.7400, lon0=-73.9900, half=3500),
|
||||
'boston': dict(name='Boston — Downtown / Back Bay / Cambridge / Seaport', lat0=42.3555, lon0=-71.0605, half=3200),
|
||||
}
|
||||
|
||||
OVERPASS = [
|
||||
'https://overpass-api.de/api/interpreter',
|
||||
'https://overpass.kumi.systems/api/interpreter',
|
||||
'https://overpass.private.coffee/api/interpreter',
|
||||
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
|
||||
]
|
||||
|
||||
|
||||
class Frame:
|
||||
def __init__(self, lat0, lon0, half, **_):
|
||||
self.lat0, self.lon0, self.half = lat0, lon0, half
|
||||
self.kx = math.cos(math.radians(lat0)) * 111320.0
|
||||
self.ky = 110540.0
|
||||
|
||||
def xy(self, lat, lon):
|
||||
return ((lon - self.lon0) * self.kx, (lat - self.lat0) * self.ky)
|
||||
|
||||
def ll(self, x, y):
|
||||
return (self.lat0 + y / self.ky, self.lon0 + x / self.kx)
|
||||
|
||||
def bbox(self, half=None):
|
||||
h = self.half if half is None else half
|
||||
s, w = self.ll(-h, -h)
|
||||
n, e = self.ll(h, h)
|
||||
return s, w, n, e
|
||||
|
||||
|
||||
def overpass(query, tag):
|
||||
"""Run an Overpass query, caching the raw JSON response under cache/osm_<tag>.json."""
|
||||
path = os.path.join(CACHE, f'osm_{tag}.json')
|
||||
if os.path.exists(path):
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
last = None
|
||||
for attempt in range(8):
|
||||
for url in OVERPASS:
|
||||
try:
|
||||
t = time.time()
|
||||
r = requests.post(url, data={'data': query}, timeout=400)
|
||||
if r.status_code != 200:
|
||||
last = f'{url} HTTP {r.status_code}'
|
||||
print(' overpass fail', last)
|
||||
continue
|
||||
d = r.json()
|
||||
if 'remark' in d and 'error' in d.get('remark', '').lower():
|
||||
last = f'{url} remark {d["remark"][:200]}'
|
||||
print(' overpass remark', last)
|
||||
continue
|
||||
print(f' overpass ok {url} {len(r.content)/1e6:.1f}MB {time.time()-t:.0f}s {len(d["elements"])} el')
|
||||
with open(path, 'w') as f:
|
||||
json.dump(d, f)
|
||||
return d
|
||||
except Exception as e: # noqa
|
||||
last = f'{url} {type(e).__name__}: {str(e)[:200]}'
|
||||
print(' overpass fail', last)
|
||||
time.sleep(15 + 15 * attempt)
|
||||
raise RuntimeError('all overpass endpoints failed: ' + str(last))
|
||||
|
||||
|
||||
# ---------------- geometry ----------------
|
||||
|
||||
def rdp(pts, eps):
|
||||
"""Douglas-Peucker on an open polyline (list of (x,y))."""
|
||||
n = len(pts)
|
||||
if n < 3:
|
||||
return list(pts)
|
||||
keep = [False] * n
|
||||
keep[0] = keep[-1] = True
|
||||
stack = [(0, n - 1)]
|
||||
while stack:
|
||||
a, b = stack.pop()
|
||||
ax, ay = pts[a]
|
||||
bx, by = pts[b]
|
||||
dx, dy = bx - ax, by - ay
|
||||
L = math.hypot(dx, dy)
|
||||
dmax, idx = -1.0, -1
|
||||
for i in range(a + 1, b):
|
||||
px, py = pts[i]
|
||||
if L < 1e-9:
|
||||
d = math.hypot(px - ax, py - ay)
|
||||
else:
|
||||
d = abs(dx * (ay - py) - dy * (ax - px)) / L
|
||||
if d > dmax:
|
||||
dmax, idx = d, i
|
||||
if dmax > eps and idx > 0:
|
||||
keep[idx] = True
|
||||
stack.append((a, idx))
|
||||
stack.append((idx, b))
|
||||
return [p for p, k in zip(pts, keep) if k]
|
||||
|
||||
|
||||
def rdp_ring(ring, eps):
|
||||
"""Douglas-Peucker for a closed ring given without the repeated closing vertex."""
|
||||
n = len(ring)
|
||||
if n < 4:
|
||||
return list(ring)
|
||||
# split at vertex 0 and the vertex farthest from it
|
||||
x0, y0 = ring[0]
|
||||
far = max(range(n), key=lambda i: (ring[i][0] - x0) ** 2 + (ring[i][1] - y0) ** 2)
|
||||
a = rdp(ring[0:far + 1], eps)
|
||||
b = rdp(ring[far:] + [ring[0]], eps)
|
||||
return a[:-1] + b[:-1]
|
||||
|
||||
|
||||
def area(ring):
|
||||
s = 0.0
|
||||
n = len(ring)
|
||||
for i in range(n):
|
||||
x1, y1 = ring[i]
|
||||
x2, y2 = ring[(i + 1) % n]
|
||||
s += x1 * y2 - x2 * y1
|
||||
return s / 2.0
|
||||
|
||||
|
||||
def centroid(ring):
|
||||
a = area(ring)
|
||||
if abs(a) < 1e-9:
|
||||
xs = [p[0] for p in ring]
|
||||
ys = [p[1] for p in ring]
|
||||
return sum(xs) / len(xs), sum(ys) / len(ys)
|
||||
cx = cy = 0.0
|
||||
n = len(ring)
|
||||
for i in range(n):
|
||||
x1, y1 = ring[i]
|
||||
x2, y2 = ring[(i + 1) % n]
|
||||
c = x1 * y2 - x2 * y1
|
||||
cx += (x1 + x2) * c
|
||||
cy += (y1 + y2) * c
|
||||
return cx / (6 * a), cy / (6 * a)
|
||||
|
||||
|
||||
def dedupe(pts):
|
||||
out = []
|
||||
for p in pts:
|
||||
if not out or p != out[-1]:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def open_ring(pts):
|
||||
pts = dedupe(pts)
|
||||
if len(pts) > 1 and pts[0] == pts[-1]:
|
||||
pts = pts[:-1]
|
||||
return pts
|
||||
|
||||
|
||||
def assemble_rings(ways):
|
||||
"""Join way coordinate lists (lists of (lat,lon) tuples) into closed rings by matching endpoints."""
|
||||
rings = []
|
||||
pool = [list(w) for w in ways if len(w) >= 2]
|
||||
while pool:
|
||||
cur = pool.pop()
|
||||
changed = True
|
||||
while cur[0] != cur[-1] and changed:
|
||||
changed = False
|
||||
for i, w in enumerate(pool):
|
||||
if w[0] == cur[-1]:
|
||||
cur = cur + w[1:]
|
||||
elif w[-1] == cur[-1]:
|
||||
cur = cur + w[::-1][1:]
|
||||
elif w[-1] == cur[0]:
|
||||
cur = w + cur[1:]
|
||||
elif w[0] == cur[0]:
|
||||
cur = w[::-1] + cur[1:]
|
||||
else:
|
||||
continue
|
||||
pool.pop(i)
|
||||
changed = True
|
||||
break
|
||||
if cur[0] == cur[-1] and len(cur) >= 4:
|
||||
rings.append(cur)
|
||||
return rings
|
||||
|
||||
|
||||
def clip_polyline(pts, xmin, ymin, xmax, ymax):
|
||||
"""Clip a polyline to a rectangle (Liang-Barsky per segment); returns list of pieces."""
|
||||
pieces, cur = [], []
|
||||
|
||||
def inside(p):
|
||||
return xmin <= p[0] <= xmax and ymin <= p[1] <= ymax
|
||||
|
||||
for i in range(len(pts) - 1):
|
||||
(x1, y1), (x2, y2) = pts[i], pts[i + 1]
|
||||
dx, dy = x2 - x1, y2 - y1
|
||||
t0, t1 = 0.0, 1.0
|
||||
ok = True
|
||||
for p, q in ((-dx, x1 - xmin), (dx, xmax - x1), (-dy, y1 - ymin), (dy, ymax - y1)):
|
||||
if p == 0:
|
||||
if q < 0:
|
||||
ok = False
|
||||
break
|
||||
else:
|
||||
t = q / p
|
||||
if p < 0:
|
||||
if t > t1:
|
||||
ok = False
|
||||
break
|
||||
t0 = max(t0, t)
|
||||
else:
|
||||
if t < t0:
|
||||
ok = False
|
||||
break
|
||||
t1 = min(t1, t)
|
||||
if not ok:
|
||||
if cur:
|
||||
pieces.append(cur)
|
||||
cur = []
|
||||
continue
|
||||
a = (x1 + t0 * dx, y1 + t0 * dy)
|
||||
b = (x1 + t1 * dx, y1 + t1 * dy)
|
||||
if not cur:
|
||||
cur = [a, b]
|
||||
else:
|
||||
if cur[-1] != a:
|
||||
pieces.append(cur)
|
||||
cur = [a, b]
|
||||
else:
|
||||
cur.append(b)
|
||||
if t1 < 1.0:
|
||||
pieces.append(cur)
|
||||
cur = []
|
||||
if cur:
|
||||
pieces.append(cur)
|
||||
return [dedupe(p) for p in pieces if len(dedupe(p)) >= 2]
|
||||
|
||||
|
||||
def clip_polygon(ring, xmin, ymin, xmax, ymax):
|
||||
"""Sutherland-Hodgman clip of a ring to a rectangle."""
|
||||
def clip(poly, inside, inter):
|
||||
out = []
|
||||
n = len(poly)
|
||||
for i in range(n):
|
||||
cur, prev = poly[i], poly[i - 1]
|
||||
if inside(cur):
|
||||
if not inside(prev):
|
||||
out.append(inter(prev, cur))
|
||||
out.append(cur)
|
||||
elif inside(prev):
|
||||
out.append(inter(prev, cur))
|
||||
return out
|
||||
|
||||
def ix(xc):
|
||||
return lambda p, q: (xc, p[1] + (q[1] - p[1]) * (xc - p[0]) / (q[0] - p[0]))
|
||||
|
||||
def iy(yc):
|
||||
return lambda p, q: (p[0] + (q[0] - p[0]) * (yc - p[1]) / (q[1] - p[1]), yc)
|
||||
|
||||
poly = list(ring)
|
||||
for ins, inter in ((lambda p: p[0] >= xmin, ix(xmin)), (lambda p: p[0] <= xmax, ix(xmax)),
|
||||
(lambda p: p[1] >= ymin, iy(ymin)), (lambda p: p[1] <= ymax, iy(ymax))):
|
||||
if not poly:
|
||||
break
|
||||
poly = clip(poly, ins, inter)
|
||||
return dedupe(poly)
|
||||
|
||||
|
||||
def r1(v):
|
||||
return round(v, 1)
|
||||
|
||||
|
||||
def flat(pts):
|
||||
out = []
|
||||
for x, y in pts:
|
||||
out.append(r1(x))
|
||||
out.append(r1(y))
|
||||
return out
|
||||
|
||||
|
||||
def write_json(path, obj):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, 'w') as f:
|
||||
json.dump(obj, f, separators=(',', ':'))
|
||||
print(f' wrote {os.path.relpath(path, ROOT)} {os.path.getsize(path)/1e6:.2f}MB')
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Build real-geodata scene packs. usage: python3 build_scene.py lax|bragg|everest [steps...]
|
||||
steps: osm ground elev check (default all)"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import common as C # noqa
|
||||
|
||||
SCENES = {
|
||||
"lax": dict(name="Los Angeles International Airport", lat0=33.9425, lon0=-118.4081, half=3500, z=17),
|
||||
"bragg": dict(name="Fort Bragg, North Carolina", lat0=35.1580, lon0=-79.0090, half=6000, z=16),
|
||||
"everest": dict(name="Mount Everest / Khumbu", lat0=27.9881, lon0=86.9250, half=15000, z=15),
|
||||
}
|
||||
|
||||
NT = {"lax": 4, "bragg": 6}
|
||||
AERO_Q = '[out:json][timeout:600];nwr["aeroway"~"^(runway|taxiway|apron|terminal|gate|parking_position)$"]({bb});out geom;'
|
||||
BLDG_Q = '[out:json][timeout:300];(way["building"]({bb});relation["building"]({bb}););out geom;'
|
||||
ROAD_Q = ('[out:json][timeout:300];way["highway"~"^(motorway|trunk|primary|secondary|tertiary|'
|
||||
'residential|unclassified|living_street|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link|service)$"]({bb});out geom;')
|
||||
MIL_Q = '[out:json][timeout:600];(way["landuse"="military"]({bb});relation["landuse"="military"]({bb});way["military"]({bb});relation["military"]({bb}););out geom;'
|
||||
PEAK_Q = '[out:json][timeout:300];node["natural"="peak"]({bb});out;'
|
||||
|
||||
|
||||
def step_osm(f, slug):
|
||||
bb = f.bbox_str()
|
||||
if slug in ("lax", "bragg"):
|
||||
aero = C.build_aeroway(f, C.overpass(AERO_Q.format(bb=bb), f"{slug}_aero"))
|
||||
if slug == "lax":
|
||||
C.wjson(os.path.join(f.out, "aeroway.json"), aero)
|
||||
else:
|
||||
mil = C.overpass(MIL_Q.format(bb=bb), f"{slug}_mil")
|
||||
polys = []
|
||||
for el in mil["elements"]:
|
||||
tags = el.get("tags", {})
|
||||
for ring in C.outer_rings(el, f):
|
||||
name = tags.get("name") or (f"[{tags['military']}]" if "military" in tags else "")
|
||||
polys.append([name] + C.flat(C.dp_ring(ring, 2.0)))
|
||||
names = sorted({p[0] for p in polys if p[0]})
|
||||
print(f" military polygons: {len(polys)}; names: {names[:40]}")
|
||||
out = {"military": polys, "runways": aero["runways"], "taxiways": aero["taxiways"],
|
||||
"aprons": aero["aprons"], "apronNames": aero["apronNames"], "terminals": aero["terminals"],
|
||||
"gates": aero["gates"], "parking": aero["parking"]}
|
||||
C.wjson(os.path.join(f.out, "military.json"), out)
|
||||
C.wjson(os.path.join(f.out, "buildings.json"), C.build_buildings(f, C.overpass_tiled(f, BLDG_Q, f"{slug}_bldg", NT[slug])))
|
||||
C.wjson(os.path.join(f.out, "roads.json"), C.build_roads(f, C.overpass_tiled(f, ROAD_Q, f"{slug}_roads", NT[slug] // 2)))
|
||||
else:
|
||||
d = C.overpass(PEAK_Q.format(bb=bb), f"{slug}_peaks")
|
||||
peaks = []
|
||||
skipped = 0
|
||||
for el in d["elements"]:
|
||||
t = el.get("tags", {})
|
||||
name = t.get("name:en") or t.get("name")
|
||||
ele = t.get("ele")
|
||||
if not name or not ele:
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
s = ele.lower().replace(",", ".").strip()
|
||||
v = float(s.replace("ft", "").replace("m", "").strip().split()[0].split(";")[0])
|
||||
if "ft" in s:
|
||||
v *= 0.3048
|
||||
except ValueError:
|
||||
skipped += 1
|
||||
continue
|
||||
x, y = f.xy(el["lon"], el["lat"])
|
||||
peaks.append([C.r1(x), C.r1(y), C.r1(v), name])
|
||||
peaks.sort(key=lambda p: -p[2])
|
||||
print(f" peaks with name+ele: {len(peaks)} (skipped {skipped} lacking name/ele); top: {peaks[:6]}")
|
||||
C.wjson(os.path.join(f.out, "peaks.json"), peaks)
|
||||
|
||||
|
||||
def step_elev(f):
|
||||
z = 12
|
||||
url = "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
||||
mos, ox, oy, nf = C.mosaic(f, z, url, "terrarium", "png", "RGB", pad_m=1500)
|
||||
if nf:
|
||||
print(f" WARNING: {nf} terrarium tiles missing")
|
||||
m = mos.astype(np.float32)
|
||||
h = m[..., 0] * 256 + m[..., 1] + m[..., 2] / 256 - 32768
|
||||
N = 1024
|
||||
e = C.reproject(f, h[..., None], ox, oy, z, N)[..., 0].astype("<f4")
|
||||
e.tofile(os.path.join(f.out, "elev.f32"))
|
||||
meta = {"size": N, "min": C.r1(e.min()), "max": C.r1(e.max()), "half": f.half,
|
||||
"source": "AWS Terrain Tiles terrarium z12", "rowOrder": "row0=north", "dtype": "float32le"}
|
||||
C.wjson(os.path.join(f.out, "elev_meta.json"), meta)
|
||||
print(f" elev min {e.min():.1f} max {e.max():.1f}")
|
||||
# hillshade
|
||||
cell = 2 * f.half / N
|
||||
gy, gx = np.gradient(e.astype(np.float64), cell) # gy: d/drow (southward)
|
||||
dzdx, dzdy = gx, -gy
|
||||
az, alt = np.radians(315), np.radians(45)
|
||||
slope = np.arctan(np.hypot(dzdx, dzdy))
|
||||
aspect = np.arctan2(-dzdx, -dzdy) # direction of steepest descent... simple form
|
||||
lx, ly, lz = np.sin(az) * np.cos(alt), np.cos(az) * np.cos(alt), np.sin(alt)
|
||||
nx, ny, nz = -dzdx, -dzdy, np.ones_like(dzdx)
|
||||
nn = np.sqrt(nx * nx + ny * ny + 1)
|
||||
shade = np.clip((nx * lx + ny * ly + nz * lz) / nn, 0, 1)
|
||||
t = (e - e.min()) / max(1, e.max() - e.min())
|
||||
rgb = np.stack([0.35 + 0.65 * t, 0.45 + 0.55 * t, 0.55 + 0.45 * t], -1) * shade[..., None]
|
||||
Image.fromarray((np.clip(rgb, 0, 1) * 255).astype(np.uint8)).save(os.path.join(f.out, "elev_preview.png"))
|
||||
print(" wrote elev_preview.png")
|
||||
|
||||
|
||||
def step_check(f, slug):
|
||||
N = 2048
|
||||
im = C.check_canvas(f)
|
||||
dr = ImageDraw.Draw(im, "RGBA")
|
||||
P = lambda a: C.to_px(f, a, N) # noqa
|
||||
if slug in ("lax", "bragg"):
|
||||
roads = json.load(open(os.path.join(f.out, "roads.json")))["roads"]
|
||||
col = {0: (255, 60, 60, 230), 1: (255, 160, 40, 220), 2: (255, 230, 60, 200), 3: (200, 255, 120, 180),
|
||||
4: (160, 200, 255, 150), 5: (180, 180, 180, 120)}
|
||||
for r in roads:
|
||||
dr.line(P(r[3:]), fill=col[r[0]], width=2 if r[0] < 3 else 1)
|
||||
bl = json.load(open(os.path.join(f.out, "buildings.json")))["b"]
|
||||
for b in bl:
|
||||
pts = P(b[2:])
|
||||
if len(pts) >= 3:
|
||||
dr.polygon(pts, outline=(0, 255, 255, 200))
|
||||
aero = json.load(open(os.path.join(f.out, "aeroway.json" if slug == "lax" else "military.json")))
|
||||
if slug == "bragg":
|
||||
for m in aero["military"]:
|
||||
dr.polygon(P(m[1:]), outline=(255, 0, 255, 255))
|
||||
for a in aero["aprons"]:
|
||||
dr.polygon(P(a), outline=(255, 255, 255, 200))
|
||||
for t in aero["taxiways"]:
|
||||
dr.line(P(t), fill=(255, 255, 0, 230), width=1)
|
||||
for rw in aero["runways"]:
|
||||
w = max(2, int(rw[0] / (2 * f.half) * N))
|
||||
dr.line(P(rw[2:6]), fill=(255, 40, 40, 110), width=w)
|
||||
dr.line(P(rw[2:6]), fill=(255, 255, 255, 255), width=1)
|
||||
for t in aero["terminals"]:
|
||||
dr.polygon(P(t[1:]), outline=(255, 120, 0, 255))
|
||||
for g in aero["gates"]:
|
||||
(x, y), = P(g[:2])
|
||||
dr.ellipse([x - 2, y - 2, x + 2, y + 2], fill=(0, 255, 0, 255))
|
||||
else:
|
||||
peaks = json.load(open(os.path.join(f.out, "peaks.json")))
|
||||
for x0, y0, ele, name in peaks:
|
||||
(x, y), = P([x0, y0])
|
||||
dr.ellipse([x - 4, y - 4, x + 4, y + 4], outline=(255, 60, 60, 255), width=2)
|
||||
if ele > 6000:
|
||||
dr.text((x + 6, y - 6), f"{name} {ele:.0f}", fill=(255, 255, 255, 255))
|
||||
# contour-ish overlay from DEM to check imagery/DEM alignment
|
||||
e = np.fromfile(os.path.join(f.out, "elev.f32"), "<f4").reshape(1024, 1024)
|
||||
e2 = np.asarray(Image.fromarray(e).resize((N, N), Image.BILINEAR))
|
||||
band = (np.floor(e2 / 500) % 2).astype(np.int8)
|
||||
edge = (np.abs(np.diff(band, axis=0, prepend=band[:1])) + np.abs(np.diff(band, axis=1, prepend=band[:, :1]))) > 0
|
||||
arr = np.asarray(im).copy()
|
||||
arr[edge] = (0.4 * arr[edge] + 0.6 * np.array([0, 255, 255])).astype(np.uint8)
|
||||
im = Image.fromarray(arr)
|
||||
dr = ImageDraw.Draw(im, "RGBA")
|
||||
for x0, y0, ele, name in peaks:
|
||||
(x, y), = P([x0, y0])
|
||||
dr.ellipse([x - 4, y - 4, x + 4, y + 4], outline=(255, 60, 60, 255), width=2)
|
||||
if ele > 6500:
|
||||
dr.text((x + 6, y - 6), f"{name} {ele:.0f}", fill=(255, 255, 255, 255))
|
||||
p = os.path.join(f.out, "check.png")
|
||||
im.save(p)
|
||||
print(f" wrote {p}")
|
||||
|
||||
|
||||
def main():
|
||||
slug = sys.argv[1]
|
||||
steps = sys.argv[2:] or ["osm", "ground", "elev", "check"]
|
||||
s = SCENES[slug]
|
||||
f = C.Frame(slug, s["name"], s["lat0"], s["lon0"], s["half"])
|
||||
f.write_meta()
|
||||
print(f"== {slug} bbox {f.bbox_str()}")
|
||||
if "osm" in steps:
|
||||
step_osm(f, slug)
|
||||
if "ground" in steps:
|
||||
C.build_ground(f, s["z"])
|
||||
if "ground_s2" in steps:
|
||||
# alternative uniform real imagery (EOX Sentinel-2 cloudless 2020) — Esri is a patchwork here
|
||||
C.build_ground(f, 14, C.EOX_S2, "eox_s2", "ground_s2")
|
||||
if "elev" in steps and slug == "everest":
|
||||
step_elev(f)
|
||||
if "check" in steps:
|
||||
step_check(f, slug)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,571 @@
|
||||
"""Shared helpers for real-geodata scene builds (city2).
|
||||
|
||||
Local frame: ENU metres relative to (lat0, lon0):
|
||||
x = (lon - lon0) * cos(lat0) * 111320
|
||||
y = (lat - lat0) * 110540
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CACHE = os.path.join(HERE, "cache")
|
||||
CITY = os.path.abspath(os.path.join(HERE, "..", "..", "city"))
|
||||
os.makedirs(CACHE, exist_ok=True)
|
||||
|
||||
OVERPASS = [
|
||||
"https://overpass.kumi.systems/api/interpreter",
|
||||
"https://overpass.private.coffee/api/interpreter",
|
||||
"https://maps.mail.ru/osm/tools/overpass/api/interpreter",
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
]
|
||||
UA = {"User-Agent": "gods-eye-view-intro/1.0 (cinematic geodata build)"}
|
||||
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
class Frame:
|
||||
def __init__(self, slug, name, lat0, lon0, half):
|
||||
self.slug, self.name, self.lat0, self.lon0, self.half = slug, name, lat0, lon0, half
|
||||
self.kx = math.cos(math.radians(lat0)) * 111320.0
|
||||
self.ky = 110540.0
|
||||
self.out = os.path.join(CITY, slug)
|
||||
os.makedirs(self.out, exist_ok=True)
|
||||
|
||||
def xy(self, lon, lat):
|
||||
return (lon - self.lon0) * self.kx, (lat - self.lat0) * self.ky
|
||||
|
||||
def ll(self, x, y):
|
||||
return self.lon0 + x / self.kx, self.lat0 + y / self.ky
|
||||
|
||||
def bbox(self, pad=0.0):
|
||||
"""(south, west, north, east)"""
|
||||
h = self.half + pad
|
||||
return (self.lat0 - h / self.ky, self.lon0 - h / self.kx,
|
||||
self.lat0 + h / self.ky, self.lon0 + h / self.kx)
|
||||
|
||||
def bbox_str(self, pad=0.0):
|
||||
s, w, n, e = self.bbox(pad)
|
||||
return f"{s:.6f},{w:.6f},{n:.6f},{e:.6f}"
|
||||
|
||||
def write_meta(self):
|
||||
meta = {"slug": self.slug, "name": self.name, "lat0": self.lat0, "lon0": self.lon0,
|
||||
"half": self.half, "frame": "ENU m"}
|
||||
wjson(os.path.join(self.out, "meta.json"), meta)
|
||||
|
||||
def geom_xy(self, geom):
|
||||
"""list of {lat,lon} -> Nx2 float array in local metres"""
|
||||
a = np.array([[g["lon"], g["lat"]] for g in geom if g is not None], dtype=np.float64)
|
||||
if len(a) == 0:
|
||||
return np.zeros((0, 2))
|
||||
return np.stack([(a[:, 0] - self.lon0) * self.kx, (a[:, 1] - self.lat0) * self.ky], 1)
|
||||
|
||||
|
||||
def wjson(path, obj):
|
||||
with open(path, "w") as f:
|
||||
json.dump(obj, f, separators=(",", ":"))
|
||||
print(f" wrote {path} ({os.path.getsize(path)/1e6:.2f} MB)")
|
||||
|
||||
|
||||
def r1(v):
|
||||
return round(float(v), 1)
|
||||
|
||||
|
||||
def flat(arr):
|
||||
return [r1(v) for v in np.asarray(arr).reshape(-1)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Overpass
|
||||
def overpass(query, tag):
|
||||
key = hashlib.sha1(query.encode()).hexdigest()[:12]
|
||||
path = os.path.join(CACHE, f"overpass_{tag}_{key}.json")
|
||||
if os.path.exists(path):
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
last = None
|
||||
for attempt in range(10):
|
||||
for url in OVERPASS:
|
||||
try:
|
||||
print(f" overpass {tag} -> {url}", flush=True)
|
||||
t = time.time()
|
||||
r = requests.get(url, params={"data": query}, headers=UA, timeout=900)
|
||||
if r.status_code != 200:
|
||||
last = f"{url}: HTTP {r.status_code} {r.text[:200]}"
|
||||
print(" ", last)
|
||||
continue
|
||||
d = r.json()
|
||||
rem = d.get("remark", "")
|
||||
if rem and ("runtime error" in rem or "timed out" in rem or "out of memory" in rem):
|
||||
last = f"{url}: remark {rem[:200]}"
|
||||
print(" ", last)
|
||||
continue
|
||||
print(f" {len(d.get('elements', []))} elements in {time.time()-t:.0f}s")
|
||||
with open(path, "w") as f:
|
||||
json.dump(d, f)
|
||||
return d
|
||||
except Exception as e: # noqa
|
||||
last = f"{url}: {e}"
|
||||
print(" ", last)
|
||||
time.sleep(30)
|
||||
raise RuntimeError(f"overpass failed for {tag}: {last}")
|
||||
|
||||
|
||||
def overpass_tiled(frame, qfmt, tag, n):
|
||||
"""Run a query template ({bb} placeholder) over an n x n split of the frame bbox; dedupe elements."""
|
||||
s, w, nn, e = frame.bbox()
|
||||
seen, els = set(), []
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
bb = (f"{s + (nn - s) * i / n:.6f},{w + (e - w) * j / n:.6f},"
|
||||
f"{s + (nn - s) * (i + 1) / n:.6f},{w + (e - w) * (j + 1) / n:.6f}")
|
||||
d = overpass(qfmt.format(bb=bb), f"{tag}_{i}{j}")
|
||||
for el in d["elements"]:
|
||||
k = (el["type"], el["id"])
|
||||
if k not in seen:
|
||||
seen.add(k)
|
||||
els.append(el)
|
||||
print(f" {tag}: {len(els)} unique elements over {n}x{n} tiles")
|
||||
return {"elements": els}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- geometry
|
||||
def assemble_rings(ways):
|
||||
"""ways: list of Nx2 arrays (open or closed). Returns list of closed rings."""
|
||||
rings = []
|
||||
pending = [w for w in ways if len(w) >= 2]
|
||||
while pending:
|
||||
cur = pending.pop(0)
|
||||
changed = True
|
||||
while not np.allclose(cur[0], cur[-1], atol=1e-3) and changed:
|
||||
changed = False
|
||||
for i, w in enumerate(pending):
|
||||
if np.allclose(cur[-1], w[0], atol=1e-3):
|
||||
cur = np.vstack([cur, w[1:]])
|
||||
elif np.allclose(cur[-1], w[-1], atol=1e-3):
|
||||
cur = np.vstack([cur, w[::-1][1:]])
|
||||
elif np.allclose(cur[0], w[-1], atol=1e-3):
|
||||
cur = np.vstack([w, cur[1:]])
|
||||
elif np.allclose(cur[0], w[0], atol=1e-3):
|
||||
cur = np.vstack([w[::-1], cur[1:]])
|
||||
else:
|
||||
continue
|
||||
pending.pop(i)
|
||||
changed = True
|
||||
break
|
||||
if np.allclose(cur[0], cur[-1], atol=1e-3) and len(cur) >= 4:
|
||||
rings.append(cur)
|
||||
return rings
|
||||
|
||||
|
||||
def outer_rings(el, frame):
|
||||
"""Outer rings (local metres, closed) for a way or multipolygon relation with out geom."""
|
||||
if el["type"] == "way":
|
||||
g = el.get("geometry")
|
||||
if not g:
|
||||
return []
|
||||
a = frame.geom_xy(g)
|
||||
if len(a) >= 4 and np.allclose(a[0], a[-1], atol=1e-3):
|
||||
return [a]
|
||||
return []
|
||||
if el["type"] == "relation":
|
||||
ways = [frame.geom_xy(m["geometry"]) for m in el.get("members", [])
|
||||
if m.get("type") == "way" and m.get("role", "outer") in ("outer", "") and m.get("geometry")]
|
||||
return assemble_rings(ways)
|
||||
return []
|
||||
|
||||
|
||||
def dp(pts, eps):
|
||||
"""Douglas-Peucker on Nx2 array (iterative)."""
|
||||
n = len(pts)
|
||||
if n < 3:
|
||||
return pts
|
||||
keep = np.zeros(n, bool)
|
||||
keep[0] = keep[-1] = True
|
||||
stack = [(0, n - 1)]
|
||||
while stack:
|
||||
a, b = stack.pop()
|
||||
if b <= a + 1:
|
||||
continue
|
||||
p, q = pts[a], pts[b]
|
||||
seg = pts[a + 1:b]
|
||||
d = q - p
|
||||
L = math.hypot(d[0], d[1])
|
||||
if L < 1e-9:
|
||||
dist = np.hypot(seg[:, 0] - p[0], seg[:, 1] - p[1])
|
||||
else:
|
||||
dist = np.abs(d[0] * (seg[:, 1] - p[1]) - d[1] * (seg[:, 0] - p[0])) / L
|
||||
i = int(np.argmax(dist))
|
||||
if dist[i] > eps:
|
||||
m = a + 1 + i
|
||||
keep[m] = True
|
||||
stack.append((a, m))
|
||||
stack.append((m, b))
|
||||
return pts[keep]
|
||||
|
||||
|
||||
def dp_ring(ring, eps):
|
||||
"""DP for a closed ring: split at the vertex farthest from ring[0]."""
|
||||
r = ring[:-1] if np.allclose(ring[0], ring[-1]) else ring
|
||||
if len(r) < 4:
|
||||
return r
|
||||
far = int(np.argmax(np.hypot(*(r - r[0]).T)))
|
||||
a = dp(np.vstack([r[:far + 1]]), eps)
|
||||
b = dp(np.vstack([r[far:], r[:1]]), eps)
|
||||
return np.vstack([a[:-1], b[:-1]]) # open ring (no repeated closing vertex)
|
||||
|
||||
|
||||
def area(ring):
|
||||
x, y = ring[:, 0], ring[:, 1]
|
||||
return 0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1)))
|
||||
|
||||
|
||||
def poly_len(a):
|
||||
return float(np.sum(np.hypot(*np.diff(a, axis=0).T))) if len(a) > 1 else 0.0
|
||||
|
||||
|
||||
def clip_polyline(a, lim):
|
||||
"""Split a polyline into runs inside the square |x|,|y|<=lim, cutting segments at the edge."""
|
||||
out, cur = [], []
|
||||
inside = (np.abs(a[:, 0]) <= lim) & (np.abs(a[:, 1]) <= lim)
|
||||
|
||||
def cut(p, q):
|
||||
# point on segment p->q at the square boundary (p inside, q outside)
|
||||
t = 1.0
|
||||
d = q - p
|
||||
for k in range(2):
|
||||
if d[k] > 0 and q[k] > lim:
|
||||
t = min(t, (lim - p[k]) / d[k])
|
||||
elif d[k] < 0 and q[k] < -lim:
|
||||
t = min(t, (-lim - p[k]) / d[k])
|
||||
return p + d * t
|
||||
|
||||
for i in range(len(a)):
|
||||
if inside[i]:
|
||||
if not cur and i > 0:
|
||||
cur.append(cut(a[i], a[i - 1]))
|
||||
cur.append(a[i])
|
||||
else:
|
||||
if cur:
|
||||
cur.append(cut(a[i - 1], a[i]))
|
||||
out.append(np.array(cur))
|
||||
cur = []
|
||||
if cur:
|
||||
out.append(np.array(cur))
|
||||
return [c for c in out if len(c) >= 2]
|
||||
|
||||
|
||||
def tag_height(tags, default=10.0):
|
||||
def num(s):
|
||||
try:
|
||||
s = str(s).strip().lower().replace(",", ".")
|
||||
ft = s.endswith("ft") or s.endswith("'")
|
||||
s = s.replace("m", "").replace("ft", "").replace("'", "").strip().split(";")[0].split()[0]
|
||||
v = float(s)
|
||||
return v * 0.3048 if ft else v
|
||||
except Exception:
|
||||
return None
|
||||
h = num(tags["height"]) if "height" in tags else None
|
||||
if h is None and "building:levels" in tags:
|
||||
lv = num(tags["building:levels"])
|
||||
if lv is not None:
|
||||
h = lv * 3.2
|
||||
if h is None or h <= 0:
|
||||
h = default
|
||||
mh = num(tags["min_height"]) if "min_height" in tags else None
|
||||
if mh is None and "building:min_level" in tags:
|
||||
lv = num(tags["building:min_level"])
|
||||
mh = lv * 3.2 if lv is not None else None
|
||||
mh = mh if (mh is not None and 0 <= mh < h) else 0.0
|
||||
return h, mh
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- buildings / roads
|
||||
def build_buildings(frame, data, min_area=6.0):
|
||||
b = []
|
||||
dropped = 0
|
||||
lim = frame.half
|
||||
for el in data["elements"]:
|
||||
if el["type"] not in ("way", "relation"):
|
||||
continue
|
||||
tags = el.get("tags", {})
|
||||
if "building" not in tags:
|
||||
continue
|
||||
h, mh = tag_height(tags)
|
||||
for ring in outer_rings(el, frame):
|
||||
c = ring[:-1].mean(0)
|
||||
if abs(c[0]) > lim or abs(c[1]) > lim:
|
||||
continue
|
||||
s = dp_ring(ring, 0.5)
|
||||
if len(s) < 3 or area(s) < min_area:
|
||||
dropped += 1
|
||||
continue
|
||||
b.append([r1(h), r1(mh)] + flat(s))
|
||||
print(f" buildings: {len(b)} kept, {dropped} tiny dropped")
|
||||
return {"count": len(b), "b": b}
|
||||
|
||||
|
||||
ROAD_CLASS = {
|
||||
"motorway": 0, "trunk": 0,
|
||||
"primary": 1, "secondary": 2, "tertiary": 3,
|
||||
"residential": 4, "unclassified": 4, "living_street": 4,
|
||||
"motorway_link": 5, "trunk_link": 5, "primary_link": 5, "secondary_link": 5,
|
||||
"tertiary_link": 5, "service": 5,
|
||||
}
|
||||
DEFAULT_LANES = {0: 3, 1: 2, 2: 2, 3: 2, 4: 1, 5: 1}
|
||||
|
||||
|
||||
def build_roads(frame, data):
|
||||
roads = []
|
||||
counts = {}
|
||||
lim = frame.half + 50
|
||||
for el in data["elements"]:
|
||||
if el["type"] != "way" or not el.get("geometry"):
|
||||
continue
|
||||
tags = el.get("tags", {})
|
||||
hw = tags.get("highway")
|
||||
if hw not in ROAD_CLASS:
|
||||
continue
|
||||
cls = ROAD_CLASS[hw]
|
||||
a = frame.geom_xy(el["geometry"])
|
||||
if hw == "service" and poly_len(a) <= 80:
|
||||
continue
|
||||
ow = tags.get("oneway", "")
|
||||
oneway = 0
|
||||
if ow in ("yes", "1", "true") or (hw in ("motorway",) and ow != "no") or tags.get("junction") in ("roundabout", "circular"):
|
||||
oneway = 1
|
||||
elif ow == "-1":
|
||||
oneway = 1
|
||||
a = a[::-1]
|
||||
try:
|
||||
lanes = int(str(tags.get("lanes", "")).split(";")[0])
|
||||
except ValueError:
|
||||
lanes = DEFAULT_LANES[cls] * (1 if oneway else 2) if cls < 4 else (1 if oneway else 2)
|
||||
for part in clip_polyline(a, lim):
|
||||
if poly_len(part) < 2:
|
||||
continue
|
||||
roads.append([cls, oneway, lanes] + flat(part))
|
||||
counts[cls] = counts.get(cls, 0) + 1
|
||||
print(f" roads: {len(roads)} polylines, by class {dict(sorted(counts.items()))}")
|
||||
return {"roads": roads}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- aeroway
|
||||
def build_aeroway(frame, data):
|
||||
lim = frame.half + 200
|
||||
runways, taxiways, aprons, terminals, gates, parking = [], [], [], [], [], []
|
||||
apron_names = []
|
||||
for el in data["elements"]:
|
||||
tags = el.get("tags", {})
|
||||
aw = tags.get("aeroway")
|
||||
if el["type"] == "node":
|
||||
x, y = frame.xy(el["lon"], el["lat"])
|
||||
if abs(x) > lim or abs(y) > lim:
|
||||
continue
|
||||
if aw == "gate":
|
||||
gates.append([r1(x), r1(y), tags.get("ref", tags.get("name", ""))])
|
||||
elif aw == "parking_position":
|
||||
parking.append([r1(x), r1(y), tags.get("ref", tags.get("name", ""))])
|
||||
continue
|
||||
if aw == "runway" and el["type"] == "way" and el.get("geometry"):
|
||||
a = frame.geom_xy(el["geometry"])
|
||||
closed = len(a) > 3 and np.allclose(a[0], a[-1], atol=1e-3)
|
||||
try:
|
||||
w = float(str(tags.get("width", "")).replace("m", "").strip())
|
||||
except ValueError:
|
||||
w = None
|
||||
if closed or tags.get("area") == "yes":
|
||||
# area runway: use its long axis (PCA) as centreline, width = short extent
|
||||
c = a.mean(0)
|
||||
u, s, vt = np.linalg.svd(a - c, full_matrices=False)
|
||||
ax = vt[0]
|
||||
t = (a - c) @ ax
|
||||
n = (a - c) @ vt[1]
|
||||
p1, p2 = c + ax * t.min(), c + ax * t.max()
|
||||
w = w or float(n.max() - n.min())
|
||||
else:
|
||||
p1, p2 = a[0], a[-1]
|
||||
runways.append([r1(w or 45.0), tags.get("ref", ""), r1(p1[0]), r1(p1[1]), r1(p2[0]), r1(p2[1])])
|
||||
elif aw == "taxiway" and el["type"] == "way" and el.get("geometry"):
|
||||
a = frame.geom_xy(el["geometry"])
|
||||
if tags.get("area") == "yes":
|
||||
continue
|
||||
for part in clip_polyline(a, lim):
|
||||
taxiways.append(flat(part))
|
||||
elif aw == "apron":
|
||||
for ring in outer_rings(el, frame):
|
||||
c = ring.mean(0)
|
||||
if abs(c[0]) > lim or abs(c[1]) > lim:
|
||||
continue
|
||||
aprons.append(flat(dp_ring(ring, 0.5)))
|
||||
apron_names.append(tags.get("name", ""))
|
||||
elif aw == "terminal":
|
||||
h, _ = tag_height(tags)
|
||||
for ring in outer_rings(el, frame):
|
||||
c = ring.mean(0)
|
||||
if abs(c[0]) > lim or abs(c[1]) > lim:
|
||||
continue
|
||||
terminals.append([r1(h)] + flat(dp_ring(ring, 0.5)))
|
||||
# OSM often splits one runway into several ways (displaced thresholds, blast pads):
|
||||
# merge segments sharing a ref into one centreline spanning their extreme endpoints.
|
||||
merged, groups = [], {}
|
||||
for rw in runways:
|
||||
if rw[1]:
|
||||
groups.setdefault(rw[1], []).append(rw)
|
||||
else:
|
||||
merged.append(rw)
|
||||
for ref, segs in groups.items():
|
||||
pts = np.array([[s[2], s[3]] for s in segs] + [[s[4], s[5]] for s in segs])
|
||||
main = max(segs, key=lambda s: math.hypot(s[4] - s[2], s[5] - s[3]))
|
||||
ax = np.array([main[4] - main[2], main[5] - main[3]])
|
||||
ax /= np.linalg.norm(ax)
|
||||
t = (pts - pts[0]) @ ax
|
||||
p1, p2 = pts[int(np.argmin(t))], pts[int(np.argmax(t))]
|
||||
merged.append([main[0], ref, r1(p1[0]), r1(p1[1]), r1(p2[0]), r1(p2[1])])
|
||||
if len(merged) != len(runways):
|
||||
print(f" runways: merged {len(runways)} OSM segments into {len(merged)}")
|
||||
runways = merged
|
||||
out = {"runways": runways, "taxiways": taxiways, "aprons": aprons, "apronNames": apron_names,
|
||||
"terminals": terminals, "gates": gates, "parking": parking}
|
||||
print(" aeroway: " + ", ".join(f"{k}={len(v)}" for k, v in out.items()))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- tiles / imagery
|
||||
def merc_px(lon, lat, z, ts=256):
|
||||
n = 2 ** z
|
||||
x = (lon + 180.0) / 360.0 * n * ts
|
||||
lat_r = np.radians(lat)
|
||||
y = (1.0 - np.log(np.tan(lat_r) + 1.0 / np.cos(lat_r)) / math.pi) / 2.0 * n * ts
|
||||
return x, y
|
||||
|
||||
|
||||
def fetch_tiles(url_fmt, tiles, subdir, ext, workers=8):
|
||||
d = os.path.join(CACHE, subdir)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
sess = requests.Session()
|
||||
sess.headers.update(UA)
|
||||
fails = []
|
||||
|
||||
def get(t):
|
||||
z, x, y = t
|
||||
p = os.path.join(d, f"{z}_{x}_{y}.{ext}")
|
||||
if os.path.exists(p) and os.path.getsize(p) > 0:
|
||||
return p
|
||||
for i in range(4):
|
||||
try:
|
||||
r = sess.get(url_fmt.format(z=z, x=x, y=y), timeout=60)
|
||||
if r.status_code == 200 and len(r.content) > 100:
|
||||
with open(p + ".tmp", "wb") as f:
|
||||
f.write(r.content)
|
||||
os.replace(p + ".tmp", p)
|
||||
return p
|
||||
if r.status_code == 404:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.5 * (i + 1))
|
||||
fails.append(t)
|
||||
return None
|
||||
|
||||
with ThreadPoolExecutor(workers) as ex:
|
||||
res = list(ex.map(get, tiles))
|
||||
return dict(zip(tiles, res)), fails
|
||||
|
||||
|
||||
def mosaic(frame, z, url_fmt, subdir, ext, mode, pad_m=300.0):
|
||||
s, w, n, e = frame.bbox(pad_m)
|
||||
x0, y1 = merc_px(w, s, z)
|
||||
x1, y0 = merc_px(e, n, z)
|
||||
tx0, tx1 = int(x0 // 256), int(x1 // 256)
|
||||
ty0, ty1 = int(y0 // 256), int(y1 // 256)
|
||||
tiles = [(z, x, y) for y in range(ty0, ty1 + 1) for x in range(tx0, tx1 + 1)]
|
||||
print(f" {subdir} z{z}: {tx1-tx0+1}x{ty1-ty0+1} = {len(tiles)} tiles", flush=True)
|
||||
paths, fails = fetch_tiles(url_fmt, tiles, subdir, ext)
|
||||
if fails:
|
||||
print(f" WARNING: {len(fails)} tiles failed: {fails[:10]}")
|
||||
W, H = (tx1 - tx0 + 1) * 256, (ty1 - ty0 + 1) * 256
|
||||
nb = 3 if mode == "RGB" else 3
|
||||
mos = np.zeros((H, W, nb), np.uint8)
|
||||
for (zz, x, y), p in paths.items():
|
||||
if p is None:
|
||||
continue
|
||||
im = Image.open(p).convert("RGB")
|
||||
if im.size != (256, 256):
|
||||
im = im.resize((256, 256))
|
||||
mos[(y - ty0) * 256:(y - ty0 + 1) * 256, (x - tx0) * 256:(x - tx0 + 1) * 256] = np.asarray(im)
|
||||
return mos, tx0 * 256, ty0 * 256, len(fails)
|
||||
|
||||
|
||||
def bilinear(img, px, py):
|
||||
"""img HxWxC (float or uint8), px/py float arrays in pixel coords (pixel centres at i+0.5)."""
|
||||
H, W = img.shape[:2]
|
||||
fx = np.clip(px - 0.5, 0, W - 1.000001)
|
||||
fy = np.clip(py - 0.5, 0, H - 1.000001)
|
||||
x0 = np.floor(fx).astype(np.int64)
|
||||
y0 = np.floor(fy).astype(np.int64)
|
||||
ax = (fx - x0)[..., None]
|
||||
ay = (fy - y0)[..., None]
|
||||
im = img if img.ndim == 3 else img[..., None]
|
||||
a = im[y0, x0].astype(np.float32)
|
||||
b = im[y0, x0 + 1].astype(np.float32)
|
||||
c = im[y0 + 1, x0].astype(np.float32)
|
||||
d = im[y0 + 1, x0 + 1].astype(np.float32)
|
||||
out = (a * (1 - ax) + b * ax) * (1 - ay) + (c * (1 - ax) + d * ax) * ay
|
||||
return out if img.ndim == 3 else out[..., 0]
|
||||
|
||||
|
||||
def reproject(frame, img, ox, oy, z, N, rows_per=256):
|
||||
"""Sample a Mercator mosaic (origin global px ox,oy at zoom z) onto the local NxN square."""
|
||||
h = frame.half
|
||||
out = np.zeros((N, N) + img.shape[2:], np.float32)
|
||||
xs = -h + (np.arange(N) + 0.5) * (2 * h / N)
|
||||
for r0 in range(0, N, rows_per):
|
||||
r = np.arange(r0, min(N, r0 + rows_per))
|
||||
ys = h - (r + 0.5) * (2 * h / N)
|
||||
X, Y = np.meshgrid(xs, ys)
|
||||
lon, lat = frame.ll(X, Y)
|
||||
mx, my = merc_px(lon, lat, z)
|
||||
out[r0:r0 + len(r)] = bilinear(img, mx - ox, my - oy)
|
||||
return out
|
||||
|
||||
|
||||
ESRI = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||
|
||||
|
||||
EOX_S2 = "https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2020_3857/default/g/{z}/{y}/{x}.jpg"
|
||||
|
||||
|
||||
def build_ground(frame, z, url=ESRI, sub="esri", name="ground"):
|
||||
mos, ox, oy, nf = mosaic(frame, z, url, sub, "jpg", "RGB")
|
||||
out = reproject(frame, mos, ox, oy, z, 4096)
|
||||
im = Image.fromarray(np.clip(out + 0.5, 0, 255).astype(np.uint8))
|
||||
p = os.path.join(frame.out, f"{name}.jpg")
|
||||
im.save(p, quality=90, optimize=True)
|
||||
print(f" wrote {p} ({os.path.getsize(p)/1e6:.2f} MB)")
|
||||
im2 = im.resize((2048, 2048), Image.LANCZOS)
|
||||
p2 = os.path.join(frame.out, f"{name}_2k.jpg")
|
||||
im2.save(p2, quality=88, optimize=True)
|
||||
print(f" wrote {p2} ({os.path.getsize(p2)/1e6:.2f} MB)")
|
||||
return nf
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- check overlay
|
||||
def to_px(frame, a, N):
|
||||
a = np.asarray(a, dtype=np.float64).reshape(-1, 2)
|
||||
return [((x + frame.half) / (2 * frame.half) * N, (frame.half - y) / (2 * frame.half) * N) for x, y in a]
|
||||
|
||||
|
||||
def check_canvas(frame, dark=0.45):
|
||||
from PIL import ImageEnhance
|
||||
im = Image.open(os.path.join(frame.out, "ground_2k.jpg")).convert("RGB")
|
||||
im = ImageEnhance.Brightness(im).enhance(dark)
|
||||
return im
|
||||
@@ -1,6 +1,6 @@
|
||||
// Export the film clock for the score: every visual event the audio locks to.
|
||||
import fs from 'node:fs';
|
||||
import { T, FEEDS, DURATION, captureTimes, CAPTURE_SLOW } from '../src/timeline.js';
|
||||
import { T, FEEDS, CITIES, DURATION, captureTimes, CAPTURE_SLOW } from '../src/timeline.js';
|
||||
import { angDist } from '../src/geo.js';
|
||||
|
||||
const heroes = JSON.parse(fs.readFileSync('imagery/hero/index.json', 'utf8'));
|
||||
@@ -34,7 +34,7 @@ const boot = [];
|
||||
}
|
||||
});
|
||||
|
||||
const status = [53.4, 54.5, 55.6, 56.7, 57.7];
|
||||
const status = [77.4, 78.5, 79.6, 80.7, 81.7];
|
||||
const rapid = Array.from({ length: 7 }, (_, i) => T.rapid + i * 0.22);
|
||||
fs.writeFileSync('audio/cues.json', JSON.stringify({ T, DURATION, feeds: FEEDS.map((x) => ({ key: x.key, t: x.t })), caps, tiles, boot, status, rapid }, null, 1));
|
||||
fs.writeFileSync('audio/cues.json', JSON.stringify({ T, DURATION, feeds: FEEDS.map((x) => ({ key: x.key, t: x.t })), cities: CITIES.map((c) => ({ slug: c.slug, t0: c.t0, t1: c.t1 })), caps, tiles, boot, status, rapid }, null, 1));
|
||||
console.log('cues:', caps.length, 'captures,', tiles.length, 'tiles,', boot.length, 'keystrokes');
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# v2: sound design only (made to sit under a music track). Normalise the SFX
|
||||
# mix to -16 LUFS, keep it as a stem, and mux share + master cuts.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
node tools/export-cues.mjs
|
||||
python3 audio/sfx.py
|
||||
stats=$(ffmpeg -hide_banner -i audio/sfx.wav -af loudnorm=I=-16:TP=-1.5:LRA=14:print_format=json -f null - 2>&1 | sed -n '/^{/,/^}/p')
|
||||
g() { echo "$stats" | python3 -c "import json,sys;print(json.load(sys.stdin)['$1'])"; }
|
||||
ffmpeg -y -loglevel error -i audio/sfx.wav -af "loudnorm=I=-16:TP=-1.5:LRA=14:measured_I=$(g input_i):measured_TP=$(g input_tp):measured_LRA=$(g input_lra):measured_thresh=$(g input_thresh):linear=true,aresample=48000" -c:a pcm_s24le audio/sfx_norm.wav
|
||||
mkdir -p out/final_v2
|
||||
cp audio/sfx_norm.wav out/final_v2/GodsEyeView_FirstLight_v2_SFX_stem.wav
|
||||
for tag in 16x9 9x16; do
|
||||
v=out/v2_video_$tag.mp4
|
||||
[ -s "$v" ] || { echo "missing $v"; continue; }
|
||||
ffmpeg -y -loglevel error -i $v -i audio/sfx_norm.wav -map 0:v -map 1:a -c:v copy -c:a aac -b:a 320k -shortest -movflags +faststart out/final_v2/GodsEyeView_FirstLight_v2_${tag}_master.mp4
|
||||
ffmpeg -y -loglevel error -i $v -c:v libx264 -preset slow -b:v 16M -maxrate 24M -bufsize 32M -pass 1 -passlogfile out/final_v2/p_$tag -an -f null /dev/null
|
||||
ffmpeg -y -loglevel error -i $v -i audio/sfx_norm.wav -map 0:v -map 1:a -c:v libx264 -preset slow -b:v 16M -maxrate 24M -bufsize 32M -pass 2 -passlogfile out/final_v2/p_$tag \
|
||||
-pix_fmt yuv420p -colorspace bt709 -color_primaries bt709 -color_trc bt709 -c:a aac -b:a 256k -shortest -movflags +faststart out/final_v2/GodsEyeView_FirstLight_v2_${tag}.mp4
|
||||
rm -f out/final_v2/p_$tag*
|
||||
echo "finalized $tag"
|
||||
done
|
||||
ls -la out/final_v2
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Build the flurry montage captures.
|
||||
|
||||
python3 select_cities.py # -> city_candidates.json (primary + backup per region)
|
||||
python3 build.py # -> imagery/flurry/fx_###.jpg, index.json, tools/flurry/sheet.jpg
|
||||
|
||||
Each capture: Esri World Imagery, 3x3 tiles around the point, centred 512x512
|
||||
crop, JPEG q86. Placeholder / low-variance captures are rejected automatically
|
||||
(tiles.quality_ok); captures that passed the metrics but looked wrong on the
|
||||
review sheets (forest, farmland, haze) are listed in VISUAL_REJECT. Rejected
|
||||
items are replaced by the next backup of the same region (cities) or by the
|
||||
next backup landmark.
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from landmarks import LANDMARKS # noqa: E402
|
||||
from tiles import HERE, MAX_WORKERS, OUT, capture, quality_ok # noqa: E402
|
||||
|
||||
N_CITIES = 170
|
||||
N_LANDMARKS = 90
|
||||
|
||||
# Natural Earth points that landed on a park/water/cemetery: moved to the dense centre.
|
||||
CITY_OVERRIDES = {
|
||||
"Lagos": (6.4541, 3.3947), # Lagos Island (was open lagoon)
|
||||
"Cairns": (-16.9230, 145.7740), # CBD (was mangrove mudflat)
|
||||
"Calgary": (51.0460, -114.0650), # downtown (was cemetery)
|
||||
"Dubai": (25.0805, 55.1403), # Dubai Marina (was construction lot)
|
||||
"Perth": (-31.9530, 115.8575), # CBD (was Kings Park bush)
|
||||
"Dhaka": (23.7330, 90.4172), # Motijheel (was hazy open ground)
|
||||
"Delhi": (28.6315, 77.2167), # Connaught Place
|
||||
"Montréal": (45.5017, -73.5673), # downtown (was Mount Royal park)
|
||||
"Busan": (35.1000, 129.0350), # Nampo / harbour (was forested hill)
|
||||
"Jeddah": (21.4858, 39.1925), # Al-Balad old town (was empty lot)
|
||||
"Tokyo": (35.6717, 139.7650), # Ginza (was Imperial Palace woods)
|
||||
}
|
||||
VISUAL_REJECT = {
|
||||
"Venice": "blurry low-res imagery",
|
||||
"Conakry": "heavy haze",
|
||||
"Maracaibo": "farmland / scrub",
|
||||
"Barquisimeto": "dry riverbed, no city",
|
||||
"Natal": "solid forest canopy",
|
||||
"João Pessoa": "forest canopy",
|
||||
"Mbuji-Mayi": "forest/scrub",
|
||||
"Huambo": "scrub, sparse",
|
||||
"Port Harcourt": "haze",
|
||||
"Qaraghandy": "dark featureless rail yard",
|
||||
"Herat": "low-res blur",
|
||||
"Chengdu": "smog haze",
|
||||
"Xian": "washed-out industrial lot",
|
||||
"Kaohsiung": "forested hill",
|
||||
"Sapporo": "university farm fields",
|
||||
"Isfahan": "low contrast blur",
|
||||
"Stuttgart": "hazy woodland",
|
||||
"Townsville": "airport apron, not city",
|
||||
}
|
||||
LANDMARK_BACKUPS = [
|
||||
"Glen Canyon Dam", "Grand Coulee Dam", "Mount St. Helens", "Groom Lake", "Diavik Diamond Mine",
|
||||
"Syncrude Mildred Lake oil sands", "Centre-pivot fields, Kansas", "Indianapolis Motor Speedway",
|
||||
"Brooklyn Bridge", "Panama Canal, Miraflores Locks", "Escondida Mine", "ALMA Observatory",
|
||||
"Meeting of the Waters, Manaus", "Brasilia, National Congress", "PS10 / PS20 Solar Towers", "Tower Bridge",
|
||||
"Port of Tema", "Akosombo Dam", "Okavango Delta", "FNB Stadium, Soweto", "East Oweinat pivots",
|
||||
"Kilimanjaro, Kibo crater", "Ras Laffan Industrial City", "Dubai International Airport", "Bagan temples",
|
||||
"Jiuquan Launch Centre", "Mount Bromo", "Vostochny Cosmodrome", "Pine Gap", "Tanegashima Space Center",
|
||||
"Teide", "Faa'a International Airport", "Hartsfield-Jackson Atlanta Airport", "Niagara Horseshoe Falls",
|
||||
"Iguazu Falls, Devil's Throat", "Vandenberg SLC-4E", "Paranal Observatory", "Cerro Dominador Solar",
|
||||
"Gibraltar Airport runway", "Erg Chebbi dunes", "Millau Viaduct", "Garzweiler Lignite Mine", "Inga Dams",
|
||||
"Grand Ethiopian Renaissance Dam", "Ras Tanura refinery", "Udachnaya Pipe", "Busan New Port",
|
||||
"Mount Taranaki", "Beijing National Stadium", "Port of Hamburg, Burchardkai", "Port of Santos",
|
||||
"Jebel Ali Port", "Mont-Saint-Michel", "Angkor Wat", "Mount Vesuvius", "Horseshoe Bend",
|
||||
"Benban Solar Park", "Borobudur", "Bhadla Solar Park", "Kariba Dam", "Wake Island Airfield",
|
||||
"Diamond Head", "Port Hedland salt ponds",
|
||||
]
|
||||
|
||||
|
||||
def evaluate(cands):
|
||||
def job(c):
|
||||
try:
|
||||
img, info = capture(c["lat"], c["lon"], c["zoom"])
|
||||
ok, why = quality_ok(info)
|
||||
except Exception as e: # network failure counts as a reject
|
||||
img, info, ok, why = None, {}, False, f"fetch error: {e}"
|
||||
if ok and c["name"] in VISUAL_REJECT and c.get("kind") == "city":
|
||||
ok, why = False, "visual: " + VISUAL_REJECT[c["name"]]
|
||||
return img, ok, why
|
||||
|
||||
with ThreadPoolExecutor(MAX_WORKERS) as ex:
|
||||
return list(ex.map(job, cands))
|
||||
|
||||
|
||||
def pick(cands, results, quota_by_group):
|
||||
"""Primaries first in order; failures replaced by the group's backups in order."""
|
||||
chosen, rejects = [], []
|
||||
for g, quota in quota_by_group.items():
|
||||
items = [(c, r) for c, r in zip(cands, results) if c["group"] == g]
|
||||
prim = [x for x in items if x[0]["rank"] == "primary"]
|
||||
back = [x for x in items if x[0]["rank"] == "backup"]
|
||||
got = []
|
||||
for c, r in prim + back:
|
||||
if len(got) >= quota:
|
||||
break
|
||||
if r[1]:
|
||||
got.append((c, r[0]))
|
||||
elif c["rank"] == "primary":
|
||||
rejects.append((c, r[2]))
|
||||
if len(got) < quota:
|
||||
print(f"!! group {g}: only {len(got)}/{quota}")
|
||||
chosen += got
|
||||
return chosen, rejects
|
||||
|
||||
|
||||
def main():
|
||||
cities = json.load(open(os.path.join(HERE, "city_candidates.json")))
|
||||
for c in cities:
|
||||
c["kind"], c["group"] = "city", "c:" + c["region"]
|
||||
if c["name"] in CITY_OVERRIDES:
|
||||
c["lat"], c["lon"] = CITY_OVERRIDES[c["name"]]
|
||||
cquota = {}
|
||||
for c in cities:
|
||||
if c["rank"] == "primary":
|
||||
cquota[c["group"]] = cquota.get(c["group"], 0) + 1
|
||||
assert sum(cquota.values()) == N_CITIES, cquota
|
||||
|
||||
lms = [dict(name=n, country=co, lat=la, lon=lo, zoom=z, kind="landmark", group="landmark",
|
||||
rank="backup" if n in LANDMARK_BACKUPS else "primary") for n, co, la, lo, z in LANDMARKS]
|
||||
lms.sort(key=lambda c: c["rank"] != "primary") # stable: primaries first
|
||||
nprim = sum(c["rank"] == "primary" for c in lms)
|
||||
assert nprim == N_LANDMARKS, nprim
|
||||
|
||||
allc = cities + lms
|
||||
print(f"evaluating {len(allc)} candidates ...", flush=True)
|
||||
res = evaluate(allc)
|
||||
quotas = dict(cquota)
|
||||
quotas["landmark"] = N_LANDMARKS
|
||||
chosen, rejects = pick(allc, res, quotas)
|
||||
|
||||
chosen.sort(key=lambda x: x[0]["lon"])
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
for f in glob.glob(os.path.join(OUT, "fx_*.jpg")):
|
||||
os.remove(f)
|
||||
index = []
|
||||
for i, (c, img) in enumerate(chosen):
|
||||
fn = f"fx_{i:03d}.jpg"
|
||||
img.save(os.path.join(OUT, fn), quality=86, optimize=True)
|
||||
index.append({"file": fn, "name": c["name"], "country": c["country"], "lat": round(c["lat"], 5),
|
||||
"lon": round(c["lon"], 5), "zoom": c["zoom"], "kind": c["kind"]})
|
||||
with open(os.path.join(OUT, "index.json"), "w") as f:
|
||||
json.dump(index, f, indent=1, ensure_ascii=False)
|
||||
|
||||
# contact sheet: 16 x 17 grid of 96px thumbs, west->east reading order
|
||||
cols, cell = 16, 96
|
||||
rows = (len(chosen) + cols - 1) // cols
|
||||
sheet = Image.new("RGB", (cols * cell, rows * cell), (10, 10, 10))
|
||||
for i, (c, img) in enumerate(chosen):
|
||||
sheet.paste(img.resize((cell, cell), Image.LANCZOS), ((i % cols) * cell, (i // cols) * cell))
|
||||
sheet.save(os.path.join(HERE, "sheet.jpg"), quality=88)
|
||||
|
||||
from collections import Counter
|
||||
print(f"wrote {len(index)} captures: {Counter(x['kind'] for x in index)}")
|
||||
print("rejected primaries (replaced by backups):")
|
||||
for c, why in rejects:
|
||||
print(f" - {c['name']} ({c['country']}): {why}")
|
||||
bands = Counter(int((x["lon"] + 180) // 30) for x in index)
|
||||
print("30-degree longitude bands W->E:", [bands.get(i, 0) for i in range(12)])
|
||||
json.dump([{"name": c["name"], "country": c["country"], "reason": why} for c, why in rejects],
|
||||
open(os.path.join(HERE, "rejects.json"), "w"), indent=1, ensure_ascii=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,205 @@
|
||||
"""Hand-picked non-city landmarks for the flurry montage.
|
||||
|
||||
(name, country, lat, lon, zoom). zoom 16 for compact features, 15 for very large
|
||||
ones (deltas, glaciers, big pits, pivot fields). Coordinates are verified
|
||||
visually via tools/flurry/review.py sheets; entries that came back as featureless
|
||||
terrain were corrected or dropped (see REJECTED at the bottom).
|
||||
"""
|
||||
|
||||
LANDMARKS = [
|
||||
# --- ports / container terminals ---
|
||||
("Port of Rotterdam, Maasvlakte", "Netherlands", 51.9575, 4.0500, 16),
|
||||
("Port of Singapore, Pasir Panjang", "Singapore", 1.2735, 103.7750, 16),
|
||||
("Yangshan Deep-Water Port", "China", 30.6280, 122.0650, 16),
|
||||
("Port of Los Angeles, Pier 400", "United States of America", 33.7350, -118.2550, 16),
|
||||
("Jebel Ali Port", "United Arab Emirates", 25.0100, 55.0600, 16),
|
||||
("Port of Santos", "Brazil", -23.9700, -46.3000, 16),
|
||||
("Port of Hamburg, Burchardkai", "Germany", 53.5350, 9.9200, 16),
|
||||
("Port Hedland salt ponds", "Australia", -20.3450, 118.6550, 15),
|
||||
("Busan New Port", "South Korea", 35.0800, 128.8000, 16),
|
||||
("Tanger Med Port", "Morocco", 35.8850, -5.5000, 16),
|
||||
("Port of Tema", "Ghana", 5.6330, 0.0100, 16),
|
||||
# --- airports ---
|
||||
("Hartsfield-Jackson Atlanta Airport", "United States of America", 33.6407, -84.4277, 16),
|
||||
("Dubai International Airport", "United Arab Emirates", 25.2528, 55.3644, 16),
|
||||
("Hong Kong International Airport", "Hong Kong S.A.R.", 22.3080, 113.9185, 16),
|
||||
("Denver International Airport", "United States of America", 39.8494, -104.6738, 16),
|
||||
("Kansai International Airport", "Japan", 34.4320, 135.2304, 16),
|
||||
("Beijing Daxing Airport", "China", 39.5098, 116.4105, 16),
|
||||
("Davis-Monthan Aircraft Boneyard", "United States of America", 32.1500, -110.8300, 16),
|
||||
("Princess Juliana Airport", "Sint Maarten", 18.0410, -63.1089, 16),
|
||||
("Gibraltar Airport runway", "Gibraltar", 36.1512, -5.3497, 16),
|
||||
("Faa'a International Airport", "French Polynesia", -17.5537, -149.6067, 16),
|
||||
("Funafuti Airfield", "Tuvalu", -8.5250, 179.1960, 16),
|
||||
("Midway Atoll, Sand Island", "United States of America", 28.2100, -177.3800, 16),
|
||||
("Wake Island Airfield", "United States of America", 19.2820, 166.6470, 16),
|
||||
# --- dams ---
|
||||
("Hoover Dam", "United States of America", 36.0160, -114.7377, 16),
|
||||
("Three Gorges Dam", "China", 30.8233, 111.0036, 16),
|
||||
("Itaipu Dam", "Brazil", -25.4085, -54.5888, 16),
|
||||
("Grand Coulee Dam", "United States of America", 47.9560, -118.9817, 16),
|
||||
("Kariba Dam", "Zimbabwe", -16.5222, 28.7614, 16),
|
||||
("Aswan High Dam", "Egypt", 23.9707, 32.8780, 16),
|
||||
("Grand Ethiopian Renaissance Dam", "Ethiopia", 11.2150, 35.0900, 16),
|
||||
("Glen Canyon Dam", "United States of America", 36.9375, -111.4844, 16),
|
||||
("Akosombo Dam", "Ghana", 6.2999, 0.0592, 16),
|
||||
("Inga Dams", "Congo (Kinshasa)", -5.5240, 13.6200, 16),
|
||||
# --- open-pit mines ---
|
||||
("Bingham Canyon Mine", "United States of America", 40.5230, -112.1510, 15),
|
||||
("Chuquicamata Mine", "Chile", -22.2900, -68.9000, 15),
|
||||
("Mir Diamond Mine", "Russia", 62.5275, 113.9936, 15),
|
||||
("Escondida Mine", "Chile", -24.2700, -69.0700, 15),
|
||||
("Kalgoorlie Super Pit", "Australia", -30.7775, 121.5040, 15),
|
||||
("Udachnaya Pipe", "Russia", 66.4300, 112.3200, 15),
|
||||
("Garzweiler Lignite Mine", "Germany", 51.0600, 6.4800, 15),
|
||||
("Kimberley Big Hole", "South Africa", -28.7383, 24.7585, 16),
|
||||
("Diavik Diamond Mine", "Canada", 64.4960, -110.2700, 15),
|
||||
("Syncrude Mildred Lake oil sands", "Canada", 57.0400, -111.6000, 15),
|
||||
# --- solar ---
|
||||
("Noor Ouarzazate Solar", "Morocco", 31.0498, -6.8686, 16),
|
||||
("Ivanpah Solar", "United States of America", 35.5525, -115.4680, 16),
|
||||
("Crescent Dunes Solar", "United States of America", 38.2386, -117.3636, 15),
|
||||
("Bhadla Solar Park", "India", 27.5400, 71.9100, 16),
|
||||
("Gemasolar", "Spain", 37.5606, -5.3317, 16),
|
||||
("PS10 / PS20 Solar Towers", "Spain", 37.4425, -6.2550, 16),
|
||||
("Benban Solar Park", "Egypt", 24.4500, 32.7300, 16),
|
||||
("Cerro Dominador Solar", "Chile", -22.7700, -69.4800, 16),
|
||||
# --- centre-pivot irrigation ---
|
||||
("Centre-pivot fields, Kansas", "United States of America", 37.9000, -100.9000, 15),
|
||||
("Centre-pivot fields, Wadi As-Sirhan", "Saudi Arabia", 30.2000, 38.0800, 15),
|
||||
("Kufra agricultural project", "Libya", 24.1400, 23.3500, 15),
|
||||
("East Oweinat pivots", "Egypt", 22.6000, 28.6000, 15),
|
||||
("Centre-pivot fields, Nebraska", "United States of America", 41.1000, -101.7000, 15),
|
||||
# --- river deltas / water ---
|
||||
("Lena Delta", "Russia", 72.8000, 126.5000, 15),
|
||||
("Okavango Delta", "Botswana", -19.3000, 22.9000, 15),
|
||||
("Betsiboka Estuary", "Madagascar", -15.7500, 46.3500, 15),
|
||||
("Meeting of the Waters, Manaus", "Brazil", -3.1370, -59.9100, 15),
|
||||
# --- salt / evaporation ---
|
||||
("Salar de Atacama lithium ponds", "Chile", -23.5280, -68.3680, 16),
|
||||
("Dead Sea evaporation ponds", "Israel", 31.1000, 35.4000, 15),
|
||||
("San Francisco Bay salt ponds", "United States of America", 37.5000, -122.0500, 15),
|
||||
("Lake Retba (Pink Lake)", "Senegal", 14.8400, -17.2300, 15),
|
||||
("Hutt Lagoon", "Australia", -28.1700, 114.2500, 15),
|
||||
# --- glaciers / ice ---
|
||||
("Perito Moreno Glacier", "Argentina", -50.4800, -73.0500, 15),
|
||||
("Aletsch Glacier, Konkordiaplatz", "Switzerland", 46.5000, 8.0300, 15),
|
||||
("Hubbard Glacier", "United States of America", 60.0200, -139.5000, 15),
|
||||
# Antarctica: Esri has only Landsat-class imagery here (z14+ is placeholder),
|
||||
# so these use z11-12 where the source pixels are native rather than upsampled.
|
||||
("Taylor Valley, Canada Glacier", "Antarctica", -77.6300, 162.9500, 12),
|
||||
("McMurdo Dry Valleys, Wright Valley", "Antarctica", -77.5700, 161.8500, 11),
|
||||
("Deception Island caldera", "Antarctica", -62.9600, -60.6300, 12),
|
||||
("Mount Everest", "Nepal", 27.9881, 86.9250, 15),
|
||||
# --- volcanoes / craters ---
|
||||
("Mount Bromo", "Indonesia", -7.9420, 112.9530, 15),
|
||||
("Erta Ale", "Ethiopia", 13.6065, 40.6655, 16),
|
||||
("Kilauea, Halemaumau", "United States of America", 19.4060, -155.2830, 15),
|
||||
("Mount Fuji summit crater", "Japan", 35.3628, 138.7307, 16),
|
||||
("Nyiragongo", "Congo (Kinshasa)", -1.5200, 29.2500, 16),
|
||||
("Meteor Crater", "United States of America", 35.0275, -111.0225, 15),
|
||||
("Mount St. Helens", "United States of America", 46.2000, -122.1900, 15),
|
||||
("Kawah Ijen", "Indonesia", -8.0580, 114.2420, 16),
|
||||
("Teide", "Spain", 28.2725, -16.6425, 16),
|
||||
("Mount Vesuvius", "Italy", 40.8216, 14.4263, 16),
|
||||
("Rano Kau, Easter Island", "Chile", -27.1860, -109.4350, 15),
|
||||
("Diamond Head", "United States of America", 21.2620, -157.8050, 15),
|
||||
("Darvaza Gas Crater", "Turkmenistan", 40.2525, 58.4394, 16),
|
||||
("Kilimanjaro, Kibo crater", "Tanzania", -3.0758, 37.3533, 15),
|
||||
("Mount Taranaki", "New Zealand", -39.2968, 174.0634, 15),
|
||||
("Whakaari / White Island", "New Zealand", -37.5200, 177.1800, 15),
|
||||
("Dallol", "Ethiopia", 14.2400, 40.3000, 16),
|
||||
# --- stadiums / venues ---
|
||||
("Maracana Stadium", "Brazil", -22.9122, -43.2302, 16),
|
||||
("Rungrado May Day Stadium", "North Korea", 39.0497, 125.7756, 16),
|
||||
("Camp Nou", "Spain", 41.3809, 2.1228, 16),
|
||||
("Beijing National Stadium", "China", 39.9925, 116.3910, 16),
|
||||
("Melbourne Cricket Ground", "Australia", -37.8200, 144.9834, 16),
|
||||
("Indianapolis Motor Speedway", "United States of America", 39.7950, -86.2350, 15),
|
||||
("Estadio Azteca", "Mexico", 19.3029, -99.1505, 16),
|
||||
("FNB Stadium, Soweto", "South Africa", -26.2348, 27.9825, 16),
|
||||
# --- bridges / canals ---
|
||||
("Golden Gate Bridge", "United States of America", 37.8100, -122.4770, 16),
|
||||
("Sydney Harbour Bridge & Opera House", "Australia", -33.8550, 151.2120, 16),
|
||||
("Tower Bridge", "United Kingdom", 51.5055, -0.0754, 16),
|
||||
("Oresund Bridge, Peberholm", "Denmark", 55.5900, 12.7740, 16),
|
||||
("Millau Viaduct", "France", 44.0794, 3.0225, 16),
|
||||
("Brooklyn Bridge", "United States of America", 40.7061, -73.9969, 16),
|
||||
("Panama Canal, Miraflores Locks", "Panama", 8.9970, -79.5915, 16),
|
||||
("Panama Canal, Agua Clara Locks", "Panama", 9.2720, -79.9180, 16),
|
||||
# --- launch sites ---
|
||||
("Kennedy Space Center LC-39A", "United States of America", 28.6083, -80.6041, 16),
|
||||
("Baikonur, Gagarin's Start", "Kazakhstan", 45.9203, 63.3422, 16),
|
||||
("Vandenberg SLC-4E", "United States of America", 34.6320, -120.6110, 16),
|
||||
("Starbase, Boca Chica", "United States of America", 25.9972, -97.1566, 16),
|
||||
("Wenchang Launch Site", "China", 19.6145, 110.9510, 16),
|
||||
("Jiuquan Launch Centre", "China", 40.9580, 100.2910, 16),
|
||||
("Tanegashima Space Center", "Japan", 30.4009, 130.9749, 16),
|
||||
("Vostochny Cosmodrome", "Russia", 51.8840, 128.3330, 16),
|
||||
("Rocket Lab LC-1, Mahia", "New Zealand", -39.2615, 177.8649, 16),
|
||||
# --- energy / industry ---
|
||||
("Permian Basin well pads", "United States of America", 32.2100, -103.8300, 15),
|
||||
("Ras Tanura refinery", "Saudi Arabia", 26.6400, 50.1600, 16),
|
||||
("Jamnagar Refinery", "India", 22.3500, 69.8500, 16),
|
||||
("Ras Laffan Industrial City", "Qatar", 25.9100, 51.5500, 16),
|
||||
("Yamal LNG, Sabetta", "Russia", 71.2700, 72.0700, 16),
|
||||
("Chernobyl Nuclear Power Plant", "Ukraine", 51.3890, 30.0990, 16),
|
||||
# --- monuments / sites ---
|
||||
("Jiayuguan Pass, Great Wall", "China", 39.8016, 98.2133, 16),
|
||||
("Pyramids of Giza", "Egypt", 29.9792, 31.1342, 16),
|
||||
("Palm Jumeirah", "United Arab Emirates", 25.1124, 55.1390, 15),
|
||||
("Angkor Wat", "Cambodia", 13.4125, 103.8670, 16),
|
||||
("The Pentagon", "United States of America", 38.8719, -77.0563, 16),
|
||||
("Groom Lake", "United States of America", 37.2350, -115.8111, 15),
|
||||
("FAST Radio Telescope", "China", 25.6529, 106.8566, 16),
|
||||
("Very Large Array", "United States of America", 34.0784, -107.6184, 16),
|
||||
("Uluru", "Australia", -25.3444, 131.0369, 15),
|
||||
("Mont-Saint-Michel", "France", 48.6361, -1.5115, 16),
|
||||
("Grand Prismatic Spring", "United States of America", 44.5251, -110.8382, 16),
|
||||
("Masjid al-Haram, Mecca", "Saudi Arabia", 21.4225, 39.8262, 16),
|
||||
("Forbidden City", "China", 39.9155, 116.3910, 16),
|
||||
("Taj Mahal", "India", 27.1751, 78.0421, 16),
|
||||
("Greenhouses of Almeria", "Spain", 36.7500, -2.7200, 16),
|
||||
("Pine Gap", "Australia", -23.7990, 133.7370, 16),
|
||||
("ALMA Observatory", "Chile", -23.0229, -67.7552, 16),
|
||||
("Paranal Observatory", "Chile", -24.6275, -70.4044, 16),
|
||||
("Brasilia, National Congress", "Brazil", -15.7998, -47.8645, 16),
|
||||
("Iguazu Falls, Devil's Throat", "Argentina", -25.6950, -54.4367, 16),
|
||||
("Niagara Horseshoe Falls", "Canada", 43.0773, -79.0757, 16),
|
||||
("Victoria Falls", "Zambia", -17.9243, 25.8572, 16),
|
||||
("Horseshoe Bend", "United States of America", 36.8791, -111.5103, 16),
|
||||
("Sossusvlei dunes", "Namibia", -24.7300, 15.2900, 15),
|
||||
("Erg Chebbi dunes", "Morocco", 31.1500, -3.9800, 15),
|
||||
("Bora Bora", "French Polynesia", -16.4950, -151.7050, 15),
|
||||
("Borobudur", "Indonesia", -7.6079, 110.2038, 16),
|
||||
("Bagan temples", "Myanmar", 21.1700, 94.8600, 16),
|
||||
("Svalbard, Longyearbyen", "Norway", 78.2200, 15.6500, 16),
|
||||
("Edinburgh of the Seven Seas, Tristan da Cunha", "United Kingdom", -37.0670, -12.3100, 16),
|
||||
("Diego Garcia", "United Kingdom", -7.3100, 72.4100, 15),
|
||||
("Kwajalein Atoll", "Marshall Islands", 8.7200, 167.7300, 16),
|
||||
]
|
||||
|
||||
REJECTED = {
|
||||
'Pituffik Space Base': 'placeholder above z13; z13 too blurry',
|
||||
'Ilulissat Icefjord': 'placeholder above z13; open water at z13',
|
||||
'McMurdo Station': 'placeholder above z13; blurry ice at z13',
|
||||
'Rothera Research Station': 'placeholder above z13; mostly sea',
|
||||
'Mount Erebus crater': 'placeholder above z13; cloud-like blur',
|
||||
'Port of Durban': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Port of Mombasa': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Sundarbans, Ganges Delta': "featureless/cloud/low-res/off-target at tested coords",
|
||||
"Mississippi Bird's-Foot Delta": "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Volga Delta': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Great Salt Lake causeway': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Jokulsarlon Glacier Lagoon': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Baltoro Glacier, Concordia': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Cotopaxi': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Guiana Space Centre ELA-3': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Satish Dhawan Space Centre': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Abqaiq processing plant': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Prudhoe Bay oil field': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Great Wall at Mutianyu': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Machu Picchu': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Baltra Island airport, Galapagos': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Moynaq ship graveyard, Aral Sea': "featureless/cloud/low-res/off-target at tested coords",
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Locator sheets: a wider mosaic around a guess with a lat/lon grid, to find exact coords.
|
||||
|
||||
usage: python3 locate.py out.jpg "name|lat|lon|z|ntiles" ...
|
||||
Each panel is ntiles x ntiles tiles at zoom z (resized to 640px) with grid
|
||||
lines every `step` degrees, labelled. Panels are stacked in a 2-column grid.
|
||||
"""
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from tiles import fetch_tile, pixel_xy # noqa: E402
|
||||
|
||||
P = 640
|
||||
|
||||
|
||||
def panel(name, lat, lon, z, nt):
|
||||
px, py = pixel_xy(lat, lon, z)
|
||||
tx, ty = int(px // 256), int(py // 256)
|
||||
h = nt // 2
|
||||
coords = [(tx - h + i, ty - h + j) for j in range(nt) for i in range(nt)]
|
||||
with ThreadPoolExecutor(8) as ex:
|
||||
datas = list(ex.map(lambda c: fetch_tile(z, c[0], c[1]), coords))
|
||||
m = Image.new("RGB", (nt * 256, nt * 256))
|
||||
for (x, y), d in zip(coords, datas):
|
||||
m.paste(Image.open(io.BytesIO(d)).convert("RGB"), ((x - tx + h) * 256, (y - ty + h) * 256))
|
||||
ox, oy = (tx - h) * 256, (ty - h) * 256
|
||||
s = P / (nt * 256)
|
||||
m = m.resize((P, P), Image.LANCZOS)
|
||||
d = ImageDraw.Draw(m)
|
||||
n = 2 ** z * 256
|
||||
|
||||
def lon2x(lo):
|
||||
return ((lo + 180) / 360 * n - ox) * s
|
||||
|
||||
def lat2y(la):
|
||||
si = math.sin(math.radians(la))
|
||||
return ((0.5 - math.log((1 + si) / (1 - si)) / (4 * math.pi)) * n - oy) * s
|
||||
|
||||
span = nt * 256 / n * 360
|
||||
step = [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5][
|
||||
min(range(9), key=lambda k: abs(span / [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5][k] - 6))]
|
||||
lo0 = math.floor((lon - span) / step) * step
|
||||
for k in range(40):
|
||||
lo = lo0 + k * step
|
||||
x = lon2x(lo)
|
||||
if 0 <= x <= P:
|
||||
d.line((x, 0, x, P), fill=(255, 255, 0), width=1)
|
||||
d.text((x + 2, P - 12), f"{lo:.3f}", fill=(255, 255, 0))
|
||||
la0 = math.floor((lat - span) / step) * step
|
||||
for k in range(40):
|
||||
la = la0 + k * step
|
||||
y = lat2y(la)
|
||||
if 0 <= y <= P:
|
||||
d.line((0, y, P, y), fill=(0, 255, 255), width=1)
|
||||
d.text((2, y + 1), f"{la:.3f}", fill=(0, 255, 255))
|
||||
cx, cy = lon2x(lon), lat2y(lat)
|
||||
d.ellipse((cx - 5, cy - 5, cx + 5, cy + 5), outline=(255, 0, 0), width=2)
|
||||
d.rectangle((0, 0, P, 14), fill=(0, 0, 0))
|
||||
d.text((3, 1), f"{name} z{z}", fill=(255, 255, 255))
|
||||
return m
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out = sys.argv[1]
|
||||
specs = [a.split("|") for a in sys.argv[2:]]
|
||||
ps = [panel(s[0], float(s[1]), float(s[2]), int(s[3]), int(s[4])) for s in specs]
|
||||
cols = 2 if len(ps) > 1 else 1
|
||||
rows = (len(ps) + cols - 1) // cols
|
||||
sheet = Image.new("RGB", (cols * P, rows * P))
|
||||
for i, p in enumerate(ps):
|
||||
sheet.paste(p, ((i % cols) * P, (i // cols) * P))
|
||||
sheet.save(out, quality=85)
|
||||
print(out)
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"name": "Venice",
|
||||
"country": "Italy",
|
||||
"reason": "visual: blurry low-res imagery"
|
||||
},
|
||||
{
|
||||
"name": "Chengdu",
|
||||
"country": "China",
|
||||
"reason": "visual: smog haze"
|
||||
},
|
||||
{
|
||||
"name": "Xian",
|
||||
"country": "China",
|
||||
"reason": "visual: washed-out industrial lot"
|
||||
},
|
||||
{
|
||||
"name": "Maracaibo",
|
||||
"country": "Venezuela",
|
||||
"reason": "visual: farmland / scrub"
|
||||
},
|
||||
{
|
||||
"name": "Barquisimeto",
|
||||
"country": "Venezuela",
|
||||
"reason": "visual: dry riverbed, no city"
|
||||
},
|
||||
{
|
||||
"name": "Conakry",
|
||||
"country": "Guinea",
|
||||
"reason": "visual: heavy haze"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Fetch candidates and render labelled review sheets for visual verification.
|
||||
|
||||
usage: python3 review.py landmarks|cities [start] [count]
|
||||
Writes tools/flurry/review/<kind>_NN.jpg (5x4 grid, 256px cells, labelled).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from tiles import HERE, MAX_WORKERS, capture, quality_ok # noqa: E402
|
||||
|
||||
REV = os.path.join(HERE, "review")
|
||||
|
||||
|
||||
def load(kind):
|
||||
if kind == "landmarks":
|
||||
from landmarks import LANDMARKS
|
||||
return [dict(name=n, country=c, lat=la, lon=lo, zoom=z) for n, c, la, lo, z in LANDMARKS]
|
||||
return json.load(open(os.path.join(HERE, "city_candidates.json")))
|
||||
|
||||
|
||||
def run(cands, prefix, cell=256, cols=5, rows=4):
|
||||
os.makedirs(REV, exist_ok=True)
|
||||
|
||||
def job(c):
|
||||
try:
|
||||
img, info = capture(c["lat"], c["lon"], c["zoom"])
|
||||
return img, info
|
||||
except Exception as e:
|
||||
return None, {"error": str(e)}
|
||||
|
||||
with ThreadPoolExecutor(MAX_WORKERS) as ex:
|
||||
res = list(ex.map(job, cands))
|
||||
per = cols * rows
|
||||
report = []
|
||||
for s in range(0, len(cands), per):
|
||||
sheet = Image.new("RGB", (cols * cell, rows * cell), (20, 20, 20))
|
||||
d = ImageDraw.Draw(sheet)
|
||||
for k, (c, (img, info)) in enumerate(zip(cands[s:s + per], res[s:s + per])):
|
||||
x, y = (k % cols) * cell, (k // cols) * cell
|
||||
idx = s + k
|
||||
if img is not None:
|
||||
sheet.paste(img.resize((cell, cell), Image.LANCZOS), (x, y))
|
||||
ok, why = quality_ok(info)
|
||||
lab2 = f"s{info['std']:.0f} g{info['grad']:.1f}" + ("" if ok else " X " + why[:18])
|
||||
else:
|
||||
ok, lab2 = False, "ERR"
|
||||
d.rectangle((x, y, x + cell, y + 24), fill=(0, 0, 0))
|
||||
d.text((x + 3, y + 1), f"{idx} {c['name'][:36]}", fill=(255, 255, 0))
|
||||
d.text((x + 3, y + 12), lab2, fill=(255, 120, 120) if not ok else (160, 255, 160))
|
||||
report.append((idx, c["name"], ok, lab2))
|
||||
out = os.path.join(REV, f"{prefix}_{s // per:02d}.jpg")
|
||||
sheet.save(out, quality=85)
|
||||
print(out)
|
||||
for r in report:
|
||||
if not r[2]:
|
||||
print("REJECT", r)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
kind = sys.argv[1]
|
||||
cands = load(kind)
|
||||
st = int(sys.argv[2]) if len(sys.argv) > 2 else 0
|
||||
n = int(sys.argv[3]) if len(sys.argv) > 3 else len(cands)
|
||||
run(cands[st:st + n], kind[:2] + (f"_{st}" if st else ""))
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Pick ~170 city centres from data/cities.json with geographic spread.
|
||||
|
||||
Writes tools/flurry/city_candidates.json: ordered list of dicts with
|
||||
rank ("primary"/"backup") and region. Backups replace rejected primaries of the
|
||||
same region in build.py.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
|
||||
AFRICA = {
|
||||
"Nigeria", "Egypt", "Congo (Kinshasa)", "South Africa", "Algeria", "Sudan", "Morocco", "Tanzania",
|
||||
"Cameroon", "Mozambique", "Libya", "Ethiopia", "Ivory Coast", "Tunisia", "Zambia", "Angola", "Ghana",
|
||||
"Uganda", "Kenya", "Guinea", "Madagascar", "Senegal", "Zimbabwe", "Mali", "Somalia", "Chad", "Niger",
|
||||
"Benin", "Eritrea", "Somaliland", "South Sudan", "Sierra Leone", "Mauritania", "Malawi", "Togo",
|
||||
"Congo (Brazzaville)", "Burkina Faso", "Rwanda", "Mauritius", "Gabon", "Equatorial Guinea", "Liberia",
|
||||
"Djibouti", "Central African Republic", "Guinea Bissau", "Lesotho", "Burundi", "Namibia", "Botswana",
|
||||
"The Gambia", "Comoros", "Cape Verde", "eSwatini",
|
||||
}
|
||||
SOUTH_AMERICA = {"Brazil", "Argentina", "Colombia", "Venezuela", "Peru", "Chile", "Ecuador", "Bolivia",
|
||||
"Paraguay", "Uruguay", "Guyana", "Suriname"}
|
||||
OCEANIA = {"Australia", "New Zealand", "Papua New Guinea", "Fiji", "French Polynesia", "Guam"}
|
||||
CENTRAL_ASIA = {"Kazakhstan", "Uzbekistan", "Turkmenistan", "Kyrgyzstan", "Tajikistan", "Afghanistan", "Mongolia"}
|
||||
|
||||
# (region, primary quota, backups, per-country cap, min spacing km)
|
||||
PASSES = [
|
||||
("africa", AFRICA, 31, 16, 3, 300),
|
||||
("south_america", SOUTH_AMERICA, 22, 10, 5, 250),
|
||||
("oceania", OCEANIA, 12, 4, 5, 120),
|
||||
("central_asia", CENTRAL_ASIA, 9, 5, 3, 150),
|
||||
("rest", None, 96, 40, 11, 200),
|
||||
]
|
||||
# Picked first (in "rest" unless their country is in a quota region): fill the
|
||||
# Pacific / high-latitude longitude bands and add visually distinctive cities.
|
||||
FORCED = [
|
||||
"Honolulu", "Anchorage", "Papeete", "Agana", "Petropavlovsk-Kamchatsky", "Vancouver", "San Francisco",
|
||||
"Las Vegas", "Seattle", "Phoenix", "Denver", "Calgary", "Guatemala City", "Havana", "Reykjavík",
|
||||
"Dubai", "Doha", "Abu Dhabi", "Hong Kong", "Macau", "Venice", "Amsterdam", "København", "Oslo",
|
||||
"Stockholm", "Helsinki", "Prague", "Kathmandu", "Colombo", "Vladivostok", "Novosibirsk", "Irkutsk",
|
||||
"Yakutsk", "Norilsk", "Murmansk", "Manaus", "Mombasa", "Windhoek", "Nouakchott", "Marrakesh",
|
||||
"Samarkand", "Cairns", "Muscat", "Québec",
|
||||
]
|
||||
# Natural Earth rows whose point lands in farmland/hills rather than a dense centre.
|
||||
EXCLUDE = {"Amaravati", "Hechi", "Saidu", "Cranbourne", "Manukau", "Waitakere", "North Shore", "Wahiawa"}
|
||||
COUNTRY_CAP_TOTAL = 12
|
||||
|
||||
|
||||
def km(a, b):
|
||||
la1, lo1, la2, lo2 = map(math.radians, (a[1], a[0], b[1], b[0]))
|
||||
h = math.sin((la2 - la1) / 2) ** 2 + math.cos(la1) * math.cos(la2) * math.sin((lo2 - lo1) / 2) ** 2
|
||||
return 6371 * 2 * math.asin(math.sqrt(h))
|
||||
|
||||
|
||||
def main():
|
||||
rows = json.load(open(os.path.join(ROOT, "data", "cities.json")))["rows"]
|
||||
special = AFRICA | SOUTH_AMERICA | OCEANIA | CENTRAL_ASIA
|
||||
chosen, out = [], []
|
||||
byname = {}
|
||||
for r in rows:
|
||||
byname.setdefault(r[3], r) # first = most populous
|
||||
per = {}
|
||||
|
||||
def region_of(country):
|
||||
for reg, mem, *_ in PASSES:
|
||||
if mem and country in mem:
|
||||
return reg
|
||||
return "rest"
|
||||
|
||||
for nm in FORCED:
|
||||
lon, lat, pop, name, country = byname[nm]
|
||||
chosen.append((lon, lat))
|
||||
per[country] = per.get(country, 0) + 1
|
||||
out.append(dict(name=name, country=country, lat=lat, lon=lon, zoom=16, pop=pop,
|
||||
region=region_of(country), rank="primary", forced=True))
|
||||
for region, members, quota, nback, cap, spacing in PASSES:
|
||||
got = sum(1 for c in out if c["region"] == region)
|
||||
for lon, lat, pop, name, country in rows:
|
||||
if name in EXCLUDE or name in byname and any(c["name"] == name and c["country"] == country for c in out):
|
||||
continue
|
||||
inreg = (country in members) if members else (country not in special)
|
||||
if not inreg:
|
||||
continue
|
||||
if per.get(country, 0) >= cap and got < quota or per.get(country, 0) >= COUNTRY_CAP_TOTAL:
|
||||
continue
|
||||
if any(km((lon, lat), (c[0], c[1])) < spacing for c in chosen):
|
||||
continue
|
||||
rank = "primary" if got < quota else "backup"
|
||||
chosen.append((lon, lat))
|
||||
per[country] = per.get(country, 0) + 1
|
||||
out.append(dict(name=name, country=country, lat=lat, lon=lon, zoom=16, pop=pop,
|
||||
region=region, rank=rank))
|
||||
got += 1
|
||||
if got >= quota + nback:
|
||||
break
|
||||
json.dump(out, open(os.path.join(HERE, "city_candidates.json"), "w"), indent=0, ensure_ascii=False)
|
||||
prim = [c for c in out if c["rank"] == "primary"]
|
||||
print(len(prim), "primary,", len(out) - len(prim), "backup")
|
||||
from collections import Counter
|
||||
print(Counter(c["region"] for c in prim))
|
||||
print(Counter(c["country"] for c in prim).most_common(12))
|
||||
bands = Counter(int((c["lon"] + 180) // 30) for c in prim)
|
||||
print("30deg bands:", [bands.get(i, 0) for i in range(12)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Polite cached Esri World Imagery fetch + 512px centred capture + quality metrics."""
|
||||
import hashlib
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CACHE = os.path.join(HERE, "cache")
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
OUT = os.path.join(ROOT, "imagery", "flurry")
|
||||
|
||||
ESRI = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||
UA = "gods-eye-view-intro/1.0 (flurry montage prep)"
|
||||
MAX_WORKERS = 8
|
||||
PLACEHOLDER_MD5 = {"f27d9de7f80c13501f470595e327aa6d"} # "Map data not yet available"
|
||||
|
||||
_local = threading.local()
|
||||
|
||||
|
||||
def _session():
|
||||
s = getattr(_local, "s", None)
|
||||
if s is None:
|
||||
s = requests.Session()
|
||||
s.headers["User-Agent"] = UA
|
||||
_local.s = s
|
||||
return s
|
||||
|
||||
|
||||
def fetch_tile(z, x, y, retries=6):
|
||||
n = 2 ** z
|
||||
x %= n
|
||||
path = os.path.join(CACHE, str(z), str(x), f"{y}.jpg")
|
||||
if os.path.exists(path) and os.path.getsize(path) > 0:
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
url = ESRI.format(z=z, x=x, y=y)
|
||||
last = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
r = _session().get(url, timeout=45)
|
||||
if r.status_code == 200 and r.content:
|
||||
tmp = path + f".{threading.get_ident()}.part"
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(r.content)
|
||||
os.replace(tmp, path)
|
||||
return r.content
|
||||
last = f"HTTP {r.status_code}"
|
||||
if r.status_code in (400, 404):
|
||||
break
|
||||
except Exception as e:
|
||||
last = repr(e)
|
||||
time.sleep(min(20, 1.0 * 2 ** attempt))
|
||||
raise RuntimeError(f"failed {url}: {last}")
|
||||
|
||||
|
||||
def is_placeholder(data):
|
||||
if hashlib.md5(data).hexdigest() in PLACEHOLDER_MD5:
|
||||
return True
|
||||
a = np.asarray(Image.open(io.BytesIO(data)).convert("RGB")).astype(np.float32)
|
||||
m = a.mean(axis=(0, 1))
|
||||
return bool(abs(m - 204.7).max() < 4 and a.std() < 9)
|
||||
|
||||
|
||||
def pixel_xy(lat, lon, z):
|
||||
n = 2 ** z * 256
|
||||
px = (lon + 180.0) / 360.0 * n
|
||||
s = math.sin(math.radians(lat))
|
||||
py = (0.5 - math.log((1 + s) / (1 - s)) / (4 * math.pi)) * n
|
||||
return px, py
|
||||
|
||||
|
||||
def capture(lat, lon, z, size=512):
|
||||
"""Return (image, info). info has placeholder flag and quality metrics."""
|
||||
px, py = pixel_xy(lat, lon, z)
|
||||
tx, ty = int(px // 256), int(py // 256)
|
||||
mosaic = Image.new("RGB", (768, 768))
|
||||
ox, oy = (tx - 1) * 256, (ty - 1) * 256
|
||||
left, top = int(round(px - ox - size / 2)), int(round(py - oy - size / 2))
|
||||
ph = False
|
||||
for j in range(3):
|
||||
for i in range(3):
|
||||
data = fetch_tile(z, tx - 1 + i, ty - 1 + j)
|
||||
# does this tile overlap the crop window?
|
||||
tl, tt = i * 256, j * 256
|
||||
overlaps = tl < left + size and tl + 256 > left and tt < top + size and tt + 256 > top
|
||||
if overlaps and is_placeholder(data):
|
||||
ph = True
|
||||
mosaic.paste(Image.open(io.BytesIO(data)).convert("RGB"), (tl, tt))
|
||||
img = mosaic.crop((left, top, left + size, top + size))
|
||||
info = metrics(img)
|
||||
info["placeholder"] = ph
|
||||
return img, info
|
||||
|
||||
|
||||
def metrics(img):
|
||||
a = np.asarray(img).astype(np.float32)
|
||||
lum = a @ np.array([0.299, 0.587, 0.114], np.float32)
|
||||
small = np.asarray(img.convert("L").resize((128, 128), Image.BILINEAR)).astype(np.float32)
|
||||
gx = np.abs(np.diff(lum, axis=1)).mean()
|
||||
gy = np.abs(np.diff(lum, axis=0)).mean()
|
||||
# coarse-scale structure: std of 32px block means (catches flat ocean/cloud/ice)
|
||||
blocks = lum.reshape(16, 32, 16, 32).mean(axis=(1, 3))
|
||||
sat = (a.max(axis=2) - a.min(axis=2)).mean()
|
||||
return {
|
||||
"std": float(lum.std()),
|
||||
"grad": float((gx + gy) / 2),
|
||||
"block_std": float(blocks.std()),
|
||||
"small_std": float(small.std()),
|
||||
"sat": float(sat),
|
||||
"mean": float(lum.mean()),
|
||||
}
|
||||
|
||||
|
||||
def quality_ok(info):
|
||||
if info["placeholder"]:
|
||||
return False, "placeholder"
|
||||
if info["std"] < 12:
|
||||
return False, f"low variance (std {info['std']:.1f})"
|
||||
if info["grad"] < 1.3:
|
||||
return False, f"featureless (grad {info['grad']:.1f})"
|
||||
if info["mean"] > 225 and info["std"] < 25:
|
||||
return False, "cloud/snow white-out"
|
||||
if info["mean"] < 22:
|
||||
return False, "too dark"
|
||||
return True, ""
|
||||
@@ -0,0 +1,82 @@
|
||||
"""MBTA static GTFS -> city/boston/transit.json (subway, Mattapan, commuter rail, bus).
|
||||
usage: python3 build_transit.py <dir with routes.txt shapes.txt trips.txt>"""
|
||||
import csv, json, math, os, sys
|
||||
from collections import defaultdict
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import SCENES, enu
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
G = sys.argv[1]
|
||||
H = SCENES['boston'][2]; M = H + 1000
|
||||
|
||||
def rdp(pts, eps):
|
||||
if len(pts) < 3: return pts
|
||||
keep = [False] * len(pts); keep[0] = keep[-1] = True
|
||||
stack = [(0, len(pts) - 1)]
|
||||
while stack:
|
||||
a, b = stack.pop()
|
||||
ax, ay = pts[a]; bx, by = pts[b]; dx, dy = bx - ax, by - ay; L = math.hypot(dx, dy)
|
||||
best, bi = -1, -1
|
||||
for i in range(a + 1, b):
|
||||
px, py = pts[i]
|
||||
d = abs(dy * (px - ax) - dx * (py - ay)) / L if L else math.hypot(px - ax, py - ay)
|
||||
if d > best: best, bi = d, i
|
||||
if best > eps:
|
||||
keep[bi] = True; stack += [(a, bi), (bi, b)]
|
||||
return [p for p, k in zip(pts, keep) if k]
|
||||
|
||||
def length(p): return sum(math.hypot(p[i+1][0]-p[i][0], p[i+1][1]-p[i][1]) for i in range(len(p)-1))
|
||||
|
||||
routes = {}
|
||||
for r in csv.DictReader(open(os.path.join(G, 'routes.txt'), encoding='utf-8-sig')):
|
||||
t = int(r['route_type'])
|
||||
if t not in (0, 1, 2, 3): continue
|
||||
if 'Replacement' in r['route_desc'] or r['route_id'].startswith('Shuttle'): continue
|
||||
name = r['route_short_name'] or r['route_long_name']
|
||||
if t == 3 and r['route_short_name'] and r['route_long_name']:
|
||||
name = f"{r['route_short_name']} {r['route_long_name']}"
|
||||
routes[r['route_id']] = {'name': name, 'color': '#' + (r['route_color'] or '888888').upper(), 'type': t}
|
||||
|
||||
shape_rd = {}
|
||||
for t in csv.DictReader(open(os.path.join(G, 'trips.txt'), encoding='utf-8-sig')):
|
||||
if t['route_id'] in routes and t['shape_id']:
|
||||
shape_rd.setdefault(t['shape_id'], (t['route_id'], t['direction_id']))
|
||||
|
||||
shapes = defaultdict(list)
|
||||
for s in csv.DictReader(open(os.path.join(G, 'shapes.txt'), encoding='utf-8-sig')):
|
||||
if s['shape_id'] in shape_rd:
|
||||
shapes[s['shape_id']].append((int(s['shape_pt_sequence']), float(s['shape_pt_lat']), float(s['shape_pt_lon'])))
|
||||
|
||||
best = {} # (route, dir) -> (len_in_square, segments)
|
||||
for sid, pts in shapes.items():
|
||||
pts.sort()
|
||||
xy = [enu('boston', la, lo) for _, la, lo in pts]
|
||||
if not any(abs(x) <= H and abs(y) <= H for x, y in xy): continue # must pass within the square
|
||||
segs, cur = [], []
|
||||
for i, (x, y) in enumerate(xy):
|
||||
if abs(x) <= M and abs(y) <= M:
|
||||
if not cur and i > 0: cur.append(xy[i-1]) # carry edge point
|
||||
cur.append((x, y))
|
||||
elif cur:
|
||||
cur.append((x, y)); segs.append(cur); cur = []
|
||||
if cur: segs.append(cur)
|
||||
segs = [rdp(s, 2.0) for s in segs if len(s) > 1]
|
||||
L = sum(length(s) for s in segs)
|
||||
key = shape_rd[sid]
|
||||
if key not in best or L > best[key][0]: best[key] = (L, segs)
|
||||
|
||||
out_shapes, seen = [], set()
|
||||
for (rid, d), (L, segs) in sorted(best.items()):
|
||||
for s in segs:
|
||||
flat = tuple(round(v, 1) for p in s for v in p)
|
||||
if (rid, flat) in seen: continue
|
||||
seen.add((rid, flat))
|
||||
out_shapes.append([rid] + list(flat))
|
||||
used = {s[0] for s in out_shapes}
|
||||
out = {'routes': {k: v for k, v in routes.items() if k in used}, 'shapes': out_shapes,
|
||||
'source': 'MBTA GTFS ' + open(os.path.join(G, 'feed_info.txt')).read().splitlines()[1].split(',"')[1].split('"')[0]}
|
||||
json.dump(out, open(os.path.join(ROOT, 'city', 'boston', 'transit.json'), 'w'), separators=(',', ':'))
|
||||
bytype = defaultdict(int)
|
||||
for k in used: bytype[routes[k]['type']] += 1
|
||||
print('routes', len(used), dict(bytype), 'shapes', len(out_shapes), 'pts', sum((len(s)-1)//2 for s in out_shapes))
|
||||
print('rail routes:', sorted(k for k in used if routes[k]['type'] != 3))
|
||||
print('missing requested:', [k for k in ['Red','Orange','Blue','Green-B','Green-C','Green-D','Green-E','Mattapan'] if k not in used])
|
||||
@@ -0,0 +1,15 @@
|
||||
import math
|
||||
SCENES = {
|
||||
'nyc': (40.7400, -73.9900, 3500),
|
||||
'boston': (42.3555, -71.0605, 3200),
|
||||
'lax': (33.9425, -118.4081, 3500),
|
||||
'bragg': (35.1580, -79.0090, 6000),
|
||||
}
|
||||
def enu(slug, lat, lon):
|
||||
lat0, lon0, _ = SCENES[slug]
|
||||
x = (lon - lon0) * math.cos(math.radians(lat0)) * 111320
|
||||
y = (lat - lat0) * 110540
|
||||
return round(x, 1), round(y, 1)
|
||||
def inside(slug, x, y, margin=0.0):
|
||||
h = SCENES[slug][2] + margin
|
||||
return abs(x) <= h and abs(y) <= h
|
||||
@@ -0,0 +1,76 @@
|
||||
"""YOLOv8n (ONNX, CPU) over cctv/cam_*.jpg and cctv/nyc/*.jpg -> cctv/detections.json.
|
||||
Model auto-downloads to tools/live/models/yolov8n.onnx (git-ignored)."""
|
||||
import glob, json, os, sys, time, requests
|
||||
import numpy as np, onnxruntime as ort
|
||||
from PIL import Image, ImageDraw
|
||||
from collections import Counter
|
||||
HERE = os.path.dirname(os.path.abspath(__file__)); ROOT = os.path.abspath(os.path.join(HERE, '..', '..'))
|
||||
MODEL = os.path.join(HERE, 'models', 'yolov8n.onnx')
|
||||
MODEL_URL = 'https://huggingface.co/Kalray/yolov8/resolve/main/yolov8n.onnx'
|
||||
KEEP = {0: 'person', 1: 'bicycle', 2: 'car', 3: 'motorcycle', 5: 'bus', 7: 'truck', 9: 'traffic light', 11: 'stop sign'}
|
||||
CONF, IOU, S = 0.16, 0.45, 640
|
||||
if not os.path.exists(MODEL):
|
||||
os.makedirs(os.path.dirname(MODEL), exist_ok=True)
|
||||
open(MODEL, 'wb').write(requests.get(MODEL_URL, timeout=120).content)
|
||||
sess = ort.InferenceSession(MODEL, providers=['CPUExecutionProvider'])
|
||||
inp = sess.get_inputs()[0].name
|
||||
|
||||
def nms(b, s, thr):
|
||||
order = s.argsort()[::-1]; keep = []
|
||||
while order.size:
|
||||
i = order[0]; keep.append(i)
|
||||
xx0 = np.maximum(b[i, 0], b[order[1:], 0]); yy0 = np.maximum(b[i, 1], b[order[1:], 1])
|
||||
xx1 = np.minimum(b[i, 2], b[order[1:], 2]); yy1 = np.minimum(b[i, 3], b[order[1:], 3])
|
||||
inter = np.clip(xx1 - xx0, 0, None) * np.clip(yy1 - yy0, 0, None)
|
||||
a = (b[:, 2] - b[:, 0]) * (b[:, 3] - b[:, 1])
|
||||
iou = inter / (a[i] + a[order[1:]] - inter + 1e-9)
|
||||
order = order[1:][iou <= thr]
|
||||
return keep
|
||||
|
||||
def detect(path):
|
||||
im = Image.open(path).convert('RGB'); W, H = im.size
|
||||
r = min(S / W, S / H); nw, nh = round(W * r), round(H * r); px, py = (S - nw) // 2, (S - nh) // 2
|
||||
canvas = Image.new('RGB', (S, S), (114, 114, 114)); canvas.paste(im.resize((nw, nh), Image.BILINEAR), (px, py))
|
||||
x = np.asarray(canvas, dtype=np.float32).transpose(2, 0, 1)[None] / 255.0
|
||||
p = sess.run(None, {inp: x})[0][0].T # 8400 x 84
|
||||
sc = p[:, 4:]; cls = sc.argmax(1); conf = sc.max(1)
|
||||
m = (conf >= CONF) & np.isin(cls, list(KEEP))
|
||||
p, cls, conf = p[m], cls[m], conf[m]
|
||||
if not len(p): return []
|
||||
b = np.stack([p[:, 0] - p[:, 2] / 2, p[:, 1] - p[:, 3] / 2, p[:, 0] + p[:, 2] / 2, p[:, 1] + p[:, 3] / 2], 1)
|
||||
out = []
|
||||
for c in np.unique(cls): # class-wise NMS
|
||||
idx = np.where(cls == c)[0]
|
||||
for k in nms(b[idx], conf[idx], IOU):
|
||||
j = idx[k]; x0, y0, x1, y1 = b[j]
|
||||
x0 = min(max((x0 - px) / r / W, 0), 1); x1 = min(max((x1 - px) / r / W, 0), 1)
|
||||
y0 = min(max((y0 - py) / r / H, 0), 1); y1 = min(max((y1 - py) / r / H, 0), 1)
|
||||
if x1 - x0 > 0 and y1 - y0 > 0:
|
||||
out.append([round(float(x0), 4), round(float(y0), 4), round(float(x1), 4), round(float(y1), 4), round(float(conf[j]), 3), KEEP[int(c)]])
|
||||
return sorted(out, key=lambda d: -d[4])
|
||||
|
||||
COL = {'person': (255, 60, 60), 'car': (60, 200, 255), 'truck': (255, 180, 0), 'bus': (255, 0, 255), 'bicycle': (0, 255, 120),
|
||||
'motorcycle': (180, 255, 0), 'traffic light': (255, 255, 255), 'stop sign': (255, 0, 0)}
|
||||
def render(path, dets, dst):
|
||||
im = Image.open(path).convert('RGB'); W, H = im.size; d = ImageDraw.Draw(im)
|
||||
for x0, y0, x1, y1, c, n in dets:
|
||||
d.rectangle([x0 * W, y0 * H, x1 * W, y1 * H], outline=COL[n], width=2)
|
||||
d.text((x0 * W + 2, max(0, y0 * H - 11)), f'{n} {c:.2f}', fill=COL[n])
|
||||
im.save(dst, quality=90)
|
||||
|
||||
if __name__ == '__main__':
|
||||
files = sorted(glob.glob(os.path.join(ROOT, 'cctv', 'cam_*.jpg'))) + sorted(glob.glob(os.path.join(ROOT, 'cctv', 'nyc', '*.jpg')))
|
||||
res, tot, t0 = {}, Counter(), time.time()
|
||||
for f in files:
|
||||
rel = os.path.relpath(f, os.path.join(ROOT, 'cctv'))
|
||||
res[rel] = detect(f); tot.update(d[5] for d in res[rel])
|
||||
dt = time.time() - t0
|
||||
json.dump(res, open(os.path.join(ROOT, 'cctv', 'detections.json'), 'w'), separators=(',', ':'))
|
||||
print('images', len(files), 'secs', round(dt, 1), 'ms/img', round(1000 * dt / max(1, len(files))))
|
||||
print('totals', dict(tot.most_common()), 'all', sum(tot.values()))
|
||||
print('nyc totals', dict(Counter(d[5] for k, v in res.items() if k.startswith('nyc/') for d in v)))
|
||||
picks = sys.argv[1:] or [max((k for k in res if k.startswith('nyc/')), key=lambda k: len(res[k])),
|
||||
max((k for k in res if not k.startswith('nyc/')), key=lambda k: len(res[k])),
|
||||
max(res, key=lambda k: sum(d[5] == 'person' for d in res[k]))]
|
||||
for i, k in enumerate(picks, 1):
|
||||
render(os.path.join(ROOT, 'cctv', k), res[k], os.path.join(HERE, f'det_check_{i}.jpg')); print('render', i, k, len(res[k]))
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Poll live ADS-B (adsb.lol, fallback adsb.fi) around each scene 3x, 20 s apart.
|
||||
Writes city/<slug>/air.json with latest sample per aircraft + short track history."""
|
||||
import json, os, sys, time, requests
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import SCENES, enu
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
RAD = {'nyc': 25, 'lax': 25, 'bragg': 25, 'boston': 20}
|
||||
HELI_TYPES = {'EC35','EC30','EC45','EC55','EC20','EC25','EC75','EC13','EC40','AS50','AS55','AS65','AS32','AS3B','H125','H130','H135','H145','H160','H175','B06','B06T','B407','B412','B427','B429','B430','B47G','B505','B212','B222','B230','B105','BK17','R22','R44','R66','S76','S92','S61','S64','S70','H60','UH60','MH60','HH60','H47','CH47','H53','A109','A119','A139','A169','A189','AW09','MI8','MI17','UH1','UH1Y','AH1','AH64','H64','V22','MV22','CABR','G2CA','EN28','EN48','H269','H500','MD52','MD60','NH90','LYNX','PUMA','GAZL','EH10','H1','B505'}
|
||||
H = {'User-Agent': 'gods-eye-view-intro/1.0 (film research)'}
|
||||
def fetch(slug):
|
||||
lat, lon, _ = SCENES[slug]; nm = RAD[slug]
|
||||
for url, key in ((f'https://api.adsb.lol/v2/point/{lat}/{lon}/{nm}', 'ac'),
|
||||
(f'https://opendata.adsb.fi/api/v2/lat/{lat}/lon/{lon}/dist/{nm}', 'aircraft')):
|
||||
try:
|
||||
r = requests.get(url, headers=H, timeout=20); r.raise_for_status(); j = r.json()
|
||||
ac = j.get('ac') or j.get('aircraft') or []
|
||||
now = j.get('now') or time.time() * 1000
|
||||
return url, now / 1000 if now > 1e11 else now, ac
|
||||
except Exception as e:
|
||||
print(slug, 'fail', url, e, file=sys.stderr)
|
||||
return None, time.time(), []
|
||||
def num(v):
|
||||
try: return float(v)
|
||||
except Exception: return None
|
||||
def conv(slug, a):
|
||||
lat, lon = num(a.get('lat')), num(a.get('lon'))
|
||||
if lat is None or lon is None:
|
||||
lp = a.get('lastPosition') or {}
|
||||
lat, lon = num(lp.get('lat')), num(lp.get('lon'))
|
||||
if lat is None: return None
|
||||
x, y = enu(slug, lat, lon)
|
||||
ab = a.get('alt_baro')
|
||||
if ab == 'ground': alt = 0.0
|
||||
else:
|
||||
v = num(ab)
|
||||
if v is None: v = num(a.get('alt_geom'))
|
||||
alt = round(v * 0.3048, 1) if v is not None else None
|
||||
trk = num(a.get('track')); trk = num(a.get('true_heading')) if trk is None else trk
|
||||
gs = num(a.get('gs')); gs = round(gs * 0.514444, 1) if gs is not None else None
|
||||
vr = num(a.get('baro_rate')); vr = num(a.get('geom_rate')) if vr is None else vr
|
||||
vr = round(vr * 0.00508, 2) if vr is not None else None
|
||||
return [x, y, alt, trk, gs, vr, (a.get('flight') or '').strip(), a.get('t') or '', a.get('category') or '']
|
||||
def main():
|
||||
polls = {s: [] for s in SCENES}
|
||||
for i in range(3):
|
||||
t0 = time.time()
|
||||
for s in SCENES:
|
||||
url, t, ac = fetch(s); polls[s].append((url, t, ac)); print(i, s, len(ac), url, flush=True)
|
||||
if i < 2: time.sleep(max(0, 20 - (time.time() - t0)))
|
||||
summary = {}
|
||||
for s in SCENES:
|
||||
latest, tracks, src = {}, {}, set()
|
||||
tlast = 0
|
||||
for url, t, ac in polls[s]:
|
||||
if url: src.add(url.split('/')[2])
|
||||
tlast = max(tlast, t)
|
||||
for a in ac:
|
||||
hx = a.get('hex'); row = conv(s, a)
|
||||
if not hx or row is None: continue
|
||||
if (num(a.get('seen_pos')) or 0) > 60: continue
|
||||
latest[hx] = row
|
||||
tr = tracks.setdefault(hx, [])
|
||||
p = [row[0], row[1], row[2]]
|
||||
if not tr or tr[-1][:2] != p[:2]: tr.append(p)
|
||||
heli = [h for h, r in latest.items() if r[8] == 'A7' or r[7] in HELI_TYPES]
|
||||
out = {'t': int(tlast), 'source': sorted(src), 'radius_nm': RAD[s], 'hex': list(latest.keys()),
|
||||
'a': list(latest.values()), 'tracks': {h: v for h, v in tracks.items() if len(v) > 1}}
|
||||
json.dump(out, open(os.path.join(ROOT, 'city', s, 'air.json'), 'w'), separators=(',', ':'))
|
||||
summary[s] = {'aircraft': len(latest), 'with_track': len(out['tracks']), 'heli': len(heli),
|
||||
'heli_list': [[latest[h][6], latest[h][7], latest[h][8], latest[h][2]] for h in heli]}
|
||||
print(json.dumps(summary, indent=1))
|
||||
if __name__ == '__main__': main()
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Live MBTA vehicle positions -> city/boston/vehicles.json (square + 1 km)."""
|
||||
import json, os, sys, requests
|
||||
from collections import Counter
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import enu, inside
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
j = requests.get('https://cdn.mbta.com/realtime/VehiclePositions_enhanced.json', timeout=30).json()
|
||||
rows, total = [], 0
|
||||
for e in j['entity']:
|
||||
v = e.get('vehicle') or {}; p = v.get('position') or {}
|
||||
if 'latitude' not in p: continue
|
||||
total += 1
|
||||
x, y = enu('boston', p['latitude'], p['longitude'])
|
||||
if not inside('boston', x, y, 1000): continue
|
||||
rid = (v.get('trip') or {}).get('route_id', '')
|
||||
lab = (v.get('vehicle') or {}).get('label') or (v.get('vehicle') or {}).get('id', '')
|
||||
sp = p.get('speed'); rows.append([x, y, p.get('bearing'), round(sp, 2) if sp is not None else None, rid, lab])
|
||||
out = {'t': int(j['header']['timestamp']), 'source': 'cdn.mbta.com VehiclePositions_enhanced.json', 'v': rows}
|
||||
json.dump(out, open(os.path.join(ROOT, 'city', 'boston', 'vehicles.json'), 'w'), separators=(',', ':'))
|
||||
c = Counter(r[4].split('-')[0] if r[4].startswith(('Green', 'CR')) else ('bus' if r[4][:1].isdigit() or r[4][:2] in ('SL','CT') else r[4]) for r in rows)
|
||||
print('t', out['t'], 'feed total', total, 'in square+1km', len(rows), dict(c))
|
||||
@@ -0,0 +1,53 @@
|
||||
"""NYC DOT TMC cameras -> city/nyc/cameras.json + current stills for ~24 nearest cams."""
|
||||
import hashlib, io, json, os, sys, time, requests
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps
|
||||
from collections import Counter
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import enu, inside
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
OUT = os.path.join(ROOT, 'cctv', 'nyc'); DBG = os.path.join(os.path.dirname(__file__), 'nyc_rejects')
|
||||
os.makedirs(OUT, exist_ok=True); os.makedirs(DBG, exist_ok=True)
|
||||
H = {'User-Agent': 'gods-eye-view-intro/1.0'}
|
||||
cat = requests.get('https://webcams.nyctmc.org/api/cameras', headers=H, timeout=30).json()
|
||||
cams = []
|
||||
for c in cat:
|
||||
if str(c.get('isOnline')).lower() != 'true': continue
|
||||
try: lat, lon = float(c['latitude']), float(c['longitude'])
|
||||
except Exception: continue
|
||||
x, y = enu('nyc', lat, lon)
|
||||
if inside('nyc', x, y): cams.append((x, y, c['id'], c['name'].strip(), lat, lon))
|
||||
json.dump([[x, y, i, n] for x, y, i, n, _, _ in cams], open(os.path.join(ROOT, 'city', 'nyc', 'cameras.json'), 'w'), separators=(',', ':'))
|
||||
print('catalog', len(cat), 'online in square', len(cams))
|
||||
cams.sort(key=lambda c: c[0]**2 + c[1]**2)
|
||||
for f in os.listdir(OUT):
|
||||
if f.startswith('cam_') and f.endswith('.jpg'): os.remove(os.path.join(OUT, f))
|
||||
idx, rejects, hashes, raw_imgs = [], Counter(), Counter(), []
|
||||
fetched_utc = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
for x, y, cid, name, lat, lon in cams[:80]:
|
||||
if len(raw_imgs) >= 40: break
|
||||
try:
|
||||
r = requests.get(f'https://webcams.nyctmc.org/api/cameras/{cid}/image', headers=H, timeout=20)
|
||||
r.raise_for_status(); im = Image.open(io.BytesIO(r.content)).convert('RGB')
|
||||
except Exception as e:
|
||||
rejects['error'] += 1; continue
|
||||
h = hashlib.md5(r.content).hexdigest(); hashes[h] += 1
|
||||
g = np.asarray(im.convert('L'), dtype=np.float32)
|
||||
raw_imgs.append(dict(x=x, y=y, id=cid, name=name, lat=lat, lon=lon, im=im, h=h, mean=float(g.mean()), std=float(g.std()), size=im.size, n=len(r.content)))
|
||||
n = 0
|
||||
for d in raw_imgs:
|
||||
reason = None
|
||||
if hashes[d['h']] > 1: reason = 'duplicate/placeholder'
|
||||
elif d['mean'] < 14: reason = 'black'
|
||||
elif d['std'] < 10: reason = 'flat'
|
||||
elif d['size'][0] < 200: reason = 'tiny'
|
||||
if reason:
|
||||
rejects[reason] += 1; d['im'].save(os.path.join(DBG, f"{reason.split('/')[0]}_{d['id'][:8]}.jpg")); continue
|
||||
if n >= 24: break
|
||||
n += 1; fn = f'cam_{n:03d}.jpg'
|
||||
ImageOps.fit(d['im'], (640, 360), Image.LANCZOS).save(os.path.join(OUT, fn), quality=88)
|
||||
idx.append({'file': fn, 'id': d['id'], 'name': d['name'], 'x': d['x'], 'y': d['y'], 'lat': d['lat'], 'lon': d['lon'],
|
||||
'orig_size': list(d['size']), 'fetched_utc': fetched_utc, 'source': 'NYC DOT TMC webcams.nyctmc.org'})
|
||||
print(fn, d['name'], d['size'], round(d['mean']), round(d['std']), round((d['x']**2+d['y']**2)**.5))
|
||||
json.dump(idx, open(os.path.join(OUT, 'index.json'), 'w'), indent=1)
|
||||
print('saved', n, 'rejects', dict(rejects), 'tried', len(raw_imgs))
|
||||
@@ -3,12 +3,13 @@
|
||||
# tools/render-all.sh 1920 1080 16x9
|
||||
set -u
|
||||
W=$1; H=$2; TAG=$3
|
||||
CH=240; TOTAL=2160
|
||||
mkdir -p out/chunks_$TAG
|
||||
CH=240; TOTAL=${4:-2850}
|
||||
mkdir -p out/${PREFIX:-}chunks_$TAG
|
||||
for ((s=0; s<TOTAL; s+=CH)); do
|
||||
e=$((s+CH)); f=out/chunks_$TAG/c_$(printf %05d $s).mp4
|
||||
e=$((s+CH)); f=out/${PREFIX:-}chunks_$TAG/c_$(printf %05d $s).mp4
|
||||
[ -s "$f" ] && continue
|
||||
node tools/render.mjs --w $W --h $H --from $s --to $e --video "$f.tmp.mp4" --crf 14 --preset slow && mv "$f.tmp.mp4" "$f"
|
||||
e=$(( e > TOTAL ? TOTAL : e ))
|
||||
node tools/render.mjs --port ${PORT:-8766} --w $W --h $H --from $s --to $e --video "$f.tmp.mp4" --crf 14 --preset slow && mv "$f.tmp.mp4" "$f"
|
||||
done
|
||||
ls out/chunks_$TAG/c_*.mp4 | sed "s|^|file '$PWD/|; s|$|'|" > out/chunks_$TAG/list.txt
|
||||
ffmpeg -y -loglevel error -f concat -safe 0 -i out/chunks_$TAG/list.txt -c copy out/video_$TAG.mp4 && echo "CONCAT OK $TAG"
|
||||
ls out/${PREFIX:-}chunks_$TAG/c_*.mp4 | sed "s|^|file '$PWD/|; s|$|'|" > out/${PREFIX:-}chunks_$TAG/list.txt
|
||||
ffmpeg -y -loglevel error -f concat -safe 0 -i out/${PREFIX:-}chunks_$TAG/list.txt -c copy out/${PREFIX:-}video_$TAG.mp4 && echo "CONCAT OK $TAG"
|
||||
Reference in new issue
Block a user