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:
Claude committed 2026-10-19 01:10:39 +00:00
1 parent e9ddc37788
commit 02e535cb55
30 files changed
+8921 -1442

No files matched your search

+79
View File
@@ -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)
+107
View File
@@ -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)
+495
View File
@@ -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()
+288
View File
@@ -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')
+188
View File
@@ -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()
+571
View File
@@ -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
+3 -3
View File
@@ -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');
+23
View File
@@ -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
+175
View File
@@ -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
+205
View File
@@ -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",
}
+78
View File
@@ -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)
+32
View File
@@ -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"
}
]
+69
View File
@@ -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()
+132
View File
@@ -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])
+15
View File
@@ -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
+76
View File
@@ -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]))
+72
View File
@@ -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))
+7 -6
View File
@@ -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"