mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
feat(intro): v2 — cities, accelerating sweep, detections, thriller sound design
- City engine: Esri ground, OSM buildings that rise with the scan, road ribbons carrying modelled traffic coloured by congestion, NYC DOT cameras, real ADS-B, MBTA GTFS lines + realtime vehicles, LAX runways with approach strobes, Fort Bragg restricted volumes, Everest terrain with contours. - Indexing keeps accelerating into a 260-capture global sweep while the planet whips a full turn. - The eye opens: aperture blades, sensor array waking, optics calibration, radar sweep, orbiting coordinate ring. - Camera wall with YOLOv8n detections and a city-pulse trace. - Ends on GOD'S EYE VIEW / OMNISCIENCE. - audio/sfx.py: sound design only, built to sit under a music track. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KqP857pippaUs6konSDfe8
This commit is contained in:
30 files changed
+8921
-1442
No files matched your search
@@ -10,3 +10,13 @@ data/*.txt
|
||||
audio/*.wav
|
||||
tools/imagery/cache/
|
||||
__pycache__/
|
||||
|
||||
# fetch caches
|
||||
tools/*/cache/
|
||||
# city snapshots (Esri imagery, live feeds) and review/model artefacts
|
||||
/city/
|
||||
tools/flurry/review/
|
||||
tools/flurry/*.jpg
|
||||
tools/live/models/
|
||||
tools/live/*.jpg
|
||||
tools/*/check*.png
|
||||
+1386
-1354
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,527 @@
|
||||
#!/usr/bin/env python3
|
||||
"""God's Eye View — First Light (v2): sound design, built to sit under a music track.
|
||||
|
||||
No melody, no beeps. Sub, air, metal, impacts, swells, data texture — a tech
|
||||
thriller palette that leaves the mids for the score. Everything is synthesised
|
||||
and placed against audio/cues.json (exported from src/timeline.js).
|
||||
|
||||
python3 audio/sfx.py -> audio/sfx.wav (48 kHz, 24-bit stereo)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import numpy as np
|
||||
from scipy import signal
|
||||
from scipy.io import wavfile
|
||||
|
||||
SR = 48000
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
C = json.load(open(os.path.join(HERE, 'cues.json')))
|
||||
T = C['T']
|
||||
DUR = C['DURATION']
|
||||
N = int(SR * DUR)
|
||||
rng = np.random.default_rng(20261019)
|
||||
|
||||
B = {k: np.zeros((2, N)) for k in ('bed', 'hits', 'fx', 'ui', 'verb')}
|
||||
|
||||
|
||||
def place(dst, sig, t, gain=1.0, pan=0.0, verb=0.0):
|
||||
if sig.ndim == 1:
|
||||
p = (pan + 1) * np.pi / 4
|
||||
sig = np.vstack([sig * np.cos(p), sig * np.sin(p)])
|
||||
i0 = int(round(t * SR))
|
||||
if i0 >= N:
|
||||
return
|
||||
s0 = max(0, -i0)
|
||||
i0 = max(0, i0)
|
||||
n = min(sig.shape[1] - s0, N - i0)
|
||||
if n <= 0:
|
||||
return
|
||||
seg = sig[:, s0:s0 + n] * gain
|
||||
B[dst][:, i0:i0 + n] += seg
|
||||
if verb > 0:
|
||||
B['verb'][:, i0:i0 + n] += seg * verb
|
||||
|
||||
|
||||
def tt(d):
|
||||
return np.arange(int(d * SR)) / SR
|
||||
|
||||
|
||||
def lp(fc, o=2):
|
||||
return signal.butter(o, min(fc, SR * 0.45), 'low', fs=SR, output='sos')
|
||||
|
||||
|
||||
def hp(fc, o=2):
|
||||
return signal.butter(o, max(fc, 10), 'high', fs=SR, output='sos')
|
||||
|
||||
|
||||
def bp(lo, hi, o=2):
|
||||
lo = max(lo, 10)
|
||||
hi = min(max(hi, lo * 1.05), SR * 0.45)
|
||||
return signal.butter(o, [lo, hi], 'band', fs=SR, output='sos')
|
||||
|
||||
|
||||
def F(x, sos):
|
||||
return signal.sosfilt(sos, x, axis=-1)
|
||||
|
||||
|
||||
def sweep(x, fn, kind='low', block=256):
|
||||
y = np.zeros_like(x)
|
||||
zi = None
|
||||
for b in range(0, len(x), block):
|
||||
fc = float(fn(b / SR))
|
||||
sos = lp(fc) if kind == 'low' else hp(fc) if kind == 'high' else bp(fc * 0.7, fc * 1.35)
|
||||
if zi is None:
|
||||
zi = signal.sosfilt_zi(sos) * 0
|
||||
y[b:b + block], zi = signal.sosfilt(sos, x[b:b + block], zi=zi)
|
||||
return y
|
||||
|
||||
|
||||
def noise(d):
|
||||
return rng.standard_normal(int(d * SR))
|
||||
|
||||
|
||||
def env_exp(d, k, a=0.002):
|
||||
t = tt(d)
|
||||
return np.minimum(1, t / max(a, 1e-4)) * np.exp(-t * k)
|
||||
|
||||
|
||||
def env_ar(d, a, r, c=2.0):
|
||||
t = tt(d)
|
||||
return np.minimum(np.clip(t / max(a, 1e-4), 0, 1) ** c, np.clip((d - t) / max(r, 1e-4), 0, 1) ** c)
|
||||
|
||||
|
||||
def sine(f, d, ph=0.0):
|
||||
t = tt(d)
|
||||
if np.isscalar(f):
|
||||
return np.sin(2 * np.pi * f * t + ph)
|
||||
return np.sin(2 * np.pi * np.cumsum(f) / SR + ph)
|
||||
|
||||
|
||||
def saw(f, d):
|
||||
t = tt(d)
|
||||
ph = f * t if np.isscalar(f) else np.cumsum(f) / SR
|
||||
return 2 * (ph - np.floor(ph + 0.5))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- designed sounds
|
||||
def tick(d=0.006, f=5000, q=0.6):
|
||||
"""A dry, expensive-sounding UI tick: filtered noise, no pitch."""
|
||||
return F(noise(d) * env_exp(d, 1200, 0.0002), bp(f * (1 - q / 2), f * (1 + q)))
|
||||
|
||||
|
||||
def granule(f=6000):
|
||||
d = 0.012 + rng.random() * 0.02
|
||||
return F(noise(d) * env_ar(d, 0.002, d * 0.8), bp(f * 0.8, f * 1.25))
|
||||
|
||||
|
||||
def sub_boom(d=3.0, f0=55, f1=24, k=1.4):
|
||||
t = tt(d)
|
||||
f = f1 + (f0 - f1) * np.exp(-t * 7)
|
||||
return sine(f, d) * env_exp(d, k, 0.003)
|
||||
|
||||
|
||||
def metal(d=3.0, base=180.0, k=1.2):
|
||||
"""Inharmonic struck-metal resonance — the cinematic 'tail'."""
|
||||
t = tt(d)
|
||||
out = np.zeros(len(t))
|
||||
for r, a in ((1.0, 1.0), (2.76, 0.6), (5.4, 0.45), (8.93, 0.3), (13.3, 0.2), (1.51, 0.5)):
|
||||
out += a * np.sin(2 * np.pi * base * r * (1 + 0.002 * rng.standard_normal()) * t + rng.random() * 6) * np.exp(-t * k * (0.6 + r * 0.25))
|
||||
return out / 3.0
|
||||
|
||||
|
||||
def crack(d=0.4):
|
||||
return F(noise(d) * env_exp(d, 18, 0.0005), bp(400, 7000))
|
||||
|
||||
|
||||
def impact(size=1.0, dark=0.0, tail=3.5, base=150):
|
||||
"""Trailer hit: sub drop + body thud + noise crack + metallic tail."""
|
||||
d = tail + 0.5
|
||||
s = sub_boom(d, 62, 22, 1.1) * 1.0
|
||||
body = F(noise(0.5) * env_exp(0.5, 9, 0.001), lp(420)) * 0.9
|
||||
out = s
|
||||
out[: len(body)] += body
|
||||
c = crack(0.45) * (0.5 - dark * 0.35)
|
||||
out[: len(c)] += c
|
||||
m = metal(d, base * (1 - dark * 0.3), 1.0) * 0.28
|
||||
out += F(m, lp(4000 - dark * 2500))
|
||||
return np.tanh(out * 1.4 * size) * 0.8
|
||||
|
||||
|
||||
def braam(d=4.0, root=36.71, bright=1400, gain=1.0):
|
||||
"""Low, distorted brass-like swell — filtered and dirty, not synthy."""
|
||||
acc = np.zeros(int(d * SR))
|
||||
for mult in (1, 1.5, 2, 3):
|
||||
for det in (-0.01, 0, 0.012):
|
||||
acc += saw(root * mult * (1 + det), d)
|
||||
acc /= 10
|
||||
y = sweep(acc, lambda tc: 120 + bright * min(1, tc / 0.09) * np.exp(-max(0, tc - 0.09) * 1.3))
|
||||
y = np.tanh(y * 3.0) * env_ar(d, 0.015, d * 0.85, 1.2)
|
||||
return y * gain
|
||||
|
||||
|
||||
def whoosh(d=1.0, f0=300, f1=4000, peak=0.55, width=0.06):
|
||||
x = noise(d)
|
||||
y = sweep(x, lambda tc: f0 * (f1 / f0) ** min(1, tc / d), 'band')
|
||||
t0 = tt(d)
|
||||
return y * np.exp(-((t0 / d - peak) ** 2) / width)
|
||||
|
||||
|
||||
def swell(d=1.5, f0=200, f1=6000):
|
||||
"""Reverse-swell into a hit."""
|
||||
x = noise(d)
|
||||
y = sweep(x, lambda tc: f0 * (f1 / f0) ** ((tc / d) ** 1.8), 'band')
|
||||
t0 = tt(d)
|
||||
m = metal(d, 220, 0.2)[::-1] * 0.25
|
||||
return (y + m) * (t0 / d) ** 2.6
|
||||
|
||||
|
||||
def servo(d=0.6, f0=180, f1=420):
|
||||
t0 = tt(d)
|
||||
f = f0 + (f1 - f0) * np.sin(np.pi * t0 / d / 2)
|
||||
x = saw(f, d) + 0.4 * saw(f * 2.01, d)
|
||||
return F(x, bp(200, 2200)) * env_ar(d, 0.05, 0.12) * 0.5
|
||||
|
||||
|
||||
def glitch(d=0.25):
|
||||
"""Digital stutter: gated, bit-crushed noise and a buzzing carrier."""
|
||||
x = noise(d)
|
||||
gate = (np.sin(2 * np.pi * (35 + rng.random() * 40) * tt(d)) > 0).astype(float)
|
||||
crushed = np.round(x * 4) / 4
|
||||
buzz = np.sign(np.sin(2 * np.pi * (90 + rng.random() * 80) * tt(d))) * 0.3
|
||||
return F((crushed * 0.6 + buzz) * gate, bp(300, 9000)) * env_ar(d, 0.002, 0.03)
|
||||
|
||||
|
||||
def drone_bed(d, notes=(36.71, 55.0, 73.42), lpf=180):
|
||||
t0 = tt(d)
|
||||
x = sum(np.sin(2 * np.pi * f * (1 + 0.0008 * k) * t0 + k) * (0.6 / (k + 1)) for k, f in enumerate(notes))
|
||||
x += F(noise(d), lp(lpf)) * 0.25
|
||||
return x
|
||||
|
||||
|
||||
def tension(d, base=55.0):
|
||||
"""Dissonant cluster, heavily low-passed: dread, not tune."""
|
||||
t0 = tt(d)
|
||||
x = np.zeros(len(t0))
|
||||
for r in (1.0, 1.059, 1.5, 2.12, 2.0):
|
||||
x += saw(base * r * (1 + 0.003 * np.sin(2 * np.pi * 0.07 * t0 * r)), d)
|
||||
x = F(x / 5, lp(380))
|
||||
return x * (0.7 + 0.3 * np.sin(2 * np.pi * 0.11 * t0))
|
||||
|
||||
|
||||
def rotor(d, rate=5.6):
|
||||
t0 = tt(d)
|
||||
thump = (0.5 + 0.5 * np.sin(2 * np.pi * rate * t0)) ** 6
|
||||
x = F(noise(d), lp(220)) * thump * 1.6 + F(noise(d), bp(900, 3000)) * thump * 0.08
|
||||
return x
|
||||
|
||||
|
||||
def jet(d):
|
||||
t0 = tt(d)
|
||||
whine = sine(2400 + 120 * np.sin(2 * np.pi * 0.2 * t0), d) * 0.04 + sine(3600, d) * 0.015
|
||||
roar = F(noise(d), bp(120, 900)) * 0.6
|
||||
return roar + whine
|
||||
|
||||
|
||||
def wind(d):
|
||||
a = sweep(noise(d), lambda tc: 260 + 500 * (0.5 + 0.5 * np.sin(tc * 0.9)), 'band')
|
||||
b = sweep(noise(d), lambda tc: 300 + 600 * (0.5 + 0.5 * np.sin(tc * 0.7 + 1.3)), 'band')
|
||||
return np.vstack([a, b])
|
||||
|
||||
|
||||
def squelch(d=0.35):
|
||||
x = F(noise(d), bp(800, 4500)) * env_ar(d, 0.005, 0.06)
|
||||
return x
|
||||
|
||||
|
||||
def heartbeat(t, g=1.0):
|
||||
for dt, gg, f0 in ((0.0, 1.0, 64), (0.19, 0.55, 54)):
|
||||
d = 0.7
|
||||
t0 = tt(d)
|
||||
f = 30 + (f0 - 30) * np.exp(-t0 * 28)
|
||||
place('hits', sine(f, d) * env_exp(d, 7.5, 0.002), t + dt, g * gg, verb=0.2)
|
||||
|
||||
|
||||
# =================================================================== the film
|
||||
t_all = tt(DUR)
|
||||
# bed: sub + air, riding an intensity curve, silent through the cut
|
||||
inten = np.interp(t_all, [0, 4, 9, 16, 30, 43, 44, 72, 76.6, T['cut'] - 0.01, T['cut'], T['title'], T['title'] + 3, DUR - 2.5, DUR],
|
||||
[0, 0.25, 0.4, 0.55, 0.65, 0.7, 0.55, 0.65, 0.75, 1.0, 0, 0, 0.6, 0.5, 0])
|
||||
bed = drone_bed(DUR) * inten
|
||||
B['bed'] += np.vstack([bed, bed]) * 0.32
|
||||
airbed = np.vstack([F(noise(DUR), bp(2500, 11000)), F(noise(DUR), bp(2500, 11000))]) * 0.012 * inten
|
||||
B['bed'] += airbed
|
||||
# tension cluster under the indexing and the cities
|
||||
tz = tension(DUR) * np.interp(t_all, [16, 20, 30, 31, 43, 44, 70, 76, 84.4, 84.41], [0, 0.5, 0.6, 0.35, 0.45, 0.6, 0.7, 0.8, 1.0, 0], left=0, right=0)
|
||||
B['bed'] += np.vstack([tz, tz * 0.95]) * 0.22
|
||||
|
||||
# ACT 0 — console
|
||||
hum = (sine(100, DUR) * 0.25 + F(noise(DUR), bp(80, 300)) * 0.05) * np.interp(t_all, [0.4, 1.2, T['collapse'], T['seed']], [0, 1, 1, 0], left=0, right=0)
|
||||
place('fx', hum, 0, 0.12)
|
||||
for k in C['boot']:
|
||||
if k['ch'] != ' ':
|
||||
place('ui', tick(0.006, 4200 + rng.random() * 2500), k['t'], 0.16, pan=(rng.random() - 0.5) * 0.3)
|
||||
place('fx', swell(T['seed'] - T['collapse'] + 0.05, 300, 9000), T['collapse'], 0.35)
|
||||
place('fx', glitch(0.18), T['collapse'], 0.25)
|
||||
place('hits', impact(0.7, dark=0.6, tail=3.0, base=110), T['seed'], 0.8, verb=0.35)
|
||||
for bt in (T['beat1'], T['beat2']):
|
||||
heartbeat(bt, 1.1)
|
||||
# aperture: servos as the blades retract
|
||||
place('fx', servo(1.2, 140, 360), T['aperture'], 0.22, -0.3, verb=0.2)
|
||||
place('fx', servo(1.0, 160, 300), T['aperture'] + 0.25, 0.18, 0.3, verb=0.2)
|
||||
for i in range(120):
|
||||
place('ui', tick(0.004, 7000 if i % 10 else 4200), T['dishDraw'] + (i / 120) * (T['dishDone'] - T['dishDraw']), 0.05 if i % 10 else 0.1, pan=np.sin(i / 120 * 6.28) * 0.8)
|
||||
|
||||
# ACT 1 — growth: organic granular crackle densifying
|
||||
gs, ge = T['growStart'], T['growEnd']
|
||||
for _ in range(2400):
|
||||
x = rng.random() ** 0.45
|
||||
tc = gs + x * (ge - gs + 0.3)
|
||||
k = (np.exp(3 * (tc - gs) / (ge - gs)) - 1) / (np.exp(3) - 1)
|
||||
if rng.random() > 0.15 + 0.85 * k:
|
||||
continue
|
||||
place('fx', granule(2000 + rng.random() * 7000), tc, 0.05 + 0.08 * rng.random() * (0.4 + k), pan=rng.uniform(-0.9, 0.9))
|
||||
# radar sweep: a slow low whoosh per revolution
|
||||
sweep_period = 2 * np.pi / 2.4
|
||||
for i in range(int((T['substrate'] - gs) / sweep_period) + 1):
|
||||
place('fx', whoosh(sweep_period, 120, 900, 0.5, 0.12), gs + i * sweep_period, 0.08, pan=0.0)
|
||||
place('fx', swell(1.6, 120, 5000), T['substrate'] - 1.6, 0.3)
|
||||
place('hits', impact(0.9, 0.2, 4.0, 150), T['substrate'], 0.9, verb=0.35)
|
||||
place('hits', braam(3.5, 36.71, 1100), T['substrate'], 0.35, verb=0.3)
|
||||
|
||||
# ACT 2 — indexing: tiles as data grains, captures as shutter clicks
|
||||
for tl in C['tiles']:
|
||||
if not tl['vis'] or tl['t'] > 23.5:
|
||||
continue
|
||||
if tl['z'] == 5 and rng.random() > 0.3:
|
||||
continue
|
||||
place('ui', granule(3000 + rng.random() * 5000), tl['t'], {3: 0.07, 4: 0.04, 5: 0.025}[tl['z']], pan=tl['pan'] * 0.8)
|
||||
|
||||
|
||||
def shutter(t, g=1.0, pan=0.0):
|
||||
place('fx', tick(0.008, 3800, 0.6), t, 0.45 * g, pan)
|
||||
place('fx', tick(0.014, 2100, 0.6), t + 0.045, 0.35 * g, pan)
|
||||
place('fx', F(noise(0.05) * env_exp(0.05, 60), lp(500)), t, 0.25 * g, pan)
|
||||
|
||||
|
||||
for cp in C['caps']:
|
||||
if cp['slow']:
|
||||
shutter(cp['t'], 1.0, -0.3)
|
||||
place('fx', servo(0.9, 220, 900), cp['t'] + 0.3, 0.12, -0.3, verb=0.2)
|
||||
place('fx', whoosh(0.45, 900, 5000, 0.5), cp['t'] + 1.15, 0.16, 0.4)
|
||||
else:
|
||||
shutter(cp['t'], 0.5, 0.5)
|
||||
# the flurry: shutters dissolving into a roar of capture
|
||||
fl0, fl1 = T['flurry'], T['flurryEnd']
|
||||
for i in range(420):
|
||||
u = rng.random() ** 0.6
|
||||
tc = fl0 + u * (fl1 - fl0)
|
||||
shutter(tc, 0.18 + 0.25 * u, rng.uniform(-0.9, 0.9))
|
||||
sw_d = fl1 - fl0
|
||||
spin = whoosh(sw_d, 150, 3500, 0.62, 0.05)
|
||||
place('fx', np.vstack([spin, np.roll(spin, 900)]), fl0, 0.35)
|
||||
place('fx', swell(1.2, 200, 8000), T['indexed'] - 1.2, 0.35)
|
||||
place('hits', impact(1.1, 0.0, 4.5, 170), T['indexed'], 1.0, verb=0.4)
|
||||
place('hits', braam(4.0, 36.71, 1800), T['indexed'], 0.4, verb=0.3)
|
||||
|
||||
# ACT 3 — feeds: each a designed arrival, not a beep
|
||||
def feed(key, t):
|
||||
place('ui', tick(0.01, 3000), t, 0.25, -0.5)
|
||||
place('fx', whoosh(0.5, 2500, 400, 0.3), t - 0.05, 0.12, -0.4)
|
||||
if key == 'air':
|
||||
for _ in range(380):
|
||||
place('ui', tick(0.003, 6000 + rng.random() * 3000), t + rng.random() ** 0.7 * 1.6, 0.04 + rng.random() * 0.04, rng.uniform(-0.9, 0.9))
|
||||
place('fx', jet(1.6) * env_ar(1.6, 0.3, 1.0), t, 0.25, 0.3)
|
||||
elif key == 'sea':
|
||||
p = sine(820 * np.exp(-tt(2.5) * 0.06), 2.5) * env_exp(2.5, 2.0)
|
||||
place('fx', F(p, lp(2000)), t + 0.05, 0.12, 0.2, verb=0.9)
|
||||
place('fx', F(noise(2.0), lp(300)) * env_ar(2.0, 0.6, 1.2), t, 0.25)
|
||||
elif key == 'mil':
|
||||
place('fx', squelch(0.3), t + 0.05, 0.25, 0.4)
|
||||
place('fx', F(saw(48, 1.2), bp(60, 600)) * env_ar(1.2, 0.1, 0.8), t, 0.18)
|
||||
elif key == 'orbit':
|
||||
place('fx', whoosh(2.6, 2600, 160, 0.4, 0.1), t + 0.2, 0.3)
|
||||
place('hits', sub_boom(2.0, 48, 24, 2.0), t + 0.1, 0.5, verb=0.3)
|
||||
for _ in range(40):
|
||||
place('ui', granule(7000 + rng.random() * 4000), t + rng.random() * 2.0, 0.03, rng.uniform(-1, 1), verb=0.6)
|
||||
elif key == 'quake':
|
||||
for j, dt in enumerate((0.05, 0.45, 0.95)):
|
||||
place('hits', sub_boom(1.4, 50, 22, 3.0), t + dt, 0.6 - j * 0.12, verb=0.25)
|
||||
place('fx', F(noise(2.4), lp(90)) * env_ar(2.4, 0.2, 1.6), t, 1.0)
|
||||
elif key == 'fire':
|
||||
for _ in range(700):
|
||||
x = rng.random() ** 1.3 * 2.0
|
||||
place('fx', granule(700 + rng.random() * 4000), t + x, (1 - x / 2) * 0.1 * rng.random(), rng.uniform(-0.8, 0.8))
|
||||
place('fx', F(noise(2.0), bp(150, 1200)) * env_ar(2.0, 0.05, 1.5), t, 0.08)
|
||||
elif key == 'wind':
|
||||
place('fx', wind(2.6) * env_ar(2.6, 0.5, 1.5), t, 0.45)
|
||||
elif key == 'cable':
|
||||
buzz = F(saw(60, 1.5) + 0.5 * saw(120.4, 1.5), bp(300, 2500)) * env_ar(1.5, 0.02, 0.9)
|
||||
place('fx', buzz, t, 0.05, 0.2)
|
||||
place('fx', glitch(0.2), t, 0.12, -0.2)
|
||||
|
||||
|
||||
for fd in C['feeds']:
|
||||
feed(fd['key'], fd['t'])
|
||||
for t0, d in ((31.6, 1.8), (33.6, 2.2), (35.0, 2.2)):
|
||||
place('fx', whoosh(d, 200, 2400, 0.5), t0, 0.16, rng.uniform(-0.5, 0.5))
|
||||
|
||||
# ACT 4 — descent and the cities
|
||||
place('fx', swell(1.4, 120, 9000), T['terrain'] - 1.4, 0.45)
|
||||
place('fx', whoosh(1.2, 4000, 120, 0.7, 0.08), T['terrain'] - 1.0, 0.4)
|
||||
place('hits', impact(1.0, 0.4, 3.5, 120), T['terrain'], 0.85, verb=0.4)
|
||||
for c in C['cities']:
|
||||
t0, t1 = c['t0'], c['t1']
|
||||
d = t1 - t0
|
||||
if c['slug'] != 'everest':
|
||||
place('fx', glitch(0.22), t0 - 0.05, 0.35)
|
||||
place('hits', impact(0.6, 0.5, 2.5, 140), t0, 0.5, verb=0.3)
|
||||
place('fx', whoosh(0.6, 3000, 300, 0.25), t0 - 0.1, 0.25)
|
||||
amb = F(noise(d), bp(60, 400)) * env_ar(d, 0.4, 0.4) * 0.5
|
||||
place('fx', np.vstack([amb, np.roll(amb, 2000)]), t0, 0.25)
|
||||
if c['slug'] == 'everest':
|
||||
place('fx', wind(d) * env_ar(d, 0.6, 0.6), t0, 0.7)
|
||||
# LiDAR sweep: a soft rising tone per pass
|
||||
for i in range(int(d / (1 / 0.35 * 1.2 / 1.2)) + 1):
|
||||
place('fx', F(sine(np.linspace(300, 1200, int(1.2 * SR)), 1.2), bp(200, 1600)) * env_ar(1.2, 0.3, 0.6), t0 + i * 2.86, 0.03, verb=0.4)
|
||||
elif c['slug'] == 'nyc':
|
||||
traffic = F(noise(d), bp(80, 700)) * env_ar(d, 0.8, 0.5)
|
||||
place('fx', np.vstack([traffic, np.roll(traffic, 5000)]), t0, 0.22)
|
||||
place('fx', rotor(d) * env_ar(d, 1.0, 1.0), t0 + 0.2, 0.3, 0.4)
|
||||
for j in range(8):
|
||||
place('fx', tick(0.01, 2400), t0 + 2.2 + j * 0.35, 0.18, 0.7)
|
||||
place('fx', F(noise(0.1), bp(1500, 7000)) * env_exp(0.1, 25), t0 + 2.2 + j * 0.35, 0.05, 0.7)
|
||||
elif c['slug'] == 'boston':
|
||||
for j in range(4):
|
||||
place('fx', F(noise(1.4), bp(100, 900)) * env_ar(1.4, 0.4, 0.8) * (1 + 0.3 * np.sin(2 * np.pi * 9 * tt(1.4))), t0 + 0.6 + j * 1.0, 0.1, rng.uniform(-0.7, 0.7))
|
||||
elif c['slug'] == 'lax':
|
||||
place('fx', jet(d) * env_ar(d, 0.8, 0.8), t0, 0.35, -0.3)
|
||||
place('fx', whoosh(2.4, 200, 1400, 0.5, 0.1), t0 + 1.4, 0.3, 0.5)
|
||||
elif c['slug'] == 'bragg':
|
||||
place('fx', rotor(d, 4.6) * env_ar(d, 0.8, 0.8), t0 + 0.4, 0.25, -0.4)
|
||||
for j in range(3):
|
||||
place('fx', squelch(0.3), t0 + 0.8 + j * 1.3, 0.16, 0.5)
|
||||
|
||||
# the camera wall
|
||||
wt = T['cctvWall']
|
||||
place('hits', impact(0.9, 0.3, 3.0, 160), wt, 0.8, verb=0.3)
|
||||
place('hits', braam(3.0, 36.71, 1400), wt, 0.3, verb=0.3)
|
||||
for _ in range(100):
|
||||
tc = wt + rng.random() ** 0.8 * 0.85 + rng.random() * 0.12
|
||||
place('fx', tick(0.012, 2200 + rng.random() * 2000), tc, 0.14, rng.uniform(-0.95, 0.95))
|
||||
place('fx', F(noise(0.08), bp(1500, 8000)) * env_exp(0.08, 30), tc, 0.03, rng.uniform(-0.95, 0.95))
|
||||
wd = T['cctvOut'] - wt + 0.3
|
||||
place('fx', np.vstack([F(noise(wd), bp(800, 6000)), F(noise(wd), bp(800, 6000))]) * env_ar(wd, 0.4, 0.25), wt, 0.035)
|
||||
for j in range(int((T['cctvOut'] - wt - 0.8) * 7)):
|
||||
place('ui', tick(0.006, 5200), wt + 0.8 + j / 7, 0.06, rng.uniform(-0.8, 0.8))
|
||||
place('fx', whoosh(0.6, 5000, 150, 0.7), T['cctvOut'] - 0.1, 0.4)
|
||||
place('hits', impact(0.9, 0.5, 3.0, 120), T['cctvOut'] + 0.4, 0.7, verb=0.35)
|
||||
|
||||
# ACT 5 — saturation: rising pressure, the breach, the state
|
||||
sat0, cut = T['saturate'], T['cut']
|
||||
sd = cut - sat0
|
||||
t0s = tt(sd)
|
||||
pos = 0.45 * t0s + 0.06 * t0s ** 2
|
||||
sh = np.zeros(len(t0s))
|
||||
for k in range(9):
|
||||
o = (k + pos) % 9
|
||||
a = np.exp(-((o - 4.5) ** 2) / (2 * 1.8 ** 2))
|
||||
sh += np.sin(2 * np.pi * np.cumsum(30 * 2 ** o) / SR) * a
|
||||
sh = F(sh / 9 * 2, lp(2200)) * env_ar(sd, 2.0, 0.01, 1.3)
|
||||
place('fx', sh, sat0, 0.3, verb=0.2)
|
||||
place('fx', swell(sd, 120, 9000), sat0, 0.3)
|
||||
# pulse of low hits accelerating toward the breach
|
||||
tk = sat0 + 0.5
|
||||
gap = 0.9
|
||||
while tk < T['dishBreak'] - 0.2:
|
||||
place('hits', sub_boom(0.9, 58, 30, 4.0), tk, 0.35 + 0.35 * (tk - sat0) / sd)
|
||||
place('fx', F(noise(0.12), bp(500, 4000)) * env_exp(0.12, 30), tk, 0.08)
|
||||
tk += gap
|
||||
gap = max(0.22, gap * 0.86)
|
||||
for st in C['status']:
|
||||
for j in range(16):
|
||||
place('ui', tick(0.005, 4500 + rng.random() * 1500), st + j * 0.026, 0.1, 0.4)
|
||||
for _ in range(300):
|
||||
tc = T['twin'] + rng.random() ** 0.7 * (T['dishBreak'] - T['twin'])
|
||||
place('ui', granule(5000 + rng.random() * 5000), tc, 0.025, rng.uniform(-1, 1), verb=0.3)
|
||||
db = T['dishBreak']
|
||||
place('hits', impact(1.25, 0.0, 5.0, 190), db, 1.1, verb=0.45)
|
||||
place('hits', braam(3.0, 36.71, 2600), db, 0.45, verb=0.4)
|
||||
for _ in range(200):
|
||||
tc = db + rng.random() ** 2.2 * 1.1
|
||||
f = 2500 + rng.random() * 7000
|
||||
g = F(noise(0.25), bp(f * 0.97, f * 1.03)) * env_exp(0.25, 10 + rng.random() * 25)
|
||||
place('fx', g, tc, 0.16 * (1 - (tc - db) / 1.2), rng.uniform(-1, 1), verb=0.3)
|
||||
place('hits', impact(1.0, 0.3, 4.0, 110), T['omni'], 0.8, verb=0.45)
|
||||
|
||||
# ACT 6 — title: low bloom, the eye, the word
|
||||
ti = T['title']
|
||||
d = DUR - ti
|
||||
warm = drone_bed(d, (36.71, 55.0, 73.42, 110.0), 140) * np.interp(tt(d), [0, 0.8, 2.0, d - 3.0, d], [0, 0.4, 0.7, 0.6, 0])
|
||||
place('bed', warm, ti, 0.3)
|
||||
place('fx', swell(0.9, 400, 7000), T['eyeOpen'] - 0.9, 0.2)
|
||||
place('hits', impact(0.8, 0.6, 5.0, 90), T['eyeOpen'], 0.6, verb=0.5)
|
||||
place('fx', metal(6.0, 220, 0.5) * 0.4, T['eyeOpen'], 0.12, verb=0.7)
|
||||
for j in range(36):
|
||||
place('ui', tick(0.006, 3500 + rng.random() * 4000), T['wordmark'] + j * 0.024, 0.07, rng.uniform(-0.4, 0.4))
|
||||
place('hits', impact(1.0, 0.4, 6.5, 100), T['wordmark'] + 0.85, 0.8, verb=0.55)
|
||||
place('hits', braam(6.0, 36.71, 900), T['wordmark'] + 0.85, 0.35, verb=0.5)
|
||||
for j in range(11):
|
||||
place('ui', tick(0.008, 4000), T['tagline'] + j * 0.08, 0.1)
|
||||
place('fx', metal(6.0, 330, 0.45) * 0.35, T['tagline'] + 0.9, 0.1, verb=0.8)
|
||||
|
||||
# =================================================================== reverb, mix, master
|
||||
def ir(rt=3.6, pre=0.03):
|
||||
n = int((rt + pre) * SR)
|
||||
t0 = np.arange(n) / SR
|
||||
out = np.zeros((2, n))
|
||||
for c in range(2):
|
||||
x = rng.standard_normal(n) * np.exp(-6.9 * t0 / rt)
|
||||
l2 = F(x, lp(2200))
|
||||
m = np.clip(t0 / 1.2, 0, 1)
|
||||
out[c] = x * (1 - m) + l2 * m
|
||||
out[c, : int(pre * SR)] = 0
|
||||
return out / np.sqrt(np.sum(out ** 2) / 2)
|
||||
|
||||
|
||||
R = ir()
|
||||
wet = np.vstack([signal.fftconvolve(B['verb'][c], R[c])[:N] for c in range(2)])
|
||||
dip = np.ones(N)
|
||||
for ht, depth in ((T['substrate'], 0.3), (T['indexed'], 0.25), (T['terrain'], 0.3), (T['cctvWall'], 0.4), (T['dishBreak'], 0.2), (T['wordmark'] + 0.85, 0.4)):
|
||||
a, b, c = int((ht - 0.28) * SR), int((ht - 0.03) * SR), int((ht + 0.5) * SR)
|
||||
dip[a:b] = np.minimum(dip[a:b], np.linspace(1, depth, b - a))
|
||||
dip[b:c] = np.minimum(dip[b:c], np.linspace(depth, 1, c - b) ** 0.7)
|
||||
mix = (B['bed'] + B['fx'] * 0.9 + B['ui'] * 0.8) * dip + B['hits'] * 1.2 + wet * 0.3
|
||||
mix = F(mix, hp(22))
|
||||
|
||||
|
||||
def stutter(x, a, b, sl=0.04):
|
||||
ia, ib, s = int(a * SR), int(b * SR), int(sl * SR)
|
||||
src = x[:, ia:ia + s].copy()
|
||||
for i in range(ia, ib, s):
|
||||
n = min(s, ib - i)
|
||||
x[:, i:i + n] = src[:, :n] * (0.9 if ((i - ia) // s) % 2 == 0 else 0.55)
|
||||
return x
|
||||
|
||||
|
||||
mix = stutter(mix, cut - 0.22, cut)
|
||||
ic, it = int(cut * SR), int(T['title'] * SR)
|
||||
f = int(0.004 * SR)
|
||||
mix[:, ic - f:ic] *= np.linspace(1, 0, f)
|
||||
mix[:, ic:it] = 0
|
||||
|
||||
# gentle glue + ceiling
|
||||
p = np.sqrt(np.convolve(np.mean(mix ** 2, axis=0), np.ones(4000) / 4000, mode='same') + 1e-12)
|
||||
thr = 10 ** (-16 / 20)
|
||||
g = np.where(p > thr, (thr * (p / thr) ** (1 / 1.8)) / p, 1.0)
|
||||
g = signal.sosfiltfilt(lp(8), g)
|
||||
mix *= g
|
||||
mix = np.tanh(mix * 1.5) / 1.5
|
||||
look = int(0.004 * SR)
|
||||
pk = np.lib.stride_tricks.sliding_window_view(np.pad(np.max(np.abs(mix), axis=0), (0, look)), look + 1).max(axis=1)[:N]
|
||||
ceil = 10 ** (-1.2 / 20)
|
||||
lg = signal.sosfiltfilt(lp(60), np.minimum(1.0, ceil / np.maximum(pk, 1e-9)))
|
||||
mix *= np.minimum(lg, 1.0)
|
||||
mix *= ceil / max(1e-9, np.max(np.abs(mix)))
|
||||
out = os.path.join(HERE, 'sfx.wav')
|
||||
wavfile.write(out, SR, (mix.T * (2 ** 31 - 1)).astype(np.int32))
|
||||
print('wrote', out, DUR, 's')
|
||||
@@ -0,0 +1,915 @@
|
||||
import * as THREE from 'three';
|
||||
import { mulberry32, clamp } from './timeline.js';
|
||||
|
||||
// City scenes in a local ENU frame (data in metres, scene units in km;
|
||||
// three.js x = east, y = up, z = -north). One engine renders every location:
|
||||
// Esri ground, OSM buildings that rise as the scan passes, road ribbons that
|
||||
// carry modelled traffic coloured by congestion, cameras, aircraft, transit,
|
||||
// airports, restricted areas and terrain.
|
||||
|
||||
const KM = 0.001;
|
||||
|
||||
async function getJSON(p) {
|
||||
try {
|
||||
const r = await fetch(p);
|
||||
return r.ok ? await r.json() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function getBin(p) {
|
||||
try {
|
||||
const r = await fetch(p);
|
||||
return r.ok ? await r.arrayBuffer() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function loadTex(p) {
|
||||
return new Promise((res) =>
|
||||
new THREE.TextureLoader().load(
|
||||
p,
|
||||
(t) => {
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
t.anisotropy = 8;
|
||||
res(t);
|
||||
},
|
||||
undefined,
|
||||
() => res(null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadCity(slug) {
|
||||
const base = `city/${slug}/`;
|
||||
const [meta, buildings, roads, extra, cameras, air, transit, vehicles, aeroway, military, peaks, elevMeta, elev, ground] = await Promise.all([
|
||||
getJSON(base + 'meta.json'),
|
||||
getJSON(base + 'buildings.json'),
|
||||
getJSON(base + 'roads.json'),
|
||||
getJSON(base + 'extra.json'),
|
||||
getJSON(base + 'cameras.json'),
|
||||
getJSON(base + 'air.json'),
|
||||
getJSON(base + 'transit.json'),
|
||||
getJSON(base + 'vehicles.json'),
|
||||
getJSON(base + 'aeroway.json'),
|
||||
getJSON(base + 'military.json'),
|
||||
getJSON(base + 'peaks.json'),
|
||||
getJSON(base + 'elev_meta.json'),
|
||||
getBin(base + 'elev.f32'),
|
||||
loadTex(base + 'ground.jpg'),
|
||||
]);
|
||||
return { slug, meta, buildings, roads, extra, cameras, air, transit, vehicles, aeroway, military, peaks, elevMeta, elev: elev ? new Float32Array(elev) : null, ground };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shared shader bits
|
||||
const HASH = /* glsl */ `
|
||||
float h11(float n) { return fract(sin(n * 127.1) * 43758.5453); }
|
||||
float h21(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
||||
`;
|
||||
|
||||
function congestionColor() {
|
||||
return /* glsl */ `
|
||||
vec3 congCol(float c) {
|
||||
vec3 g = vec3(0.10, 1.00, 0.45);
|
||||
vec3 y = vec3(1.00, 0.80, 0.10);
|
||||
vec3 r = vec3(1.00, 0.16, 0.12);
|
||||
return c < 0.5 ? mix(g, y, c * 2.0) : mix(y, r, (c - 0.5) * 2.0);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ground
|
||||
function makeGround(d, half, elevGeo) {
|
||||
const size = 2 * half * KM;
|
||||
const geo = elevGeo || new THREE.PlaneGeometry(size, size, 1, 1).rotateX(-Math.PI / 2);
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uTex: { value: d.ground },
|
||||
uReveal: { value: 0 },
|
||||
uT: { value: 0 },
|
||||
uHalf: { value: half * KM },
|
||||
uNight: { value: 0.55 },
|
||||
uTerrain: { value: elevGeo ? 1 : 0 },
|
||||
uScan: { value: -10 },
|
||||
uFade: { value: 1 },
|
||||
},
|
||||
vertexShader: /* glsl */ `
|
||||
attribute float aElev;
|
||||
varying vec2 vUv;
|
||||
varying vec3 vP;
|
||||
varying float vElev;
|
||||
varying vec3 vN;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vP = position;
|
||||
vElev = aElev;
|
||||
vN = normal;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform sampler2D uTex;
|
||||
uniform float uReveal, uT, uHalf, uNight, uTerrain, uScan, uFade;
|
||||
varying vec2 vUv;
|
||||
varying vec3 vP;
|
||||
varying float vElev;
|
||||
varying vec3 vN;
|
||||
void main() {
|
||||
vec3 img = texture2D(uTex, vUv).rgb;
|
||||
float l = dot(img, vec3(0.3, 0.59, 0.11));
|
||||
// night grade: cool, dim, contrasty — real pixels, but the city's lights do the talking
|
||||
vec3 night = pow(mix(vec3(l), img, 0.35), vec3(1.7)) * vec3(0.5, 0.7, 0.95) * 0.56;
|
||||
float r = length(vP.xz) / uHalf;
|
||||
float rev = smoothstep(uReveal, uReveal - 0.08, r);
|
||||
// tactical grid: 100 m, brighter every km
|
||||
vec2 g = vP.xz * 1000.0;
|
||||
vec2 fw = fwidth(g);
|
||||
vec2 gl = abs(fract(g / 100.0 - 0.5) - 0.5) * 100.0 / fw;
|
||||
float grid = 1.0 - smoothstep(0.0, 1.0, min(gl.x, gl.y));
|
||||
vec2 gk = abs(fract(g / 1000.0 - 0.5) - 0.5) * 1000.0 / fw;
|
||||
float gridK = 1.0 - smoothstep(0.0, 1.3, min(gk.x, gk.y));
|
||||
vec3 col = night * rev;
|
||||
col += vec3(0.0, 0.55, 0.7) * (grid * 0.05 + gridK * 0.14) * (0.4 + 0.6 * rev);
|
||||
// the scan front
|
||||
float ring = exp(-pow((r - uReveal) * 60.0, 2.0));
|
||||
col += vec3(0.2, 0.9, 1.0) * ring * 0.6;
|
||||
if (uTerrain > 0.5) {
|
||||
// terrain: sunlit relief, 100 m contours, a LiDAR line sweeping across
|
||||
vec3 L = normalize(vec3(-0.5, 0.7, 0.4));
|
||||
float sh = clamp(dot(normalize(vN), L), 0.0, 1.0);
|
||||
vec3 tg = pow(mix(vec3(l), img, 0.5), vec3(1.35));
|
||||
col = tg * (0.08 + 0.62 * sh) * vec3(0.62, 0.78, 1.0) * rev;
|
||||
float ce = vElev / 100.0;
|
||||
float cw = fwidth(ce);
|
||||
float cont = 1.0 - smoothstep(0.0, 1.2 * cw, abs(fract(ce - 0.5) - 0.5));
|
||||
float major = 1.0 - smoothstep(0.0, 1.2 * fwidth(vElev / 500.0), abs(fract(vElev / 500.0 - 0.5) - 0.5));
|
||||
col += vec3(0.1, 0.8, 1.0) * (cont * 0.16 + major * 0.5) * rev;
|
||||
float sweep = exp(-pow((vP.x - uScan) * 18.0, 2.0));
|
||||
col += vec3(0.4, 1.0, 1.0) * sweep * 0.9;
|
||||
col += vec3(0.0, 0.6, 0.8) * smoothstep(uScan, uScan - 3.0, vP.x) * 0.03 * step(vP.x, uScan);
|
||||
}
|
||||
float edge = smoothstep(1.0, 0.82, max(abs(vP.x), abs(vP.z)) / uHalf);
|
||||
gl_FragColor = vec4(col * edge * uFade, 1.0);
|
||||
}
|
||||
`,
|
||||
});
|
||||
// the ground never writes depth: it is the base layer, and road ribbons a
|
||||
// metre above it would otherwise lose the depth test at city distances
|
||||
if (!elevGeo) mat.depthWrite = false;
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.renderOrder = -1;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
function makeTerrainGeo(d, half) {
|
||||
const n = d.elevMeta?.size || 1024;
|
||||
const seg = 384;
|
||||
const size = 2 * half * KM;
|
||||
const geo = new THREE.PlaneGeometry(size, size, seg, seg).rotateX(-Math.PI / 2);
|
||||
const pos = geo.getAttribute('position');
|
||||
const uv = geo.getAttribute('uv');
|
||||
const el = new Float32Array(pos.count);
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
const u = uv.getX(i);
|
||||
const v = uv.getY(i);
|
||||
const px = Math.min(n - 1, Math.max(0, Math.round(u * (n - 1))));
|
||||
const py = Math.min(n - 1, Math.max(0, Math.round((1 - v) * (n - 1))));
|
||||
const h = d.elev[py * n + px];
|
||||
el[i] = h;
|
||||
pos.setY(i, h * KM);
|
||||
}
|
||||
geo.setAttribute('aElev', new THREE.BufferAttribute(el, 1));
|
||||
geo.computeVertexNormals();
|
||||
return geo;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- buildings
|
||||
function makeBuildings(list) {
|
||||
const P = [];
|
||||
const UV = [];
|
||||
const A = []; // height m, edge length m, dist km, rand
|
||||
const idx = [];
|
||||
let vi = 0;
|
||||
const rnd = mulberry32(5);
|
||||
const rings = [];
|
||||
for (const b of list) {
|
||||
const h = Math.max(4, Math.min(560, b[0] || 10));
|
||||
const pts = [];
|
||||
for (let i = 2; i < b.length - 1; i += 2) pts.push([b[i], b[i + 1]]);
|
||||
if (pts.length < 3) continue;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (const p of pts) {
|
||||
cx += p[0];
|
||||
cy += p[1];
|
||||
}
|
||||
cx /= pts.length;
|
||||
cy /= pts.length;
|
||||
const dist = Math.hypot(cx, cy) * KM;
|
||||
const r = rnd();
|
||||
// walls
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const [x1, y1] = pts[i];
|
||||
const [x2, y2] = pts[(i + 1) % pts.length];
|
||||
const L = Math.hypot(x2 - x1, y2 - y1);
|
||||
if (L < 0.3) continue;
|
||||
P.push(x1 * KM, 0, -y1 * KM, x2 * KM, 0, -y2 * KM, x2 * KM, h * KM, -y2 * KM, x1 * KM, h * KM, -y1 * KM);
|
||||
UV.push(0, 0, 1, 0, 1, 1, 0, 1);
|
||||
for (let k = 0; k < 4; k++) A.push(h, L, dist, r);
|
||||
idx.push(vi, vi + 1, vi + 2, vi, vi + 2, vi + 3);
|
||||
vi += 4;
|
||||
}
|
||||
// roof
|
||||
const contour = pts.map((p) => new THREE.Vector2(p[0], p[1]));
|
||||
let tris = [];
|
||||
try {
|
||||
tris = THREE.ShapeUtils.triangulateShape(contour, []);
|
||||
} catch {
|
||||
tris = [];
|
||||
}
|
||||
const base = vi;
|
||||
for (const p of pts) {
|
||||
P.push(p[0] * KM, h * KM, -p[1] * KM);
|
||||
UV.push(-1, 1); // u < 0 marks roof
|
||||
A.push(h, 0, dist, r);
|
||||
vi++;
|
||||
}
|
||||
for (const t of tris) idx.push(base + t[0], base + t[2], base + t[1]);
|
||||
rings.push({ pts, h, dist });
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(P, 3));
|
||||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(UV, 2));
|
||||
geo.setAttribute('aB', new THREE.Float32BufferAttribute(A, 4));
|
||||
geo.setIndex(vi > 65535 ? new THREE.Uint32BufferAttribute(idx, 1) : new THREE.Uint16BufferAttribute(idx, 1));
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
uniforms: { uReveal: { value: 0 }, uT: { value: 0 }, uAlpha: { value: 1 }, uTint: { value: new THREE.Color('#38e8ff') } },
|
||||
vertexShader: /* glsl */ `
|
||||
attribute vec4 aB;
|
||||
uniform float uReveal;
|
||||
varying vec2 vUv;
|
||||
varying vec4 vB;
|
||||
varying vec3 vW;
|
||||
varying float vRise;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vB = aB;
|
||||
float k = clamp((uReveal - aB.z) / 0.35, 0.0, 1.0);
|
||||
float rise = 1.0 - pow(1.0 - k, 3.0);
|
||||
vRise = k;
|
||||
vec3 p = position;
|
||||
p.y *= rise;
|
||||
vec4 wp = modelMatrix * vec4(p, 1.0);
|
||||
vW = wp.xyz;
|
||||
gl_Position = projectionMatrix * viewMatrix * wp;
|
||||
}
|
||||
`,
|
||||
fragmentShader: /* glsl */ `
|
||||
${HASH}
|
||||
uniform float uReveal, uT, uAlpha;
|
||||
uniform vec3 uTint;
|
||||
varying vec2 vUv;
|
||||
varying vec4 vB;
|
||||
varying vec3 vW;
|
||||
varying float vRise;
|
||||
void main() {
|
||||
if (vRise <= 0.0) discard;
|
||||
float h = vB.x;
|
||||
float L = vB.y;
|
||||
bool roof = vUv.x < -0.5;
|
||||
vec3 N = normalize(cross(dFdx(vW), dFdy(vW)));
|
||||
float lit = 0.35 + 0.65 * abs(dot(N, normalize(vec3(0.4, 0.8, 0.3))));
|
||||
float tall = smoothstep(30.0, 250.0, h);
|
||||
vec3 base = mix(vec3(0.010, 0.016, 0.026), vec3(0.016, 0.032, 0.05), tall) * lit;
|
||||
vec3 col = base;
|
||||
float fresh = exp(-max(uReveal - vB.z, 0.0) * 5.0);
|
||||
if (roof) {
|
||||
col = base * 1.3 + uTint * 0.02;
|
||||
} else {
|
||||
// floors and windows
|
||||
float fy = vUv.y * h / 3.4;
|
||||
float fx = vUv.x * L / 3.2;
|
||||
vec2 cell = floor(vec2(fx, fy));
|
||||
vec2 f = fract(vec2(fx, fy));
|
||||
float win = step(0.25, f.x) * step(f.x, 0.8) * step(0.3, f.y) * step(f.y, 0.75);
|
||||
float on = step(0.9, h21(cell + vB.w * 91.0));
|
||||
float warm = h21(cell * 1.7 + 3.0);
|
||||
vec3 wc = mix(vec3(1.0, 0.75, 0.45), vec3(0.6, 0.9, 1.0), step(0.7, warm));
|
||||
float flick = 0.85 + 0.15 * sin(uT * 2.0 + h21(cell) * 40.0);
|
||||
col += wc * win * on * 0.32 * flick * smoothstep(0.0, 0.1, vRise);
|
||||
// edges: crown line, vertical corners, base
|
||||
float wy = fwidth(vUv.y);
|
||||
float wx = fwidth(vUv.x);
|
||||
float crown = 1.0 - smoothstep(0.0, wy * 1.5, 1.0 - vUv.y);
|
||||
float corner = 1.0 - smoothstep(0.0, wx * 1.5, min(vUv.x, 1.0 - vUv.x));
|
||||
col += uTint * (crown * (0.08 + tall * 0.9) + corner * (0.03 + tall * 0.12)) * (0.6 + fresh * 2.5);
|
||||
// the scan passing up the facade as it rises
|
||||
col += uTint * fresh * 0.25 * smoothstep(0.2, 1.0, vUv.y);
|
||||
}
|
||||
gl_FragColor = vec4(col * uAlpha, 1.0);
|
||||
}
|
||||
`,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.frustumCulled = false;
|
||||
return { mesh, count: rings.length, rings };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- roads + traffic
|
||||
// Congestion is modelled, not measured: road class, distance to the core, and
|
||||
// a slow per-road noise. Labelled MODELED in the HUD.
|
||||
function makeRoads(list, half, { tint = null, inside = null, scale = 1 } = {}) {
|
||||
const W = [34, 24, 20, 16, 11, 7];
|
||||
const P = [];
|
||||
const A = []; // arc m, side, cong, oneway
|
||||
const B = []; // class, rnd, width
|
||||
const idx = [];
|
||||
let vi = 0;
|
||||
const rnd = mulberry32(17);
|
||||
let segs = 0;
|
||||
for (const r of list) {
|
||||
const cls = r[0];
|
||||
const one = r[1] ? 1 : 0;
|
||||
const pts = [];
|
||||
for (let i = 3; i < r.length - 1; i += 2) pts.push([r[i], r[i + 1]]);
|
||||
if (pts.length < 2) continue;
|
||||
if (inside && !inside(pts[0][0], pts[0][1])) continue;
|
||||
const w = W[cls] ?? 6;
|
||||
const cx = pts[Math.floor(pts.length / 2)];
|
||||
const core = Math.exp(-Math.hypot(cx[0], cx[1]) / (half * 0.55));
|
||||
const base = [0.55, 0.5, 0.42, 0.35, 0.22, 0.3][cls] ?? 0.3;
|
||||
const rr = rnd();
|
||||
const cong = clamp(base * 0.55 + core * 0.55 + (rr - 0.5) * 0.95, 0, 1);
|
||||
let arc = 0;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const [x1, y1] = pts[i];
|
||||
const [x2, y2] = pts[i + 1];
|
||||
const L = Math.hypot(x2 - x1, y2 - y1);
|
||||
if (L < 0.5) continue;
|
||||
const nx = -(y2 - y1) / L;
|
||||
const ny = (x2 - x1) / L;
|
||||
const hw = w / 2;
|
||||
const y = 0.6 + cls * 0.02;
|
||||
const v = [
|
||||
[x1 - nx * hw, y1 - ny * hw, -1, arc],
|
||||
[x1 + nx * hw, y1 + ny * hw, 1, arc],
|
||||
[x2 + nx * hw, y2 + ny * hw, 1, arc + L],
|
||||
[x2 - nx * hw, y2 - ny * hw, -1, arc + L],
|
||||
];
|
||||
for (const [x, yy, s, a] of v) {
|
||||
P.push(x * KM, y * KM, -yy * KM);
|
||||
A.push(a, s, cong, one);
|
||||
B.push(cls, rr, w);
|
||||
}
|
||||
idx.push(vi, vi + 1, vi + 2, vi, vi + 2, vi + 3);
|
||||
vi += 4;
|
||||
arc += L;
|
||||
segs++;
|
||||
}
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(P, 3));
|
||||
geo.setAttribute('aA', new THREE.Float32BufferAttribute(A, 4));
|
||||
geo.setAttribute('aC', new THREE.Float32BufferAttribute(B, 3));
|
||||
geo.setIndex(new THREE.Uint32BufferAttribute(idx, 1));
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
uniforms: {
|
||||
uT: { value: 0 },
|
||||
uReveal: { value: 0 },
|
||||
uHalf: { value: half * KM },
|
||||
uAlpha: { value: 1 },
|
||||
uTint: { value: tint ? new THREE.Color(tint) : new THREE.Color(0, 0, 0) },
|
||||
uUseTint: { value: tint ? 1 : 0 },
|
||||
uScale: { value: scale },
|
||||
},
|
||||
vertexShader: /* glsl */ `
|
||||
attribute vec4 aA;
|
||||
attribute vec3 aC;
|
||||
varying vec4 vA;
|
||||
varying vec3 vC;
|
||||
varying float vR;
|
||||
void main() {
|
||||
vA = aA;
|
||||
vC = aC;
|
||||
vR = length(position.xz);
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: /* glsl */ `
|
||||
${HASH}
|
||||
${congestionColor()}
|
||||
uniform float uT, uReveal, uHalf, uAlpha, uUseTint, uScale;
|
||||
uniform vec3 uTint;
|
||||
varying vec4 vA;
|
||||
varying vec3 vC;
|
||||
varying float vR;
|
||||
void main() {
|
||||
float r = vR / uHalf;
|
||||
float k = uReveal - r;
|
||||
if (k < 0.0) discard;
|
||||
float cong = vA.z;
|
||||
float side = vA.y;
|
||||
float dir = vA.w > 0.5 ? 1.0 : (side > 0.0 ? 1.0 : -1.0);
|
||||
vec3 cc = mix(congCol(cong), uTint, uUseTint);
|
||||
// ribbon cross-profile: soft edges, dark median
|
||||
float a = abs(side);
|
||||
float body = smoothstep(1.0, 0.7, a);
|
||||
// cars: dashes flowing along each lane, slower and denser when jammed
|
||||
float speed = mix(16.0, 2.5, cong) * 7.0 * uScale;
|
||||
float spacing = mix(70.0, 14.0, cong);
|
||||
float lane = vA.w > 0.5 ? 1.0 : smoothstep(0.05, 0.3, a) * smoothstep(0.98, 0.7, a);
|
||||
float ph = (vA.x - dir * uT * speed) / spacing + vC.y * 13.0 + (side > 0.0 ? 0.37 : 0.0);
|
||||
float fp = fract(ph);
|
||||
float car = smoothstep(0.0, 0.04, fp) * (1.0 - smoothstep(0.08, 0.22, fp));
|
||||
float present = step(0.25 + 0.5 * (1.0 - cong), h11(floor(ph) + vC.y * 77.0));
|
||||
float head = car * present * lane;
|
||||
vec3 carCol = dir > 0.0 ? vec3(1.0, 0.95, 0.85) : vec3(1.0, 0.25, 0.2);
|
||||
vec3 col = cc * body * (0.2 + 0.12 * cong) + mix(cc, carCol, 0.22) * head * 1.25;
|
||||
float fresh = exp(-k * 25.0);
|
||||
col += cc * fresh * 0.8 * body;
|
||||
gl_FragColor = vec4(col * uAlpha, 1.0);
|
||||
}
|
||||
`,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.frustumCulled = false;
|
||||
return { mesh, segs };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- lines (generic, glowing)
|
||||
function makePolylines(lines, { color = '#38e8ff', y = 1, width = 0, alpha = 1, colors = null } = {}) {
|
||||
const P = [];
|
||||
const C = [];
|
||||
const A = [];
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
const l = lines[li];
|
||||
const col = new THREE.Color(colors ? colors[li] : color);
|
||||
let arc = 0;
|
||||
for (let i = 0; i < l.length - 1; i++) {
|
||||
const [x1, y1, z1 = y] = l[i];
|
||||
const [x2, y2, z2 = y] = l[i + 1];
|
||||
const L = Math.hypot(x2 - x1, y2 - y1);
|
||||
P.push(x1 * KM, z1 * KM, -y1 * KM, x2 * KM, z2 * KM, -y2 * KM);
|
||||
C.push(col.r, col.g, col.b, col.r, col.g, col.b);
|
||||
A.push(arc, arc + L);
|
||||
arc += L;
|
||||
}
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(P, 3));
|
||||
geo.setAttribute('color', new THREE.Float32BufferAttribute(C, 3));
|
||||
geo.setAttribute('aArc', new THREE.Float32BufferAttribute(A, 1));
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
vertexColors: true,
|
||||
uniforms: { uT: { value: 0 }, uAlpha: { value: alpha }, uDraw: { value: 1e9 }, uPulse: { value: 0 } },
|
||||
vertexShader: /* glsl */ `
|
||||
attribute float aArc;
|
||||
varying float vArc;
|
||||
varying vec3 vCol;
|
||||
void main() { vArc = aArc; vCol = color; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
|
||||
`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform float uT, uAlpha, uDraw, uPulse;
|
||||
varying float vArc;
|
||||
varying vec3 vCol;
|
||||
void main() {
|
||||
if (vArc > uDraw) discard;
|
||||
float p = uPulse > 0.0 ? pow(fract(vArc / 900.0 - uT * 0.8), 8.0) * 2.0 : 0.0;
|
||||
gl_FragColor = vec4(vCol * (0.7 + p) * uAlpha, 1.0);
|
||||
}
|
||||
`,
|
||||
});
|
||||
return new THREE.LineSegments(geo, mat);
|
||||
}
|
||||
|
||||
// glowing point sprites updated from the CPU
|
||||
function makePoints(n, { size = 6, color = '#ffffff' } = {}) {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const pos = new Float32Array(n * 3);
|
||||
const col = new Float32Array(n * 3);
|
||||
const sz = new Float32Array(n).fill(1);
|
||||
const c = new THREE.Color(color);
|
||||
for (let i = 0; i < n; i++) col.set([c.r, c.g, c.b], i * 3);
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
|
||||
geo.setAttribute('aS', new THREE.BufferAttribute(sz, 1));
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
vertexColors: true,
|
||||
uniforms: { uSize: { value: size }, uAlpha: { value: 1 }, uPx: { value: 1 } },
|
||||
vertexShader: /* glsl */ `
|
||||
attribute float aS;
|
||||
uniform float uSize, uPx;
|
||||
varying vec3 vCol;
|
||||
void main() { vCol = color; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); gl_PointSize = uSize * aS * uPx; }
|
||||
`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform float uAlpha;
|
||||
varying vec3 vCol;
|
||||
void main() {
|
||||
float r = length(gl_PointCoord - 0.5) * 2.0;
|
||||
float a = smoothstep(1.0, 0.0, r);
|
||||
float core = smoothstep(0.35, 0.0, r);
|
||||
gl_FragColor = vec4((vCol * a * 0.8 + vec3(1.0) * core) * uAlpha, 1.0);
|
||||
}
|
||||
`,
|
||||
});
|
||||
const pts = new THREE.Points(geo, mat);
|
||||
pts.frustumCulled = false;
|
||||
return { pts, pos, col, sz, geo };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the engine
|
||||
export function buildCity(d, opts = {}) {
|
||||
const half = d.meta?.half || 3500;
|
||||
const scene = new THREE.Scene();
|
||||
const group = new THREE.Group();
|
||||
scene.add(group);
|
||||
const out = { scene, d, half, labels: [], stats: {} };
|
||||
|
||||
const terrainGeo = d.elev ? makeTerrainGeo(d, half) : null;
|
||||
const ground = makeGround(d, half, terrainGeo);
|
||||
group.add(ground);
|
||||
out.ground = ground;
|
||||
if (d.elev) {
|
||||
out.elevAt = (x, y) => {
|
||||
const n = d.elevMeta?.size || 1024;
|
||||
const px = Math.round(((x + half) / (2 * half)) * (n - 1));
|
||||
const py = Math.round(((half - y) / (2 * half)) * (n - 1));
|
||||
return d.elev[clamp(py, 0, n - 1) * n + clamp(px, 0, n - 1)] || 0;
|
||||
};
|
||||
}
|
||||
|
||||
if (d.buildings?.b?.length) {
|
||||
const bl = makeBuildings(d.buildings.b);
|
||||
group.add(bl.mesh);
|
||||
out.buildings = bl;
|
||||
out.stats.buildings = bl.count;
|
||||
}
|
||||
|
||||
// military polygons: roads inside get the amber treatment
|
||||
let insideMil = null;
|
||||
if (d.military?.military?.length) {
|
||||
const polys = d.military.military.map((m) => {
|
||||
const pts = [];
|
||||
for (let i = 1; i < m.length - 1; i += 2) pts.push([m[i], m[i + 1]]);
|
||||
return { name: m[0], pts };
|
||||
});
|
||||
out.milPolys = polys;
|
||||
insideMil = (x, y) => polys.some((p) => pip(x, y, p.pts));
|
||||
}
|
||||
|
||||
if (d.roads?.roads?.length) {
|
||||
const rd = makeRoads(d.roads.roads, half);
|
||||
group.add(rd.mesh);
|
||||
out.roads = rd;
|
||||
out.stats.roads = rd.segs;
|
||||
if (insideMil) {
|
||||
const conv = makeRoads(d.roads.roads, half, { tint: '#ffb547', inside: insideMil, scale: 0.6 });
|
||||
conv.mesh.material.uniforms.uAlpha.value = 0.9;
|
||||
group.add(conv.mesh);
|
||||
out.convoy = conv;
|
||||
}
|
||||
}
|
||||
|
||||
// restricted areas: translucent curtains with scanning bands
|
||||
if (out.milPolys) {
|
||||
const P = [];
|
||||
const U = [];
|
||||
for (const p of out.milPolys) {
|
||||
let arc = 0;
|
||||
for (let i = 0; i < p.pts.length; i++) {
|
||||
const [x1, y1] = p.pts[i];
|
||||
const [x2, y2] = p.pts[(i + 1) % p.pts.length];
|
||||
const L = Math.hypot(x2 - x1, y2 - y1);
|
||||
const H = 0.35;
|
||||
P.push(x1 * KM, 0, -y1 * KM, x2 * KM, 0, -y2 * KM, x2 * KM, H, -y2 * KM, x1 * KM, 0, -y1 * KM, x2 * KM, H, -y2 * KM, x1 * KM, H, -y1 * KM);
|
||||
U.push(arc, 0, arc + L, 0, arc + L, 1, arc, 0, arc + L, 1, arc, 1);
|
||||
arc += L;
|
||||
}
|
||||
}
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(P, 3));
|
||||
g.setAttribute('uv', new THREE.Float32BufferAttribute(U, 2));
|
||||
const m = new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
blending: THREE.AdditiveBlending,
|
||||
uniforms: { uT: { value: 0 }, uAlpha: { value: 0 } },
|
||||
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform float uT, uAlpha;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
float band = pow(fract(vUv.y * 6.0 - uT * 0.6), 6.0);
|
||||
float top = 1.0 - smoothstep(0.0, 0.04, 1.0 - vUv.y);
|
||||
float fade = 1.0 - vUv.y;
|
||||
float hatch = step(0.5, fract((vUv.x / 60.0 + vUv.y * 4.0)));
|
||||
vec3 c = vec3(1.0, 0.62, 0.18) * (fade * 0.10 + band * 0.25 + top * 0.8 + hatch * fade * 0.03);
|
||||
gl_FragColor = vec4(c * uAlpha, 1.0);
|
||||
}
|
||||
`,
|
||||
});
|
||||
out.curtain = new THREE.Mesh(g, m);
|
||||
out.curtain.frustumCulled = false;
|
||||
group.add(out.curtain);
|
||||
}
|
||||
|
||||
// airport: runways, approach strobes, taxiways
|
||||
const aw = d.aeroway || d.military;
|
||||
if (aw?.runways?.length) {
|
||||
const P = [];
|
||||
const U = [];
|
||||
const strobes = [];
|
||||
for (const rw of aw.runways) {
|
||||
const w = Math.max(30, rw[0] || 45);
|
||||
const pts = [];
|
||||
for (let i = 2; i < rw.length - 1; i += 2) pts.push([rw[i], rw[i + 1]]);
|
||||
if (pts.length < 2) continue;
|
||||
const [x1, y1] = pts[0];
|
||||
const [x2, y2] = pts[pts.length - 1];
|
||||
const L = Math.hypot(x2 - x1, y2 - y1);
|
||||
const ux = (x2 - x1) / L;
|
||||
const uy = (y2 - y1) / L;
|
||||
const nx = -uy * w / 2;
|
||||
const ny = ux * w / 2;
|
||||
const q = [[x1 - nx, y1 - ny, 0, 0], [x1 + nx, y1 + ny, 1, 0], [x2 + nx, y2 + ny, 1, L], [x1 - nx, y1 - ny, 0, 0], [x2 + nx, y2 + ny, 1, L], [x2 - nx, y2 - ny, 0, L]];
|
||||
for (const [x, y, s, a] of q) {
|
||||
P.push(x * KM, 0.0012, -y * KM);
|
||||
U.push(s, a);
|
||||
}
|
||||
// approach lighting: 900 m of strobes off each threshold
|
||||
for (const [sx, sy, dx, dy] of [[x1, y1, -ux, -uy], [x2, y2, ux, uy]]) {
|
||||
for (let k = 1; k <= 30; k++) strobes.push([sx + dx * k * 30, sy + dy * k * 30, k]);
|
||||
}
|
||||
out.labels.push({ kind: 'runway', x: (x1 + x2) / 2, y: (y1 + y2) / 2, text: `RWY ${rw[1] || ''}`.trim() });
|
||||
}
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(P, 3));
|
||||
g.setAttribute('uv', new THREE.Float32BufferAttribute(U, 2));
|
||||
const m = new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
uniforms: { uT: { value: 0 }, uAlpha: { value: 1 } },
|
||||
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform float uT, uAlpha;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
float edge = 1.0 - smoothstep(0.0, 0.06, min(vUv.x, 1.0 - vUv.x));
|
||||
float cl = (1.0 - smoothstep(0.0, 0.03, abs(vUv.x - 0.5))) * step(0.5, fract(vUv.y / 60.0));
|
||||
vec3 c = vec3(0.9, 0.95, 1.0) * (edge * 0.9 + cl * 0.7) + vec3(0.05, 0.08, 0.1);
|
||||
gl_FragColor = vec4(c * uAlpha, 1.0);
|
||||
}
|
||||
`,
|
||||
});
|
||||
out.runways = new THREE.Mesh(g, m);
|
||||
out.runways.frustumCulled = false;
|
||||
group.add(out.runways);
|
||||
out.strobes = makePoints(strobes.length, { size: 5, color: '#ffffff' });
|
||||
strobes.forEach((s, i) => out.strobes.pos.set([s[0] * KM, 0.004, -s[1] * KM], i * 3));
|
||||
out.strobeK = strobes.map((s) => s[2]);
|
||||
out.strobes.geo.getAttribute('position').needsUpdate = true;
|
||||
group.add(out.strobes.pts);
|
||||
}
|
||||
if (aw?.taxiways?.length) {
|
||||
const lines = aw.taxiways.map((t) => {
|
||||
const pts = [];
|
||||
for (let i = 0; i < t.length - 1; i += 2) pts.push([t[i], t[i + 1], 1.2]);
|
||||
return pts;
|
||||
});
|
||||
out.taxi = makePolylines(lines, { color: '#ffc04a', alpha: 0.75 });
|
||||
group.add(out.taxi);
|
||||
}
|
||||
if (aw?.aprons?.length) {
|
||||
const lines = aw.aprons.map((t) => {
|
||||
const pts = [];
|
||||
for (let i = 0; i < t.length - 1; i += 2) pts.push([t[i], t[i + 1], 1]);
|
||||
if (pts.length) pts.push(pts[0]);
|
||||
return pts;
|
||||
});
|
||||
out.aprons = makePolylines(lines, { color: '#2fd0ff', alpha: 0.4 });
|
||||
group.add(out.aprons);
|
||||
}
|
||||
|
||||
// transit: route-coloured lines with pulses, vehicles riding them
|
||||
if (d.transit?.shapes?.length) {
|
||||
const lines = [];
|
||||
const colors = [];
|
||||
out.shapes = [];
|
||||
for (const s of d.transit.shapes) {
|
||||
const rid = s[0];
|
||||
const route = d.transit.routes[rid] || {};
|
||||
const pts = [];
|
||||
for (let i = 1; i < s.length - 1; i += 2) pts.push([s[i], s[i + 1], route.type === 3 ? 2 : 6]);
|
||||
const col = route.color ? (route.color.startsWith('#') ? route.color : '#' + route.color) : '#38e8ff';
|
||||
// subway and rail glow brighter than buses
|
||||
lines.push(pts);
|
||||
colors.push(route.type === 3 ? new THREE.Color(col).multiplyScalar(0.45).getStyle() : col);
|
||||
out.shapes.push({ rid, pts, type: route.type });
|
||||
}
|
||||
out.transitLines = makePolylines(lines, { colors });
|
||||
out.transitLines.material.uniforms.uPulse.value = 1;
|
||||
group.add(out.transitLines);
|
||||
out.stats.routes = Object.keys(d.transit.routes).length;
|
||||
}
|
||||
if (d.vehicles?.v?.length) {
|
||||
const vs = d.vehicles.v;
|
||||
out.veh = makePoints(vs.length, { size: 9 });
|
||||
vs.forEach((v, i) => {
|
||||
const r = d.transit?.routes?.[v[4]];
|
||||
const c = new THREE.Color(r?.color ? (r.color.startsWith('#') ? r.color : '#' + r.color) : '#ffffff');
|
||||
out.veh.col.set([c.r, c.g, c.b], i * 3);
|
||||
out.veh.sz[i] = r?.type === 3 ? 0.65 : 1.2;
|
||||
});
|
||||
out.veh.geo.getAttribute('color').needsUpdate = true;
|
||||
out.veh.geo.getAttribute('aS').needsUpdate = true;
|
||||
group.add(out.veh.pts);
|
||||
out.stats.vehicles = vs.length;
|
||||
}
|
||||
|
||||
// street cameras: beams + base rings
|
||||
if (d.cameras?.length) {
|
||||
const lines = d.cameras.map((c) => [[c[0], c[1], 0], [c[0], c[1], 90]]);
|
||||
out.camBeams = makePolylines(lines, { color: '#eafcff', alpha: 0.5 });
|
||||
group.add(out.camBeams);
|
||||
out.camPts = makePoints(d.cameras.length, { size: 7, color: '#bff6ff' });
|
||||
d.cameras.forEach((c, i) => out.camPts.pos.set([c[0] * KM, 0.09, -c[1] * KM], i * 3));
|
||||
out.camPts.geo.getAttribute('position').needsUpdate = true;
|
||||
group.add(out.camPts.pts);
|
||||
out.stats.cameras = d.cameras.length;
|
||||
}
|
||||
|
||||
// aircraft (real ADS-B): glyphs + real track history
|
||||
if (d.air?.a?.length) {
|
||||
const a = d.air.a;
|
||||
out.airPts = makePoints(a.length + 8, { size: 8, color: '#ffffff' });
|
||||
a.forEach((p, i) => {
|
||||
const heli = p[8] === 'A7' || /^(H|EC|AS|S7|S9|B06|B407|B429|A109|A119|A139|R44|R66|EC|H1|H6)/.test(p[7] || '');
|
||||
const mil = /^(RCH|CNV|PAT|EVAC|TOPCT|HKY|SPAR|SAM)/.test(p[6] || '');
|
||||
const c = new THREE.Color(heli ? '#7dffd6' : mil ? '#ffb547' : '#ffffff');
|
||||
out.airPts.col.set([c.r, c.g, c.b], i * 3);
|
||||
p.heli = heli;
|
||||
});
|
||||
out.airPts.geo.getAttribute('color').needsUpdate = true;
|
||||
group.add(out.airPts.pts);
|
||||
const tr = d.air.tracks || {};
|
||||
const tl = Object.values(tr).filter((t) => t.length > 1).map((t) => t.map((q) => [q[0], q[1], q[2] * 1.5]));
|
||||
if (tl.length) {
|
||||
out.airTracks = makePolylines(tl.map((t) => t.filter((q) => Math.abs(q[0]) < half * 1.2 && Math.abs(q[1]) < half * 1.2)).filter((t) => t.length > 1), { color: '#9fe8ff', alpha: 0.3 });
|
||||
group.add(out.airTracks);
|
||||
}
|
||||
out.stats.aircraft = a.length;
|
||||
out.stats.helis = a.filter((p) => p.heli).length;
|
||||
}
|
||||
|
||||
// modelled extras (labelled MODELED): helicopter tours, circuits
|
||||
out.modeled = [];
|
||||
if (opts.modeled) for (const m of opts.modeled) out.modeled.push(m);
|
||||
if (out.modeled.length) {
|
||||
out.modPts = makePoints(out.modeled.length, { size: 9, color: '#7dffd6' });
|
||||
out.modeled.forEach((m, i) => {
|
||||
const c = new THREE.Color(m.color || '#7dffd6');
|
||||
out.modPts.col.set([c.r, c.g, c.b], i * 3);
|
||||
});
|
||||
out.modPts.geo.getAttribute('color').needsUpdate = true;
|
||||
group.add(out.modPts.pts);
|
||||
out.modTrails = makePolylines(out.modeled.map((m) => m.path.concat([m.path[0]])), { color: '#7dffd6', alpha: 0.25 });
|
||||
out.modTrails.material.vertexColors = true;
|
||||
group.add(out.modTrails);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- per-frame
|
||||
out.update = (lt, k, pxScale = 1) => {
|
||||
// lt: seconds into this scene; k: 0..1 progress
|
||||
const reveal = Math.min(1.6, 0.22 + lt * 1.05);
|
||||
ground.material.uniforms.uReveal.value = reveal * 1.3;
|
||||
ground.material.uniforms.uT.value = lt;
|
||||
if (d.elev) ground.material.uniforms.uScan.value = -half * KM + ((lt * 0.35) % 1.2) * 2.4 * half * KM;
|
||||
if (out.buildings) {
|
||||
out.buildings.mesh.material.uniforms.uReveal.value = reveal * half * KM * 0.9;
|
||||
out.buildings.mesh.material.uniforms.uT.value = lt;
|
||||
}
|
||||
if (out.roads) {
|
||||
out.roads.mesh.material.uniforms.uReveal.value = reveal * 1.1;
|
||||
out.roads.mesh.material.uniforms.uT.value = lt;
|
||||
}
|
||||
if (out.convoy) {
|
||||
out.convoy.mesh.material.uniforms.uReveal.value = reveal * 1.1;
|
||||
out.convoy.mesh.material.uniforms.uT.value = lt;
|
||||
}
|
||||
if (out.curtain) {
|
||||
out.curtain.material.uniforms.uT.value = lt;
|
||||
out.curtain.material.uniforms.uAlpha.value = clamp((lt - 0.6) * 1.5);
|
||||
}
|
||||
if (out.runways) out.runways.material.uniforms.uAlpha.value = clamp(lt * 1.2);
|
||||
if (out.strobes) {
|
||||
// the "rabbit": a pulse running in toward each threshold, twice a second
|
||||
const ph = (lt * 2) % 1;
|
||||
out.strobeK.forEach((kk, i) => (out.strobes.sz[i] = Math.abs((1 - kk / 30) - ph) < 0.05 ? 2.2 : 0.35));
|
||||
out.strobes.geo.getAttribute('aS').needsUpdate = true;
|
||||
out.strobes.pts.material.uniforms.uPx.value = pxScale;
|
||||
}
|
||||
if (out.transitLines) {
|
||||
out.transitLines.material.uniforms.uT.value = lt;
|
||||
out.transitLines.material.uniforms.uDraw.value = lt * 9000;
|
||||
}
|
||||
if (out.veh) {
|
||||
const vs = d.vehicles.v;
|
||||
for (let i = 0; i < vs.length; i++) {
|
||||
const v = vs[i];
|
||||
// dead-reckon along the reported bearing at the reported speed, sped up
|
||||
const sp = Math.max(4, v[3] || 8) * 6;
|
||||
const b = ((v[2] || 0) * Math.PI) / 180;
|
||||
const x = v[0] + Math.sin(b) * sp * lt;
|
||||
const y = v[1] + Math.cos(b) * sp * lt;
|
||||
out.veh.pos.set([x * KM, 0.012, -y * KM], i * 3);
|
||||
}
|
||||
out.veh.geo.getAttribute('position').needsUpdate = true;
|
||||
out.veh.pts.material.uniforms.uAlpha.value = clamp((lt - 0.8) * 2);
|
||||
out.veh.pts.material.uniforms.uPx.value = pxScale;
|
||||
}
|
||||
if (out.camPts) {
|
||||
out.camPts.pts.material.uniforms.uAlpha.value = clamp((lt - 0.9) * 2);
|
||||
out.camBeams.material.uniforms.uAlpha.value = clamp((lt - 0.9) * 2) * 0.45;
|
||||
out.camPts.pts.material.uniforms.uPx.value = pxScale;
|
||||
}
|
||||
if (out.airPts) {
|
||||
const a = d.air.a;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const p = a[i];
|
||||
const b = ((p[3] || 0) * Math.PI) / 180;
|
||||
const sp = (p[4] || 0) * 3;
|
||||
const x = p[0] + Math.sin(b) * sp * lt;
|
||||
const y = p[1] + Math.cos(b) * sp * lt;
|
||||
const z = Math.max(0, (p[2] || 0) + (p[5] || 0) * 3 * lt) * 1.5;
|
||||
out.airPts.pos.set([x * KM, z * KM + 0.004, -y * KM], i * 3);
|
||||
p.now = [x, y, z];
|
||||
}
|
||||
out.airPts.geo.getAttribute('position').needsUpdate = true;
|
||||
out.airPts.pts.material.uniforms.uAlpha.value = clamp((lt - 0.5) * 2);
|
||||
out.airPts.pts.material.uniforms.uPx.value = pxScale;
|
||||
if (out.airTracks) out.airTracks.material.uniforms.uAlpha.value = clamp((lt - 0.5) * 2) * 0.4;
|
||||
}
|
||||
if (out.modPts) {
|
||||
out.modeled.forEach((m, i) => {
|
||||
const p = pathAt(m.path, (lt * m.speed + m.phase) % 1);
|
||||
out.modPts.pos.set([p[0] * KM, p[2] * KM, -p[1] * KM], i * 3);
|
||||
m.now = p;
|
||||
});
|
||||
out.modPts.geo.getAttribute('position').needsUpdate = true;
|
||||
out.modPts.pts.material.uniforms.uAlpha.value = clamp((lt - 0.7) * 2);
|
||||
out.modPts.pts.material.uniforms.uPx.value = pxScale;
|
||||
out.modTrails.material.uniforms.uAlpha.value = clamp((lt - 0.7) * 2) * 0.22;
|
||||
}
|
||||
if (out.taxi) out.taxi.material.uniforms.uAlpha.value = clamp((lt - 0.4) * 2) * 0.7;
|
||||
if (out.aprons) out.aprons.material.uniforms.uAlpha.value = clamp((lt - 0.4) * 2) * 0.35;
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
function pathAt(path, f) {
|
||||
const n = path.length;
|
||||
const x = f * n;
|
||||
const i = Math.floor(x) % n;
|
||||
const j = (i + 1) % n;
|
||||
const u = x - Math.floor(x);
|
||||
const a = path[i];
|
||||
const b = path[j];
|
||||
return [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u, (a[2] ?? 0) + ((b[2] ?? 0) - (a[2] ?? 0)) * u];
|
||||
}
|
||||
|
||||
function pip(x, y, pts) {
|
||||
let c = false;
|
||||
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
||||
const [xi, yi] = pts[i];
|
||||
const [xj, yj] = pts[j];
|
||||
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) c = !c;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// An ellipse/loop path in metres, for modelled helicopter tours and circuits.
|
||||
export function loopPath(cx, cy, rx, ry, alt, rot = 0, n = 48) {
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = (i / n) * Math.PI * 2;
|
||||
const x = Math.cos(a) * rx;
|
||||
const y = Math.sin(a) * ry;
|
||||
out.push([cx + x * Math.cos(rot) - y * Math.sin(rot), cy + x * Math.sin(rot) + y * Math.cos(rot), alt]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+490
-24
@@ -1,4 +1,4 @@
|
||||
import { T, FEEDS, CAPTURE_SLOW, clamp, lerp, ramp, smooth, win, easeInOut, easeOut, easeIn, expoOut, count, hash1, mulberry32 } from './timeline.js';
|
||||
import { T, FEEDS, CAPTURE_SLOW, DURATION, clamp, lerp, ramp, smooth, win, easeInOut, easeOut, easeIn, expoOut, count, hash1, mulberry32 } from './timeline.js';
|
||||
import { angDist, fmtLat, fmtLon, llToVec, EARTH_KM, D2R } from './geo.js';
|
||||
|
||||
// Everything typographic and 2D: boot console, the dish, captures, the feed
|
||||
@@ -26,7 +26,7 @@ const fmt = (n) => Math.round(n).toLocaleString('en-US');
|
||||
const pad = (n, k) => String(n).padStart(k, '0');
|
||||
|
||||
export function createHUD(opts) {
|
||||
const { W, H, PORTRAIT, heroes, cams, counts, rapidList, pick, capT, snapT, sunLL } = opts;
|
||||
const { W, H, PORTRAIT, heroes, cams, counts, rapidList, pick, capT, snapT, sunLL, flurry = [], nycCams = [] } = opts;
|
||||
const u = Math.min(W, H) / 1080;
|
||||
const M = Math.round((PORTRAIT ? 52 : 58) * u);
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -77,7 +77,7 @@ export function createHUD(opts) {
|
||||
}
|
||||
|
||||
// graded CCTV stills: slight desaturation, crushed blacks, scanlines baked in
|
||||
const camTiles = cams.map((c) => {
|
||||
const camTiles = cams.concat(nycCams).map((c) => {
|
||||
const cw = 320;
|
||||
const ch = 180;
|
||||
const cv = document.createElement('canvas');
|
||||
@@ -94,6 +94,15 @@ export function createHUD(opts) {
|
||||
return { ...c, tile: cv };
|
||||
});
|
||||
|
||||
// detection totals across every still we hold (YOLOv8n, run once offline)
|
||||
const detTotals = { all: 0, person: 0, vehicle: 0 };
|
||||
for (const c of cams.concat(nycCams))
|
||||
for (const b of c.det || []) {
|
||||
detTotals.all++;
|
||||
if (b[5] === 'person') detTotals.person++;
|
||||
if (['car', 'truck', 'bus', 'motorcycle'].includes(b[5])) detTotals.vehicle++;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- primitives
|
||||
function text(s, x, y, { font = mono(14), color = WH, align = 'left', alpha = 1, spacing = 0, baseline = 'alphabetic' } = {}) {
|
||||
if (alpha <= 0.002 || !s) return 0;
|
||||
@@ -593,13 +602,13 @@ export function createHUD(opts) {
|
||||
function drawIndexing(S) {
|
||||
const t = S.t;
|
||||
if (t < T.indexStart || t > T.indexed + 1.6) return;
|
||||
const a = win(t, T.indexStart, T.capFull + 0.3, 0.3, 0.3);
|
||||
const a = win(t, T.indexStart, T.indexed, 0.3, 0.3);
|
||||
const x = M + 6 * u;
|
||||
const y = PORTRAIT ? H * 0.1 + 40 * u : H * 0.2;
|
||||
if (a > 0) {
|
||||
text(scramble('INDEXING SURFACE', t, T.indexStart, 0.6), x, y, { font: sans(30, 300), color: WH, alpha: a, spacing: 9 * u });
|
||||
text(scramble('ESRI WORLD IMAGERY · WEB MERCATOR QUADTREE', t, T.indexStart + 0.3, 0.7, 2), x, y + 28 * u, { font: mono(12), color: DIM, alpha: a, spacing: 1.5 * u });
|
||||
const zk = ramp(t, T.indexStart + 0.4, T.capFull);
|
||||
const zk = ramp(t, T.indexStart + 0.4, T.flurryEnd);
|
||||
const z = Math.round(lerp(3, 19, Math.pow(zk, 0.8)));
|
||||
const tiles = (Math.pow(4, z + 1) - 1) / 3;
|
||||
const gsd = 156543.03 / 2 ** z;
|
||||
@@ -645,11 +654,11 @@ export function createHUD(opts) {
|
||||
function drawCaptures(S) {
|
||||
const t = S.t;
|
||||
if (!heroes.length || t < T.capStart - 0.4) return;
|
||||
const sheetA = smooth(t, T.capStart - 0.4, T.capStart + 0.2) * (1 - smooth(t, T.capFull + 2.6, T.capFull + 3.0));
|
||||
const sheetA = smooth(t, T.capStart - 0.4, T.capStart + 0.2) * (1 - smooth(t, T.flurryEnd - 0.2, T.indexed));
|
||||
// empty slots
|
||||
if (sheetA > 0) {
|
||||
sheet.slots.forEach((s, i) => {
|
||||
const filled = t >= capT[i] + (i < CAPTURE_SLOW ? 1.75 : 0);
|
||||
const filled = t >= capT[i] + (i < CAPTURE_SLOW ? 1.5 : 0);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = sheetA * (filled ? 0 : 0.45);
|
||||
ctx.strokeStyle = FAINT;
|
||||
@@ -657,7 +666,7 @@ export function createHUD(opts) {
|
||||
ctx.strokeRect(s.x + 0.5, s.y + 0.5, s.w - 1, s.h - 1);
|
||||
ctx.restore();
|
||||
});
|
||||
const done = capT.filter((c, i) => t >= c + (i < CAPTURE_SLOW ? 1.75 : 0.0)).length;
|
||||
const done = capT.filter((c, i) => t >= c + (i < CAPTURE_SLOW ? 1.5 : 0.0)).length;
|
||||
const ly = sheet.y0 - 14 * u;
|
||||
text('SYNTHETIC CAPTURES', sheet.x0, ly, { font: mono(12, 500), color: CY, alpha: sheetA, spacing: 2 * u });
|
||||
text(`${pad(done, 2)}/${pad(heroes.length, 2)}`, sheet.x0 + sheet.sw, ly, { font: mono(12), color: WH, alpha: sheetA, align: 'right', spacing: 2 * u });
|
||||
@@ -669,7 +678,7 @@ export function createHUD(opts) {
|
||||
const s = sheet.slots[i];
|
||||
const t0 = capT[i];
|
||||
const slow = i < CAPTURE_SLOW;
|
||||
const landT = slow ? t0 + 1.75 : t0;
|
||||
const landT = slow ? t0 + 1.5 : t0;
|
||||
if (t < landT) return;
|
||||
if (i === fullIdx && t >= T.capFull && t < T.capFull + 3.0) return;
|
||||
const k = t - landT;
|
||||
@@ -699,7 +708,7 @@ export function createHUD(opts) {
|
||||
for (let i = 0; i < Math.min(CAPTURE_SLOW, heroes.length); i++) {
|
||||
const t0 = capT[i];
|
||||
const k = t - t0;
|
||||
if (k < 0 || k > 1.75) continue;
|
||||
if (k < 0 || k > 1.5) continue;
|
||||
const h = heroes[i];
|
||||
const lab = capLabel(h, i);
|
||||
const p = S.project(h.lon, h.lat, 1.0);
|
||||
@@ -710,7 +719,7 @@ export function createHUD(opts) {
|
||||
}
|
||||
// card rect: appear → hold → fly to slot
|
||||
const s = sheet.slots[i];
|
||||
const fly = easeInOut(ramp(k, 1.4, 1.75));
|
||||
const fly = easeInOut(ramp(k, 1.2, 1.5));
|
||||
const open = easeOut(ramp(k, 0.05, 0.3));
|
||||
const R = {
|
||||
x: lerp(bigCard.x, s.x, fly),
|
||||
@@ -872,7 +881,8 @@ export function createHUD(opts) {
|
||||
const t = S.t;
|
||||
if (t < T.air - 0.6 || t > T.cut) return;
|
||||
const wallDim = 1 - win(t, T.cctvWall - 0.1, T.cctvOut + 0.3, 0.2, 0.3);
|
||||
const a = smooth(t, T.air - 0.6, T.air) * (1 - smooth(t, T.saturate - 0.1, T.saturate + 0.7)) * wallDim;
|
||||
const a = smooth(t, T.air - 0.6, T.air) * (1 - smooth(t, T.descend, T.descend + 0.5)) * wallDim;
|
||||
if (t > T.descend + 0.6) return;
|
||||
// a soft dark gutter keeps the stack legible over the planet
|
||||
if (a > 0) {
|
||||
ctx.save();
|
||||
@@ -1063,6 +1073,7 @@ export function createHUD(opts) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.05)';
|
||||
ctx.fillRect(x, y + band, w, h * 0.08);
|
||||
ctx.restore();
|
||||
if (c.det && w > 60 * u && h > 30 * u) detBoxes(c.det, x, y, w, h, alpha * 0.9, t, w > 150 * u);
|
||||
if (w > 90 * u) {
|
||||
const fs = Math.max(7, Math.min(10, w / 26 / u));
|
||||
text((c.name || '').toUpperCase().slice(0, Math.floor(w / (fs * 0.62 * u)) - 2), x + 5 * u, y + 12 * u, { font: mono(fs, 500), color: WH, alpha: alpha * 0.9, spacing: 0.5 * u });
|
||||
@@ -1091,7 +1102,7 @@ export function createHUD(opts) {
|
||||
const t = S.t;
|
||||
if (!camTiles.length || t < T.cctv || t > T.cctvOut + 0.8) return;
|
||||
// tethered feeds pinned to the globe
|
||||
if (t < T.cctvWall + 0.3) {
|
||||
if (t < T.cctvWall + 0.3 && T.cctv < T.cctvWall) {
|
||||
const a = 1 - smooth(t, T.cctvWall - 0.1, T.cctvWall + 0.25);
|
||||
const vis = [];
|
||||
for (const c of camTiles) {
|
||||
@@ -1183,7 +1194,7 @@ export function createHUD(opts) {
|
||||
const cy = H / 2;
|
||||
const shown = count(t, T.cctvWall + 0.5, T.cctvOut - 0.6, cams.length, counts.cctv, expoOut);
|
||||
const bw = (PORTRAIT ? 620 : 700) * u;
|
||||
const bh = 150 * u;
|
||||
const bh = 184 * u;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = pa * 0.88;
|
||||
ctx.fillStyle = '#000';
|
||||
@@ -1192,7 +1203,16 @@ export function createHUD(opts) {
|
||||
brackets(W / 2 - bw / 2, cy - bh / 2, bw, bh, 14 * u, CY, pa, 1.5);
|
||||
text('OPTICAL NETWORK', W / 2, cy - 30 * u, { font: mono(13, 500), color: CY, align: 'center', alpha: pa, spacing: 6 * u });
|
||||
text(fmt(shown), W / 2, cy + 26 * u, { font: sans(56, 200), color: WH, align: 'center', alpha: pa, spacing: 4 * u });
|
||||
text(`OBJECTS IN FRAME ${fmt(detTotals.all)} · PEOPLE ${fmt(detTotals.person)} · VEHICLES ${fmt(detTotals.vehicle)}`, W / 2, cy + 76 * u, { font: mono(10, 500), color: CY, align: 'center', alpha: pa, spacing: 1.5 * u });
|
||||
text(`PUBLIC CAMERAS · ${counts.cctvSources} AGENCIES · ${counts.cctvCountries} COUNTRIES`, W / 2, cy + 56 * u, { font: mono(11), color: DIM, align: 'center', alpha: pa, spacing: 2 * u });
|
||||
// the heartbeat of the cities, from what the cameras see
|
||||
const pyb = H - 64 * u;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = pa * 0.85;
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, pyb - 26 * u, W, 64 * u);
|
||||
ctx.restore();
|
||||
cityPulse(M, pyb - 6 * u, W - 2 * M, 40 * u, pa, t, camTiles, 'CITY PULSE · OBJECTS DETECTED PER CAMERA · YOLOV8N');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1217,11 +1237,11 @@ export function createHUD(opts) {
|
||||
|
||||
// status lines
|
||||
const lines = [
|
||||
[53.4, 'FEEDS', `${pad(FEEDS.length + rapidList.length, 2)}/${pad(FEEDS.length + rapidList.length, 2)} LIVE`],
|
||||
[54.5, 'COVERAGE', '510,072,000 KM²'],
|
||||
[55.6, 'TWIN', 'SYNCHRONISED'],
|
||||
[56.7, 'LATENCY', `${212 - Math.floor(ramp(t, 56.7, 58) * 170)} MS`],
|
||||
[57.7, 'MODEL', 'EXCEEDS SUBSTRATE'],
|
||||
[77.4, 'HUMAN ACTIVITY', 'INGESTED'],
|
||||
[78.5, 'NON-HUMAN ACTIVITY', 'INGESTED'],
|
||||
[79.6, 'COVERAGE', '510,072,000 KM²'],
|
||||
[80.7, 'LATENCY', `${212 - Math.floor(ramp(t, 80.7, 82) * 170)} MS`],
|
||||
[81.7, 'TWIN', 'EXCEEDS SUBSTRATE'],
|
||||
];
|
||||
const font = mono(13);
|
||||
const lx = PORTRAIT ? W / 2 - 230 * u : W - M - 446 * u;
|
||||
@@ -1251,8 +1271,8 @@ export function createHUD(opts) {
|
||||
const oa = smooth(t, T.omni, T.omni + 0.12) * (1 - smooth(t, T.cut - 0.1, T.cut));
|
||||
if (oa > 0) {
|
||||
const y = PORTRAIT ? H * 0.8 : H * 0.78;
|
||||
text('OBSERVER STATE', W / 2, y - 46 * u, { font: mono(12, 500), color: CY, align: 'center', alpha: oa, spacing: 6 * u });
|
||||
plate(scramble('OMNIPRESENT', t, T.omni, 0.35), W / 2, y + 4 * u, { font: sans(PORTRAIT ? 34 : 38, 500), align: 'center', alpha: oa, spacing: 14, padX: 26, padY: 14 });
|
||||
text('TWIN STATE', W / 2, y - 46 * u, { font: mono(12, 500), color: CY, align: 'center', alpha: oa, spacing: 6 * u });
|
||||
plate(scramble('ALIVE', t, T.omni, 0.35), W / 2, y + 4 * u, { font: sans(PORTRAIT ? 34 : 38, 500), align: 'center', alpha: oa, spacing: 14, padX: 26, padY: 14 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1261,7 +1281,7 @@ export function createHUD(opts) {
|
||||
const t = S.t;
|
||||
if (t < T.title) return;
|
||||
const { cx, cy, s } = eyeLayout;
|
||||
const fadeEnd = 1 - smooth(t, T.fadeOut, 72);
|
||||
const fadeEnd = 1 - smooth(t, T.fadeOut, DURATION);
|
||||
ctx.save();
|
||||
ctx.translate(cx - EYE_C.x * s, cy - EYE_C.y * s);
|
||||
ctx.scale(s, s);
|
||||
@@ -1303,7 +1323,7 @@ export function createHUD(opts) {
|
||||
const wsize = PORTRAIT ? 64 : 70;
|
||||
text(scramble("GOD'S EYE VIEW", t, T.wordmark, 0.9, 4), W / 2 + 10 * u, wy, { font: sans(wsize, 200), color: WH, align: 'center', alpha: wa, spacing: (PORTRAIT ? 18 : 26) * u });
|
||||
const ta = smooth(t, T.tagline, T.tagline + 0.2) * fadeEnd;
|
||||
text(typed('NO PLACE LEFT BEHIND.', t, T.tagline, 22), W / 2, wy + 52 * u, { font: mono(17), color: DIM, align: 'center', alpha: ta, spacing: 7 * u });
|
||||
text(scramble('OMNISCIENCE', t, T.tagline, 0.9, 8), W / 2, wy + 58 * u, { font: mono(20, 500), color: CY, align: 'center', alpha: ta, spacing: 16 * u });
|
||||
const oa = smooth(t, T.online, T.online + 0.15) * fadeEnd;
|
||||
if (oa > 0) {
|
||||
const y = wy + (PORTRAIT ? 130 : 112) * u;
|
||||
@@ -1321,7 +1341,7 @@ export function createHUD(opts) {
|
||||
text('PUBLIC SIGNALS · OPEN SOURCE · RUNS IN YOUR BROWSER', W / 2, y + 30 * u, { font: mono(10), color: FAINT, align: 'center', alpha: oa * smooth(t, T.online + 0.6, T.online + 1.2), spacing: 2.5 * u });
|
||||
}
|
||||
// sources, as the feeds came in
|
||||
const ca = smooth(t, T.online + 0.8, T.online + 1.6) * fadeEnd;
|
||||
const ca = smooth(t, T.tagline + 1.6, T.tagline + 2.4) * fadeEnd;
|
||||
if (ca > 0) {
|
||||
const credits = [
|
||||
'IMAGERY ESRI WORLD IMAGERY (ESRI, MAXAR, EARTHSTAR GEOGRAPHICS) · NASA GIBS BLACK MARBLE · VIIRS',
|
||||
@@ -1335,6 +1355,449 @@ export function createHUD(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- v2: the eye opens
|
||||
const SENSORS = ['VIS', 'NIR', 'SWIR', 'MWIR', 'LWIR', 'SAR-X', 'SAR-C', 'RF', 'ADS-B', 'AIS', 'SEISMIC', 'GNSS-R', 'LIDAR', 'OPTICAL·CCTV'];
|
||||
function drawAwakening(S) {
|
||||
const t = S.t;
|
||||
if (t < T.seed || t > T.indexStart + 1.2) return;
|
||||
const fade = 1 - smooth(t, T.substrate + 0.2, T.indexStart + 1.0);
|
||||
const cx = S.center.x;
|
||||
const cy = S.center.y;
|
||||
const R = S.limbR;
|
||||
// iris aperture: seven blades retract from the seed to the limb
|
||||
const ak = easeInOut(ramp(t, T.aperture, T.growStart + 1.4));
|
||||
if (t < T.growStart + 2.2) {
|
||||
const ri = lerp(6 * u, R * 1.02, ak);
|
||||
const ro = R + 10 * u;
|
||||
const a = smooth(t, T.seed + 0.3, T.aperture) * (1 - smooth(t, T.growStart + 1.2, T.growStart + 2.2));
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.globalAlpha = a * 0.75;
|
||||
ctx.strokeStyle = CY;
|
||||
ctx.lineWidth = 1 * u;
|
||||
const rot = ak * 1.4 + t * 0.05;
|
||||
const N = 7;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const a0 = rot + (i / N) * Math.PI * 2;
|
||||
const a1 = a0 + (Math.PI * 2) / N;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Math.cos(a0) * ri, Math.sin(a0) * ri);
|
||||
ctx.lineTo(Math.cos(a1) * ri, Math.sin(a1) * ri);
|
||||
// blade edge sweeping out to the ring
|
||||
ctx.moveTo(Math.cos(a0) * ri, Math.sin(a0) * ri);
|
||||
ctx.quadraticCurveTo(Math.cos(a0 + 0.5) * (ri + ro) * 0.55, Math.sin(a0 + 0.5) * (ri + ro) * 0.55, Math.cos(a0 + 0.9) * ro, Math.sin(a0 + 0.9) * ro);
|
||||
ctx.stroke();
|
||||
}
|
||||
// closed blades read as a dark iris
|
||||
ctx.globalAlpha = a * 0.5 * (1 - ak);
|
||||
ctx.fillStyle = 'rgba(0,30,40,0.6)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, ro, 0, Math.PI * 2);
|
||||
ctx.arc(0, 0, ri, 0, Math.PI * 2, true);
|
||||
ctx.fill('evenodd');
|
||||
ctx.restore();
|
||||
const fnum = lerp(22, 1.4, ak);
|
||||
text(`APERTURE f/${fnum < 2 ? fnum.toFixed(1) : Math.round(fnum)}`, cx, cy + R + 64 * u, { font: mono(11, 500), color: CY, align: 'center', alpha: a, spacing: 4 * u });
|
||||
}
|
||||
// radar sweep across the disc while the culture grows
|
||||
const sw = win(t, T.growStart, T.substrate + 0.8, 0.5, 0.8) * fade;
|
||||
if (sw > 0) {
|
||||
const ang = (t - T.growStart) * 2.4;
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, R, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const a0 = ang - i * 0.02;
|
||||
ctx.globalAlpha = sw * 0.12 * (1 - i / 24);
|
||||
ctx.fillStyle = CY;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.arc(0, 0, R, a0 - 0.02, a0);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = sw * 0.7;
|
||||
ctx.strokeStyle = '#bff8ff';
|
||||
ctx.lineWidth = 1.2 * u;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(Math.cos(ang) * R, Math.sin(ang) * R);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
// orbiting coordinate text ring
|
||||
const ta = win(t, T.dishDone - 0.2, T.indexStart + 1.0, 0.6, 0.6);
|
||||
if (ta > 0) {
|
||||
const str = ' 00°00′00″N 000°00′00″E · OBSERVER-1 · SUBSTRATE · CULTURE 001 · SENSORS ONLINE ·'.repeat(3);
|
||||
const rr = R + 52 * u;
|
||||
const n = Math.floor((Math.PI * 2 * rr) / (8.2 * u));
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.font = mono(9);
|
||||
ctx.fillStyle = CY;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.globalAlpha = ta * 0.55;
|
||||
const rot = -t * 0.06;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = rot + (i / n) * Math.PI * 2;
|
||||
ctx.save();
|
||||
ctx.rotate(a);
|
||||
ctx.translate(0, -rr);
|
||||
ctx.fillText(str[i % str.length], 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
// sensors waking, one band at a time
|
||||
const sa = win(t, T.growStart + 0.2, T.indexStart + 1.0, 0.3, 0.6);
|
||||
if (sa > 0) {
|
||||
const x0 = PORTRAIT ? M + 6 * u : W - M - 250 * u;
|
||||
const y0 = PORTRAIT ? H - M - 330 * u : H * 0.24;
|
||||
text('SENSOR ARRAY', x0, y0, { font: mono(11, 500), color: CY, alpha: sa, spacing: 3 * u });
|
||||
line(x0, y0 + 8 * u, x0 + 240 * u, y0 + 8 * u, CY, sa * 0.5);
|
||||
SENSORS.forEach((name, i) => {
|
||||
const st = T.growStart + 0.3 + i * ((T.growEnd - T.growStart - 0.6) / SENSORS.length);
|
||||
const k = t - st;
|
||||
if (k < 0) return;
|
||||
const y = y0 + (26 + i * 18) * u;
|
||||
const ra = sa * Math.min(1, k * 6);
|
||||
text(name, x0, y, { font: mono(10), color: WH, alpha: ra, spacing: 1.2 * u });
|
||||
const lvl = clamp(k / 0.8) * (0.55 + 0.45 * hash1(i * 7.3));
|
||||
ctx.save();
|
||||
ctx.globalAlpha = ra;
|
||||
ctx.fillStyle = 'rgba(0,246,255,0.18)';
|
||||
ctx.fillRect(x0 + 96 * u, y - 7 * u, 90 * u, 5 * u);
|
||||
ctx.fillStyle = CY;
|
||||
ctx.fillRect(x0 + 96 * u, y - 7 * u, 90 * u * lvl, 5 * u);
|
||||
ctx.restore();
|
||||
text(k < 0.8 ? 'SYNC' : 'LIVE', x0 + 240 * u, y, { font: mono(9, 500), color: k < 0.8 ? AMB : CY, alpha: ra, align: 'right', spacing: 1 * u });
|
||||
});
|
||||
}
|
||||
// optics calibration, converging
|
||||
const ca = win(t, T.seed + 0.6, T.indexStart + 1.0, 0.4, 0.6);
|
||||
if (ca > 0 && !PORTRAIT) {
|
||||
const x0 = M + 6 * u;
|
||||
const y0 = H * 0.24;
|
||||
text('CALIBRATING OPTICS', x0, y0, { font: mono(11, 500), color: CY, alpha: ca, spacing: 3 * u });
|
||||
line(x0, y0 + 8 * u, x0 + 240 * u, y0 + 8 * u, CY, ca * 0.5);
|
||||
const focus = 1 - Math.exp(-(t - T.seed) * 0.45);
|
||||
const rows = [['FOCUS', focus.toFixed(3)], ['EXPOSURE', `${(-6 + focus * 6.3).toFixed(2)} EV`], ['NOISE FLOOR', `${(-12 - focus * 30).toFixed(1)} dB`], ['DYNAMIC RANGE', `${(4 + focus * 16).toFixed(1)} STOPS`]];
|
||||
rows.forEach(([k, v], i) => {
|
||||
const y = y0 + (28 + i * 20) * u;
|
||||
text(k, x0, y, { font: mono(10), color: DIM, alpha: ca, spacing: 1.2 * u });
|
||||
text(v, x0 + 240 * u, y, { font: mono(10, 500), color: WH, alpha: ca, align: 'right', spacing: 1.2 * u });
|
||||
});
|
||||
// histogram settling
|
||||
const hy = y0 + 130 * u;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = ca * 0.85;
|
||||
for (let i = 0; i < 48; i++) {
|
||||
const xx = i / 47;
|
||||
const target = Math.exp(-Math.pow((xx - 0.32) / 0.14, 2)) + 0.5 * Math.exp(-Math.pow((xx - 0.8) / 0.06, 2));
|
||||
const noise = hash1(i * 3.1 + Math.floor(t * 12)) * (1 - focus);
|
||||
const v = target * focus + noise * 0.8;
|
||||
ctx.fillStyle = i % 8 === 0 ? WH : CY;
|
||||
ctx.fillRect(x0 + i * 5 * u, hy - v * 46 * u, 3.2 * u, v * 46 * u);
|
||||
}
|
||||
ctx.restore();
|
||||
line(x0, hy + 2 * u, x0 + 240 * u, hy + 2 * u, CY, ca * 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- v2: the flurry
|
||||
function drawFlurry(S) {
|
||||
const t = S.t;
|
||||
if (!flurry.length || t < T.flurry - 0.2 || t > T.indexed + 0.4) return;
|
||||
const fa = 1 - smooth(t, T.flurryEnd, T.indexed + 0.2);
|
||||
let shown = 0;
|
||||
const sz0 = (PORTRAIT ? 92 : 78) * u;
|
||||
for (const f of flurry) {
|
||||
if (f.t === undefined) continue;
|
||||
const k = t - f.t;
|
||||
if (k >= 0) shown++;
|
||||
const life = 0.42;
|
||||
if (k < 0 || k > life) continue;
|
||||
const p = S.project(f.lon, f.lat, 1.0);
|
||||
if (!p.vis || p.facing < 0.12) continue;
|
||||
const pop = easeOut(Math.min(1, k / 0.1));
|
||||
const a = fa * (1 - smooth(k, life * 0.6, life));
|
||||
const sz = sz0 * (0.55 + 0.45 * pop) * (0.6 + 0.4 * p.facing);
|
||||
const x = p.x - sz / 2;
|
||||
const y = p.y - sz - 10 * u;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = a;
|
||||
ctx.drawImage(f.img, x, y, sz, sz);
|
||||
const fl = Math.exp(-k * 18);
|
||||
if (fl > 0.02) {
|
||||
ctx.fillStyle = `rgba(230,255,255,${fl * 0.9})`;
|
||||
ctx.fillRect(x, y, sz, sz);
|
||||
}
|
||||
ctx.restore();
|
||||
brackets(x - 2 * u, y - 2 * u, sz + 4 * u, sz + 4 * u, 6 * u, CY, a * 0.9, 1);
|
||||
line(p.x, p.y, p.x, y + sz, CY, a * 0.5, 0.8);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = a;
|
||||
ctx.fillStyle = WH;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, 2 * u, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
if (sz > 56 * u && hash1(f.lat * 3.7) > 0.55) text((f.name || '').toUpperCase().slice(0, 18), x, y - 5 * u, { font: mono(8, 500), color: WH, alpha: a * 0.85, spacing: 0.8 * u });
|
||||
}
|
||||
// sweep readout
|
||||
const ra = win(t, T.flurry, T.indexed + 0.3, 0.2, 0.3);
|
||||
if (ra > 0) {
|
||||
const x = M + 6 * u;
|
||||
const y = (PORTRAIT ? H * 0.1 + 40 * u : H * 0.2) + 160 * u;
|
||||
const rate = Math.round(lerp(6, 64, ramp(t, T.flurry, T.flurryEnd - 1)));
|
||||
text('GLOBAL SWEEP', x, y, { font: mono(12, 500), color: CY, alpha: ra, spacing: 3 * u });
|
||||
text(`CAPTURES ${fmt(heroes.length + shown)}`, x, y + 22 * u, { font: mono(13, 500), color: WH, alpha: ra, spacing: 1.5 * u });
|
||||
text(`RATE ${rate} / S`, x, y + 42 * u, { font: mono(13), color: WH, alpha: ra, spacing: 1.5 * u });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- v2: cities
|
||||
const CLS_COL = { person: '#ffd24a', car: CY, truck: '#ff8a3d', bus: '#ff8a3d', bicycle: '#7dffd6', motorcycle: '#7dffd6', 'traffic light': '#bfa8ff', 'stop sign': '#ff5b5b' };
|
||||
function detBoxes(det, x, y, w, h, alpha, t, labels = true) {
|
||||
if (!det || !det.length || alpha <= 0) return 0;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.lineWidth = Math.max(1, 1.1 * u);
|
||||
let n = 0;
|
||||
for (const b of det) {
|
||||
const [x0, y0, x1, y1, conf, cls] = b;
|
||||
const col = CLS_COL[cls] || WH;
|
||||
const j = Math.sin(t * 9 + x0 * 40) * 0.6 * u;
|
||||
const bx = x + x0 * w + j;
|
||||
const by = y + y0 * h;
|
||||
const bw = (x1 - x0) * w;
|
||||
const bh = (y1 - y0) * h;
|
||||
if (bw < 2 || bh < 2) continue;
|
||||
ctx.strokeStyle = col;
|
||||
const c = Math.min(bw, bh) * 0.3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx, by + c); ctx.lineTo(bx, by); ctx.lineTo(bx + c, by);
|
||||
ctx.moveTo(bx + bw - c, by); ctx.lineTo(bx + bw, by); ctx.lineTo(bx + bw, by + c);
|
||||
ctx.moveTo(bx + bw, by + bh - c); ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx + bw - c, by + bh);
|
||||
ctx.moveTo(bx + c, by + bh); ctx.lineTo(bx, by + bh); ctx.lineTo(bx, by + bh - c);
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = alpha * 0.12;
|
||||
ctx.fillStyle = col;
|
||||
ctx.fillRect(bx, by, bw, bh);
|
||||
ctx.globalAlpha = alpha;
|
||||
if (labels && bw > 18 * u) {
|
||||
ctx.font = mono(7, 500);
|
||||
ctx.fillStyle = col;
|
||||
ctx.fillText(`${cls.toUpperCase()} ${Math.round(conf * 100)}`, bx, by - 2 * u);
|
||||
}
|
||||
n++;
|
||||
}
|
||||
ctx.restore();
|
||||
return n;
|
||||
}
|
||||
|
||||
function drawCity(S) {
|
||||
const C = S.city;
|
||||
if (!C) return;
|
||||
const t = S.t;
|
||||
const lt = C.lt;
|
||||
const dur = C.t1 - C.t0;
|
||||
const a = smooth(lt, 0.15, 0.6) * (1 - smooth(lt, dur - 0.25, dur));
|
||||
const B = C.built;
|
||||
const st = B.stats || {};
|
||||
// dark gutter behind the readouts
|
||||
ctx.save();
|
||||
ctx.globalAlpha = a * 0.8;
|
||||
const gg = PORTRAIT ? ctx.createLinearGradient(0, 0, 0, H * 0.42) : ctx.createLinearGradient(0, 0, W * 0.36, 0);
|
||||
gg.addColorStop(0, 'rgba(0,3,6,0.85)');
|
||||
gg.addColorStop(0.6, 'rgba(0,3,6,0.45)');
|
||||
gg.addColorStop(1, 'rgba(0,3,6,0)');
|
||||
ctx.fillStyle = gg;
|
||||
if (PORTRAIT) ctx.fillRect(0, 0, W, H * 0.42);
|
||||
else ctx.fillRect(0, 0, W * 0.36, H);
|
||||
ctx.restore();
|
||||
// acquisition plate on the cut
|
||||
const pa = win(lt, 0.0, 0.9, 0.05, 0.3);
|
||||
if (pa > 0) plate(`ACQUIRING ▸ ${C.name}`, W / 2, H * 0.5, { font: mono(15, 500), align: 'center', alpha: pa, spacing: 3, padX: 16, padY: 10 });
|
||||
// title block
|
||||
const x = M + 6 * u;
|
||||
const y = PORTRAIT ? M + 110 * u : H * 0.2;
|
||||
text(scramble(C.name, t, C.t0 + 0.2, 0.5, 3), x, y, { font: sans(PORTRAIT ? 40 : 34, 300), color: WH, alpha: a, spacing: 10 * u });
|
||||
text(C.sub, x, y + 28 * u, { font: mono(12), color: CY, alpha: a, spacing: 2 * u });
|
||||
text(`${fmtLat(C.lat, 4)} ${fmtLon(C.lon, 4)}`, x, y + 48 * u, { font: mono(11), color: DIM, alpha: a, spacing: 1.5 * u });
|
||||
const rows = [];
|
||||
if (st.buildings) rows.push(['BUILDINGS', fmt(st.buildings), 'OSM']);
|
||||
if (st.roads) rows.push(['ROAD SEGMENTS', fmt(st.roads), 'OSM']);
|
||||
if (st.roads) rows.push(['TRAFFIC', 'MODELED', 'OSM GRAPH']);
|
||||
if (st.cameras) rows.push(['STREET CAMERAS', fmt(st.cameras), 'NYC DOT']);
|
||||
if (st.aircraft) rows.push(['AIRCRAFT', fmt(st.aircraft), 'ADS-B']);
|
||||
if (st.helis) rows.push(['ROTORCRAFT', fmt(st.helis), 'ADS-B']);
|
||||
if (B.modeled?.length && C.slug === 'nyc') rows.push(['HELI CORRIDORS', fmt(B.modeled.length), 'MODELED']);
|
||||
if (st.routes) rows.push(['TRANSIT ROUTES', fmt(st.routes), 'MBTA GTFS']);
|
||||
if (st.vehicles) rows.push(['TRANSIT VEHICLES', fmt(st.vehicles), 'GTFS-RT']);
|
||||
if (B.runways) rows.push(['RUNWAYS', fmt(B.d.aeroway?.runways?.length || B.d.military?.runways?.length || 0), 'OSM']);
|
||||
if (B.milPolys) rows.push(['RESTRICTED AREAS', fmt(B.milPolys.length), 'OSM']);
|
||||
if (C.slug === 'bragg') rows.push(['AIRFIELD PATTERN', '3', 'MODELED']);
|
||||
if (B.d.peaks?.length) rows.push(['PEAKS', fmt(B.d.peaks.length), 'OSM']);
|
||||
if (B.d.elev) rows.push(['ELEVATION', `${fmt(B.d.elevMeta?.max || 8848)} M`, 'AWS TERRAIN']);
|
||||
rows.forEach(([k, v, src], i) => {
|
||||
const ry = y + (78 + i * 19) * u;
|
||||
const ra = a * smooth(lt, 0.35 + i * 0.12, 0.6 + i * 0.12);
|
||||
text(k, x, ry, { font: mono(10), color: DIM, alpha: ra, spacing: 1.2 * u });
|
||||
text(v, x + 170 * u, ry, { font: mono(11, 500), color: v === 'MODELED' ? AMB : WH, alpha: ra, spacing: 1.2 * u });
|
||||
text(src, x + 262 * u, ry, { font: mono(8), color: FAINT, alpha: ra, spacing: 1 * u });
|
||||
});
|
||||
// congestion legend
|
||||
if (st.roads && C.slug !== 'everest') {
|
||||
const lx = PORTRAIT ? M + 6 * u : M + 6 * u;
|
||||
const ly = H - M - 70 * u;
|
||||
const lw = 200 * u;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = a;
|
||||
const g = ctx.createLinearGradient(lx, 0, lx + lw, 0);
|
||||
g.addColorStop(0, '#1aff73');
|
||||
g.addColorStop(0.5, '#ffcc1a');
|
||||
g.addColorStop(1, '#ff2a1f');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(lx, ly, lw, 4 * u);
|
||||
ctx.restore();
|
||||
text('FREE FLOW', lx, ly - 7 * u, { font: mono(9), color: DIM, alpha: a, spacing: 1 * u });
|
||||
text('CONGESTED', lx + lw, ly - 7 * u, { font: mono(9), color: DIM, alpha: a, align: 'right', spacing: 1 * u });
|
||||
text('TRAFFIC · MODELED ON OSM ROAD GRAPH', lx, ly + 18 * u, { font: mono(8), color: FAINT, alpha: a, spacing: 1 * u });
|
||||
}
|
||||
// world-space labels
|
||||
const la = a * smooth(lt, 0.9, 1.4);
|
||||
if (la > 0) {
|
||||
if (B.d.peaks) {
|
||||
let n = 0;
|
||||
for (const pk of B.d.peaks.slice().sort((p, q) => q[2] - p[2])) {
|
||||
if (n >= 6) break;
|
||||
const z = B.elevAt ? B.elevAt(pk[0], pk[1]) : pk[2];
|
||||
const p = C.project(pk[0], pk[1], z + 60);
|
||||
if (!p.vis || p.x < M || p.x > W - M || p.y < M || p.y > H - M) continue;
|
||||
drawCallout(p.x, p.y, (pk[3] || '').toUpperCase(), `${fmt(pk[2])} M`, n % 2 ? -1 : 1, la);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (B.d.air?.a) {
|
||||
let n = 0;
|
||||
for (const p of B.d.air.a.slice().sort((p, q) => Math.hypot(p[0], p[1]) - Math.hypot(q[0], q[1]))) {
|
||||
if (n >= 4 || !p.now) break;
|
||||
const s = C.project(p.now[0], p.now[1], p.now[2]);
|
||||
if (!s.vis || s.x < M || s.x > W - M || s.y < M * 2 || s.y > H - M * 2) continue;
|
||||
const alt = Math.round((p[2] || 0) * 3.2808);
|
||||
drawCallout(s.x, s.y, `${(p[6] || 'UNKNOWN').trim()}${p[7] ? ' · ' + p[7] : ''}`, `${alt ? fmt(alt) + ' FT' : 'GROUND'} · ${Math.round((p[4] || 0) * 1.944)} KT`, n % 2 ? -1 : 1, la, p.heli ? '#7dffd6' : WH);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (B.modeled?.length) {
|
||||
B.modeled.slice(0, 2).forEach((m, i) => {
|
||||
if (!m.now) return;
|
||||
const s = C.project(m.now[0], m.now[1], m.now[2]);
|
||||
if (!s.vis) return;
|
||||
drawCallout(s.x, s.y, m.name, `MODELED · ${fmt(m.now[2] * 3.28)} FT`, i % 2 ? 1 : -1, la * 0.9, m.color || '#7dffd6');
|
||||
});
|
||||
}
|
||||
if (B.milPolys) {
|
||||
const big = B.milPolys.slice().sort((p, q) => q.pts.length - p.pts.length)[0];
|
||||
if (big) {
|
||||
let cx = 0, cy = 0;
|
||||
big.pts.forEach((q) => { cx += q[0]; cy += q[1]; });
|
||||
const s = C.project(cx / big.pts.length, cy / big.pts.length, 400);
|
||||
if (s.vis) plate(`RESTRICTED · ${(big.name || 'MILITARY').toUpperCase().slice(0, 28)}`, s.x, s.y, { font: mono(10, 500), align: 'center', alpha: la * 0.9, spacing: 2, inverse: false, padX: 8, padY: 5 });
|
||||
}
|
||||
}
|
||||
if (B.labels?.length && C.slug === 'lax') {
|
||||
B.labels.filter((l) => l.kind === 'runway').slice(0, 4).forEach((l) => {
|
||||
const s = C.project(l.x, l.y, 5);
|
||||
if (s.vis) text(l.text, s.x, s.y - 6 * u, { font: mono(9, 500), color: WH, align: 'center', alpha: la * 0.8, spacing: 1.5 * u });
|
||||
});
|
||||
}
|
||||
}
|
||||
// NYC: street cameras opening onto real frames with detections
|
||||
if (C.slug === 'nyc' && nycCams.length) {
|
||||
const pw = (PORTRAIT ? 230 : 210) * u;
|
||||
const ph = pw * 9 / 16;
|
||||
const slots = PORTRAIT
|
||||
? [[W - M - pw, H * 0.22], [W - M - pw, H * 0.22 + ph + 14 * u], [W - M - pw, H * 0.22 + 2 * (ph + 14 * u)]]
|
||||
: [[W - M - pw, H * 0.18], [W - M - pw, H * 0.18 + ph + 16 * u], [W - M - pw, H * 0.18 + 2 * (ph + 16 * u)], [W - M - pw, H * 0.18 + 3 * (ph + 16 * u)]];
|
||||
let total = 0;
|
||||
slots.forEach(([sx, sy], i) => {
|
||||
const c = nycCams[(i + Math.floor(lt / 2.2) * slots.length) % nycCams.length];
|
||||
const st0 = 2.2 + i * 0.35 + Math.floor(lt / 2.2) * 0;
|
||||
const k = lt - st0;
|
||||
if (k < 0) return;
|
||||
const { open, glow } = tvOn(k);
|
||||
const ca = a * Math.min(1, k * 5);
|
||||
const hh = ph * open;
|
||||
const yy = sy + (ph - hh) / 2;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = ca;
|
||||
ctx.beginPath();
|
||||
ctx.rect(sx, yy, pw, Math.max(1, hh));
|
||||
ctx.clip();
|
||||
ctx.filter = 'saturate(0.6) contrast(1.15) brightness(0.9)';
|
||||
drawCover(c.img, sx, yy, pw, Math.max(1, hh));
|
||||
ctx.filter = 'none';
|
||||
ctx.restore();
|
||||
if (open > 0.95) total += detBoxes(c.det, sx, sy, pw, ph, ca * smooth(k, 0.35, 0.6), t);
|
||||
if (glow > 0.02) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = ca * glow;
|
||||
ctx.fillStyle = '#e8ffff';
|
||||
ctx.fillRect(sx, yy, pw, Math.max(2 * u, hh));
|
||||
ctx.restore();
|
||||
}
|
||||
brackets(sx - 3 * u, sy - 3 * u, pw + 6 * u, ph + 6 * u, 8 * u, CY, ca * 0.8, 1);
|
||||
text((c.name || '').toUpperCase().slice(0, 30), sx + 5 * u, sy + 12 * u, { font: mono(8, 500), color: WH, alpha: ca, spacing: 0.5 * u });
|
||||
text(`DOT ${c.id || ''} · ${new Date(snapT.getTime() + t * 1000).toISOString().slice(11, 19)}`, sx + 5 * u, sy + ph - 6 * u, { font: mono(7), color: WH, alpha: ca * 0.8 });
|
||||
// tether to the camera's beam on the street
|
||||
if (c.x !== undefined) {
|
||||
const p = C.project(c.x, c.y, 90);
|
||||
if (p.vis) line(p.x, p.y, sx, sy + ph / 2, CY, ca * 0.35, 0.8);
|
||||
}
|
||||
});
|
||||
// street pulse: detections per frame across the cameras
|
||||
const pa2 = a * smooth(lt, 2.8, 3.4);
|
||||
if (pa2 > 0) {
|
||||
const gx = PORTRAIT ? M + 6 * u : W - M - pw;
|
||||
const gy = PORTRAIT ? H - M - 150 * u : H * 0.18 + 4 * (ph + 16 * u) + 16 * u;
|
||||
const gw = PORTRAIT ? W - 2 * M - 12 * u : pw;
|
||||
cityPulse(gx, gy, gw, 34 * u, pa2, t, nycCams, 'STREET PULSE · OBJECTS / FRAME');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// an ECG-style trace built from real per-camera detection counts
|
||||
function cityPulse(x, y, w, h, alpha, t, pool, label) {
|
||||
const vals = pool.map((c) => (c.det ? c.det.length : 0));
|
||||
if (!vals.length) return;
|
||||
const N = 90;
|
||||
const scroll = t * 14;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = CY;
|
||||
ctx.lineWidth = 1.3 * u;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const s = i + scroll;
|
||||
const idx = Math.floor(s / 6) % vals.length;
|
||||
const v = vals[idx] / Math.max(1, Math.max(...vals));
|
||||
const ph = (s % 6) / 6;
|
||||
// a heartbeat-shaped spike per camera, height = its object count
|
||||
const spike = ph < 0.12 ? -0.25 * Math.sin((ph / 0.12) * Math.PI) : ph < 0.28 ? Math.sin(((ph - 0.12) / 0.16) * Math.PI) * v : ph < 0.4 ? -0.2 * Math.sin(((ph - 0.28) / 0.12) * Math.PI) * v : 0;
|
||||
const px = x + (i / N) * w;
|
||||
const py = y + h * 0.65 - spike * h * 0.8;
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
line(x, y + h * 0.65, x + w, y + h * 0.65, CY, alpha * 0.2, 0.8);
|
||||
text(label, x, y - 4 * u, { font: mono(8, 500), color: CY, alpha, spacing: 1.2 * u });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- frame
|
||||
function draw(S) {
|
||||
ctx = S.ctx;
|
||||
@@ -1343,9 +1806,12 @@ export function createHUD(opts) {
|
||||
ctx.globalAlpha = 1;
|
||||
drawBoot(S);
|
||||
drawSeed(S);
|
||||
drawAwakening(S);
|
||||
drawDish(S);
|
||||
drawGrowth(S);
|
||||
drawIndexing(S);
|
||||
drawFlurry(S);
|
||||
drawCity(S);
|
||||
drawFeeds(S);
|
||||
drawCCTV(S);
|
||||
drawSaturation(S);
|
||||
|
||||
+162
-19
@@ -1,6 +1,7 @@
|
||||
import * as THREE from 'three';
|
||||
import * as satellite from 'satellite.js';
|
||||
import { T, FPS, FEEDS, captureTimes, clamp, lerp, ramp, smooth, win, easeInOut, expoOut, mulberry32 } from './timeline.js';
|
||||
import { T, FPS, FEEDS, CITIES, DURATION, captureTimes, clamp, lerp, ramp, smooth, win, easeInOut, expoOut, mulberry32 } from './timeline.js';
|
||||
import { loadCity, buildCity, loopPath } from './city.js';
|
||||
import { llToVec, subsolar, angDist, D2R, R2D, EARTH_KM } from './geo.js';
|
||||
import { createGlobe } from './globe.js';
|
||||
import { createCoastline, createBorders, createMycelium, createCables, createArcs, createLattice } from './lines.js';
|
||||
@@ -282,7 +283,60 @@ async function init() {
|
||||
};
|
||||
|
||||
const capT = captureTimes(heroes.length);
|
||||
const hud = createHUD({ W, H, PORTRAIT, heroes, cams, counts, rapidList, pick, capT, snapT, sunLL });
|
||||
// v2 assets: the capture flurry, street cameras, detections
|
||||
const flurryIdx = (await json('imagery/flurry/index.json')) || [];
|
||||
const flurry = [];
|
||||
for (const f of flurryIdx) {
|
||||
const im = await img(`imagery/flurry/${f.file}`);
|
||||
if (im) flurry.push({ ...f, img: im });
|
||||
}
|
||||
const detections = (await json('cctv/detections.json')) || {};
|
||||
const nycIdx = (await json('cctv/nyc/index.json')) || [];
|
||||
const nycCams = [];
|
||||
for (const c of nycIdx) {
|
||||
const im = await img(`cctv/nyc/${c.file}`);
|
||||
if (im) nycCams.push({ ...c, img: im, det: detections[`nyc/${c.file}`] || detections[`cctv/nyc/${c.file}`] || [] });
|
||||
}
|
||||
for (const c of cams) c.det = detections[c.file] || detections[`cctv/${c.file}`] || [];
|
||||
status(`v2 assets: ${flurry.length} flurry, ${nycCams.length} nyc cams, ${Object.keys(detections).length} detection sets`);
|
||||
|
||||
// ---------------------------------------------------------------- cities
|
||||
const cityCam = new THREE.PerspectiveCamera(PORTRAIT ? 60 : 36, W / H, 0.05, 600);
|
||||
const cityScenes = [];
|
||||
for (const c of CITIES) {
|
||||
const d = await loadCity(c.slug);
|
||||
if (!d.meta) {
|
||||
console.warn('city missing', c.slug);
|
||||
continue;
|
||||
}
|
||||
const built = buildCity(d, { modeled: modeledFor(c.slug, d) });
|
||||
cityScenes.push({ ...c, d, built });
|
||||
status(`city ${c.slug}: ${JSON.stringify(built.stats)}`);
|
||||
}
|
||||
window.__cities = cityScenes;
|
||||
const CITY_CAMS = {
|
||||
everest: [[[17, 26, 22], [0, 4.5, 0]], [[7, 16, 17], [0, 6, -1]], [[-9, 12.5, 13], [0, 6.5, 0]]],
|
||||
nyc: [[[0.2, 7.5, 2.2], [0, 0, -0.3]], [[1.7, 2.7, 2.5], [0, 0.1, -0.4]], [[2.7, 1.15, 0.5], [0.1, 0.15, -0.6]]],
|
||||
boston: [[[-0.4, 5.6, 3.4], [0, 0, 0]], [[1.3, 2.5, 2.7], [0, 0, -0.2]], [[2.5, 1.35, 1.0], [0.1, 0.05, -0.3]]],
|
||||
lax: [[[-4.2, 2.8, 3.6], [0, 0, 0]], [[-1.0, 1.5, 3.0], [0.3, 0, 0]], [[1.9, 0.95, 2.3], [0.6, 0, -0.2]]],
|
||||
bragg: [[[0, 9, 8], [0, 0, 0]], [[-3.2, 4.8, 5.2], [0.3, 0, 0]], [[-5.2, 2.8, 2.1], [0.5, 0, 0]]],
|
||||
};
|
||||
const camCurves = {};
|
||||
for (const [k, keys] of Object.entries(CITY_CAMS)) {
|
||||
const s = PORTRAIT ? 1.18 : 1;
|
||||
camCurves[k] = {
|
||||
pos: new THREE.CatmullRomCurve3(keys.map(([p, q]) => new THREE.Vector3(q[0] + (p[0] - q[0]) * s, q[1] + (p[1] - q[1]) * s, q[2] + (p[2] - q[2]) * s))),
|
||||
tgt: new THREE.CatmullRomCurve3(keys.map(([, q]) => new THREE.Vector3(...q))),
|
||||
};
|
||||
}
|
||||
const KMV = 0.001;
|
||||
const cv = new THREE.Vector3();
|
||||
function projectCity(x, y, z = 0) {
|
||||
cv.set(x * KMV, z * KMV, -y * KMV).project(cityCam);
|
||||
return { x: (cv.x * 0.5 + 0.5) * W, y: (-cv.y * 0.5 + 0.5) * H, vis: cv.z < 1 && cv.z > -1 };
|
||||
}
|
||||
|
||||
const hud = createHUD({ W, H, PORTRAIT, heroes, cams, counts, rapidList, pick, capT, snapT, sunLL, flurry, nycCams });
|
||||
|
||||
// ---------------------------------------------------------------- camera director
|
||||
// Keys: [t, lon, lat, g (globe radius / half the short side), shiftX, shiftY, roll]
|
||||
@@ -293,24 +347,54 @@ async function init() {
|
||||
[8.7, 0, 0, 0.6, 0, 0, 0],
|
||||
[16.6, -1.5, 1.5, 0.62, 0, 0, 0],
|
||||
[19.5, 4, 6, 0.7, 0, 0, -0.01],
|
||||
[24.5, 18, 16, 0.75, 0, 0, -0.02],
|
||||
[30.0, 24, 36, 0.98, sx * 0.5, sy * 0.5, -0.02],
|
||||
[32.5, 14, 58, 1.85, sx, sy, -0.03],
|
||||
[33.7, 8, 59, 1.9, sx, sy, -0.03],
|
||||
[35.9, -55, 43, 1.0, sx, sy, -0.01],
|
||||
[37.2, -82, 38, 0.92, sx, sy, 0.0],
|
||||
[40.0, -110, 26, 0.42, sx, sy, 0.02],
|
||||
[42.6, -150, 18, 0.62, sx, sy, 0.02],
|
||||
[44.4, -190, 2, 0.7, sx, sy, 0.01],
|
||||
[46.4, -215, 4, 0.78, sx, sy, 0.0],
|
||||
[49.0, -228, 14, 0.85, sx, sy, 0.0],
|
||||
[52.4, -236, 20, 0.85, sx * 0.4, sy * 0.4, 0.0],
|
||||
[56.0, -258, 24, 0.5, 0, 0, 0.0],
|
||||
[60.5, -288, 22, 0.16, 0, 0, 0.0],
|
||||
[22.6, 14, 12, 0.74, 0, 0, -0.02],
|
||||
// the flurry: the planet whips a full turn, accelerating then braking
|
||||
[23.6, 20, 12, 0.74, 0, 0, -0.02],
|
||||
[25.0, 50, 14, 0.74, 0, 0, -0.03],
|
||||
[26.2, 120, 16, 0.72, 0, 0, -0.04],
|
||||
[27.2, 230, 18, 0.72, 0, 0, -0.04],
|
||||
[28.2, 330, 24, 0.78, 0, 0, -0.03],
|
||||
[29.2, 375, 30, 0.9, sx * 0.3, sy * 0.3, -0.02],
|
||||
[30.0, 384, 36, 0.98, sx * 0.5, sy * 0.5, -0.02],
|
||||
[31.8, 374, 58, 1.85, sx, sy, -0.03],
|
||||
[32.8, 368, 59, 1.9, sx, sy, -0.03],
|
||||
[34.4, 305, 43, 1.0, sx, sy, -0.01],
|
||||
[35.6, 268, 30, 0.5, sx, sy, 0.0],
|
||||
[37.6, 225, 20, 0.62, sx, sy, 0.02],
|
||||
[39.4, 175, 2, 0.7, sx, sy, 0.01],
|
||||
[41.0, 150, 6, 0.78, sx, sy, 0.0],
|
||||
// descent: swing to the Himalaya and fall in
|
||||
[42.4, 100, 22, 1.1, sx * 0.4, sy * 0.4, 0.0],
|
||||
[43.2, 88, 27.5, 3.5, 0, 0, 0.0],
|
||||
[43.7, 87, 28, 9.0, 0, 0, 0.0],
|
||||
// behind the camera wall, and back out for saturation
|
||||
[71.0, 205, 16, 0.9, 0, 0, 0.0],
|
||||
[76.4, 200, 18, 0.9, 0, 0, 0.0],
|
||||
[80.0, 178, 24, 0.5, 0, 0, 0.0],
|
||||
[84.4, 148, 22, 0.16, 0, 0, 0.0],
|
||||
];
|
||||
// Title framing: the planet becomes the iris of the logo.
|
||||
const eye = hud.eyeLayout;
|
||||
|
||||
// Each flurry capture fires as its real location rotates into view on the
|
||||
// leading (east) side of the disc during the spin (18° east of centre).
|
||||
{
|
||||
const samples = [];
|
||||
for (let i = 0; i <= 600; i++) {
|
||||
const tt = T.flurry + (i / 600) * (T.flurryEnd - T.flurry);
|
||||
samples.push([tt, camAt(tt).lon]);
|
||||
}
|
||||
const lo = samples[0][1];
|
||||
const hi = samples[samples.length - 1][1];
|
||||
for (const f of flurry) {
|
||||
let L = f.lon - 18;
|
||||
while (L < lo) L += 360;
|
||||
if (L > hi) continue;
|
||||
const s = samples.find((q) => q[1] >= L);
|
||||
if (s) f.t = s[0] + (hash01(f.lat * 13.3) - 0.5) * 0.08;
|
||||
}
|
||||
}
|
||||
|
||||
function camAt(t) {
|
||||
if (t >= T.title - 0.05) {
|
||||
const g = eye.irisR / (Math.min(W, H) / 2);
|
||||
@@ -439,6 +523,7 @@ async function init() {
|
||||
function renderFrame(frame) {
|
||||
const t = frame / FPS;
|
||||
const cam = placeCamera(t);
|
||||
const cityNow = cityScenes.find((c) => t >= c.t0 && t < c.t1);
|
||||
const G = globe.uniforms;
|
||||
const front = frontAngle(t);
|
||||
const titleOn = t >= T.title - 0.05;
|
||||
@@ -574,6 +659,30 @@ async function init() {
|
||||
S.camPos = camera.position;
|
||||
S.latticeR = latticePx(t, cam.dist);
|
||||
S.ringR = ringR0;
|
||||
let renderScene = scene;
|
||||
let renderCam = camera;
|
||||
if (cityNow) {
|
||||
const lt = t - cityNow.t0;
|
||||
const k = (t - cityNow.t0) / (cityNow.t1 - cityNow.t0);
|
||||
const u = 1 - Math.pow(1 - k, 2.2);
|
||||
const cc = camCurves[cityNow.slug];
|
||||
cityCam.position.copy(cc.pos.getPoint(u));
|
||||
const tg = cc.tgt.getPoint(u);
|
||||
// a hair of handheld drift
|
||||
cityCam.position.x += Math.sin(t * 0.7) * 0.01;
|
||||
cityCam.position.y += Math.sin(t * 0.53) * 0.008;
|
||||
cityCam.lookAt(tg);
|
||||
const sh = impulse(t, 9) * 0.003;
|
||||
cityCam.rotateZ(Math.sin(t * 91) * sh);
|
||||
cityCam.updateMatrixWorld();
|
||||
cityNow.built.update(lt, k, pxScale);
|
||||
// Boston is the transit showcase: let the GTFS lines lead
|
||||
if (cityNow.slug === 'boston' && cityNow.built.roads) cityNow.built.roads.mesh.material.uniforms.uAlpha.value = 0.45;
|
||||
renderScene = cityNow.built.scene;
|
||||
renderCam = cityCam;
|
||||
S.city = { ...cityNow, lt, k, project: projectCity, built: cityNow.built };
|
||||
S.cam = { ...S.cam, lat: cityNow.lat, lon: cityNow.lon, dist: 1 + cityCam.position.y / 6371 };
|
||||
}
|
||||
hud.draw(S);
|
||||
|
||||
// ---- post
|
||||
@@ -585,7 +694,9 @@ async function init() {
|
||||
win(t, T.cctvOut - 0.05, T.cctvOut + 0.3, 0.02, 0.25) * 0.8 +
|
||||
win(t, T.dishBreak - 0.02, T.dishBreak + 0.4, 0.02, 0.3) * 1.0 +
|
||||
win(t, T.cut - 0.25, T.cut, 0.1, 0.01) * 1.0 +
|
||||
win(t, T.eyeOpen - 0.02, T.eyeOpen + 0.2, 0.02, 0.15) * 0.4;
|
||||
win(t, T.eyeOpen - 0.02, T.eyeOpen + 0.2, 0.02, 0.15) * 0.4 +
|
||||
win(t, T.terrain - 0.35, T.terrain + 0.25, 0.25, 0.2) * 1.0 +
|
||||
CITIES.slice(1).reduce((s, c) => s + win(t, c.t0 - 0.06, c.t0 + 0.22, 0.04, 0.16) * 0.8, 0);
|
||||
const flash =
|
||||
Math.exp(-Math.max(0, t - T.substrate) * 12) * 0.035 * (t >= T.substrate ? 1 : 0) +
|
||||
Math.exp(-Math.max(0, t - T.indexed) * 12) * 0.05 * (t >= T.indexed ? 1 : 0) +
|
||||
@@ -594,7 +705,7 @@ async function init() {
|
||||
let fade = smooth(t, 0, 0.6);
|
||||
if (t >= T.cut && t < T.title) fade = 0;
|
||||
fade *= 1 - smooth(t, T.fadeOut, DURATION_END);
|
||||
post.render(scene, camera, {
|
||||
post.render(renderScene, renderCam, {
|
||||
time: t,
|
||||
frame,
|
||||
bloom: t < T.seed ? 0.7 : lerp(0.85, 1.15, smooth(t, T.saturate, T.cut)),
|
||||
@@ -646,7 +757,39 @@ async function init() {
|
||||
if (qs.has('play')) startPlayback(renderFrame);
|
||||
}
|
||||
|
||||
const DURATION_END = 72;
|
||||
const DURATION_END = DURATION;
|
||||
const hash01 = (n) => {
|
||||
const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
|
||||
return s - Math.floor(s);
|
||||
};
|
||||
|
||||
// Modelled actors (always labelled MODELED in the HUD) where real feeds are thin.
|
||||
function modeledFor(slug, d) {
|
||||
const out = [];
|
||||
if (slug === 'nyc') {
|
||||
const realHelis = (d.air?.a || []).filter((p) => p[8] === 'A7').length;
|
||||
if (realHelis < 3) {
|
||||
// Hudson and East River corridors, and a hover orbit over Midtown
|
||||
out.push({ name: 'HUDSON CORRIDOR', path: [[-1750, -3300, 300], [-1950, -1000, 330], [-1800, 1500, 350], [-1450, 3300, 320], [-1250, 1500, 300], [-1400, -1000, 290]], speed: 0.09, phase: 0.1, color: '#7dffd6' });
|
||||
out.push({ name: 'EAST RIVER', path: [[1650, -3200, 260], [1900, -800, 280], [2150, 1800, 300], [1750, 2600, 300], [1450, 800, 270], [1350, -1500, 250]], speed: 0.08, phase: 0.55, color: '#7dffd6' });
|
||||
out.push({ name: 'MIDTOWN ORBIT', path: loopPath(-150, 900, 420, 380, 450, 0.3, 40), speed: 0.16, phase: 0.2, color: '#7dffd6' });
|
||||
}
|
||||
}
|
||||
if (slug === 'bragg') {
|
||||
const rw = d.military?.runways?.[0];
|
||||
let cx = 0, cy = 0, rot = 0;
|
||||
if (rw) {
|
||||
const x1 = rw[2], y1 = rw[3], x2 = rw[rw.length - 2], y2 = rw[rw.length - 1];
|
||||
cx = (x1 + x2) / 2;
|
||||
cy = (y1 + y2) / 2;
|
||||
rot = Math.atan2(y2 - y1, x2 - x1);
|
||||
}
|
||||
out.push({ name: 'PATTERN', path: loopPath(cx, cy, 2600, 1100, 330, rot, 48), speed: 0.07, phase: 0.0, color: '#ffb547' });
|
||||
out.push({ name: 'PATTERN', path: loopPath(cx, cy, 2600, 1100, 330, rot, 48), speed: 0.07, phase: 0.5, color: '#ffb547' });
|
||||
out.push({ name: 'ROTARY', path: loopPath(cx - 3000, cy + 2500, 900, 700, 180, 0.4, 32), speed: 0.11, phase: 0.3, color: '#ffb547' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Real-time mode (?play): the same deterministic frames, driven by the audio
|
||||
// clock, for use as a live loading sequence on a GPU. Browsers block audio
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// stay frame-locked. Pure module: no DOM, safe to import from Node.
|
||||
|
||||
export const FPS = 30;
|
||||
export const DURATION = 72;
|
||||
export const DURATION = 95;
|
||||
export const FRAMES = FPS * DURATION;
|
||||
|
||||
export const T = {
|
||||
@@ -16,50 +16,61 @@ export const T = {
|
||||
beat2: 7.35,
|
||||
dishDraw: 6.3,
|
||||
dishDone: 8.3,
|
||||
aperture: 7.35,
|
||||
|
||||
// ACT 1 — the culture grows; the growth turns out to be drawing the planet
|
||||
// ACT 1 — the eye opens; the culture grows and turns out to be drawing the planet
|
||||
growStart: 8.7,
|
||||
coastReached: 13.4,
|
||||
growEnd: 15.6,
|
||||
substrate: 16.0,
|
||||
|
||||
// ACT 2 — indexing the surface
|
||||
// ACT 2 — indexing the surface, accelerating until it is whipping round the world
|
||||
indexStart: 17.0,
|
||||
zWave: [17.0, 19.0, 20.8], // z3, z4, z5 quadtree waves
|
||||
capStart: 18.3,
|
||||
capRapid: 23.4,
|
||||
capFull: 26.6,
|
||||
zWave: [17.0, 18.5, 19.8], // z3, z4, z5 quadtree waves
|
||||
capStart: 18.0,
|
||||
capRapid: 22.6,
|
||||
flurry: 23.6,
|
||||
flurryEnd: 29.4,
|
||||
capFull: 999, // v1's full-frame extraction now lives in the city act
|
||||
indexed: 30.0,
|
||||
|
||||
// ACT 3 — ingestion, one feed at a time, accelerating
|
||||
// ACT 3 — global ingestion
|
||||
air: 30.6,
|
||||
sea: 32.2,
|
||||
mil: 34.2,
|
||||
orbit: 37.0,
|
||||
quake: 40.2,
|
||||
fire: 42.2,
|
||||
wind: 44.0,
|
||||
cable: 45.9,
|
||||
cctv: 47.4,
|
||||
cctvWall: 49.0,
|
||||
rapid: 50.4,
|
||||
cctvOut: 52.4,
|
||||
sea: 32.0,
|
||||
mil: 33.4,
|
||||
orbit: 34.6,
|
||||
quake: 36.6,
|
||||
fire: 38.0,
|
||||
wind: 39.4,
|
||||
cable: 41.0,
|
||||
descend: 42.6,
|
||||
|
||||
// ACT 4 — saturation; the twin outgrows the dish
|
||||
saturate: 52.6,
|
||||
arcs: 53.4,
|
||||
twin: 55.2,
|
||||
dishBreak: 58.4,
|
||||
omni: 59.0,
|
||||
cut: 60.5,
|
||||
// ACT 4 — down into the world
|
||||
terrain: 43.6,
|
||||
nyc: 49.0,
|
||||
boston: 57.0,
|
||||
lax: 61.8,
|
||||
bragg: 66.4,
|
||||
cctv: 71.4,
|
||||
cctvWall: 71.4,
|
||||
rapid: 72.8,
|
||||
cctvOut: 76.4,
|
||||
|
||||
// ACT 5 — resolve
|
||||
title: 61.2,
|
||||
eyeOpen: 62.0,
|
||||
wordmark: 63.3,
|
||||
tagline: 65.0,
|
||||
online: 66.6,
|
||||
fadeOut: 70.0,
|
||||
// ACT 5 — saturation; the twin outgrows the dish
|
||||
saturate: 76.6,
|
||||
arcs: 77.3,
|
||||
twin: 79.1,
|
||||
dishBreak: 82.3,
|
||||
omni: 82.9,
|
||||
cut: 84.4,
|
||||
|
||||
// ACT 6 — resolve
|
||||
title: 85.1,
|
||||
eyeOpen: 85.9,
|
||||
wordmark: 87.2,
|
||||
tagline: 88.9,
|
||||
online: 999,
|
||||
fadeOut: 93.0,
|
||||
};
|
||||
|
||||
// Feeds as they register in the stack (ACT 3). `key` matches a layer.
|
||||
@@ -72,7 +83,15 @@ export const FEEDS = [
|
||||
{ key: 'fire', t: T.fire, code: 'IR', label: 'ACTIVE FIRES', src: 'NASA FIRMS' },
|
||||
{ key: 'wind', t: T.wind, code: 'NWP', label: 'WIND · CLOUD', src: 'NOAA GFS · GIBS' },
|
||||
{ key: 'cable', t: T.cable, code: 'GEO', label: 'SUBSEA CABLES', src: 'TELEGEOGRAPHY' },
|
||||
{ key: 'cctv', t: T.cctv, code: 'OPT', label: 'PUBLIC CAMERAS', src: '18 AGENCIES' },
|
||||
];
|
||||
|
||||
// ACT 4 — the cities, in order
|
||||
export const CITIES = [
|
||||
{ slug: 'everest', t0: T.terrain, t1: T.nyc, name: 'MOUNT EVEREST', sub: 'KHUMBU · NEPAL / TIBET', lat: 27.9881, lon: 86.925 },
|
||||
{ slug: 'nyc', t0: T.nyc, t1: T.boston, name: 'NEW YORK', sub: 'MIDTOWN · MANHATTAN', lat: 40.74, lon: -73.99 },
|
||||
{ slug: 'boston', t0: T.boston, t1: T.lax, name: 'BOSTON', sub: 'MBTA · GTFS REALTIME', lat: 42.3555, lon: -71.0605 },
|
||||
{ slug: 'lax', t0: T.lax, t1: T.bragg, name: 'LOS ANGELES INTL', sub: 'KLAX · AIRSIDE', lat: 33.9425, lon: -118.4081 },
|
||||
{ slug: 'bragg', t0: T.bragg, t1: T.cctv, name: 'FORT BRAGG', sub: 'NORTH CAROLINA · POPE FIELD', lat: 35.158, lon: -79.009 },
|
||||
];
|
||||
|
||||
// Synthetic captures in ACT 2: the first few take the slow "enhance" path, the
|
||||
@@ -80,7 +99,7 @@ export const FEEDS = [
|
||||
export const CAPTURE_SLOW = 3;
|
||||
export function captureTimes(count) {
|
||||
const out = [];
|
||||
const slot = [T.capStart, T.capStart + 1.75, T.capStart + 3.35];
|
||||
const slot = [T.capStart, T.capStart + 1.6, T.capStart + 3.1];
|
||||
for (let i = 0; i < Math.min(CAPTURE_SLOW, count); i++) out.push(slot[i]);
|
||||
const rapidN = count - CAPTURE_SLOW;
|
||||
// Accelerating rapid-fire: intervals shrink from ~0.32 s to ~0.11 s.
|
||||
@@ -88,7 +107,7 @@ export function captureTimes(count) {
|
||||
for (let i = 0; i < rapidN; i++) {
|
||||
out.push(t);
|
||||
const k = i / Math.max(1, rapidN - 1);
|
||||
t += 0.32 - 0.21 * k;
|
||||
t += 0.12 - 0.08 * k;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Render city/<slug>/check.png: ground imagery (left) and imagery + OSM overlay (right) for alignment QA.
|
||||
|
||||
usage: python3 -I tools/city/check.py nyc boston [--crop x y size]
|
||||
"""
|
||||
import sys, os, json
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from PIL import Image, ImageDraw
|
||||
from geo import CITIES, OUT, CACHE
|
||||
|
||||
ROADC = [(255, 60, 60), (255, 150, 40), (255, 220, 40), (200, 255, 80), (255, 255, 255), (160, 160, 160)]
|
||||
|
||||
|
||||
def render(slug, S=1400, crop=None):
|
||||
d = os.path.join(OUT, slug)
|
||||
h = CITIES[slug]['half']
|
||||
cx, cy, cs = crop if crop else (0, 0, h) # center and half-size of view in meters
|
||||
g = Image.open(os.path.join(d, 'ground.jpg')).convert('RGB')
|
||||
G = g.size[0]
|
||||
# crop region of ground in pixels
|
||||
l = (cx - cs + h) / (2 * h) * G
|
||||
t = (h - (cy + cs)) / (2 * h) * G
|
||||
r = (cx + cs + h) / (2 * h) * G
|
||||
b = (h - (cy - cs)) / (2 * h) * G
|
||||
base = g.crop((int(l), int(t), int(r), int(b))).resize((S, S), Image.LANCZOS)
|
||||
|
||||
def P(x, y):
|
||||
return ((x - (cx - cs)) / (2 * cs) * S, ((cy + cs) - y) / (2 * cs) * S)
|
||||
|
||||
ov = base.copy()
|
||||
dr = ImageDraw.Draw(ov, 'RGBA')
|
||||
wp = os.path.join(d, 'water.json')
|
||||
if os.path.exists(wp):
|
||||
for poly in json.load(open(wp))['polys']:
|
||||
for k, ring in enumerate(poly):
|
||||
pts = [P(ring[i], ring[i + 1]) for i in range(0, len(ring), 2)]
|
||||
if len(pts) >= 3:
|
||||
dr.polygon(pts, outline=(60, 160, 255, 255), fill=(30, 90, 255, 70) if k == 0 else None)
|
||||
for bld in json.load(open(os.path.join(d, 'buildings.json')))['b']:
|
||||
hgt = bld[0]
|
||||
pts = [P(bld[i], bld[i + 1]) for i in range(2, len(bld), 2)]
|
||||
a = min(255, 60 + int(hgt))
|
||||
col = (0, 255, 200, 255) if hgt >= 100 else (255, 0, 220, 255)
|
||||
dr.polygon(pts, outline=col, fill=(col[0], col[1], col[2], 40 if hgt < 100 else 90))
|
||||
for rd in json.load(open(os.path.join(d, 'roads.json')))['roads']:
|
||||
pts = [P(rd[i], rd[i + 1]) for i in range(3, len(rd), 2)]
|
||||
dr.line(pts, fill=ROADC[rd[0]] + (230,), width=2 if rd[0] < 3 else 1)
|
||||
rp = os.path.join(d, 'rail.json')
|
||||
if os.path.exists(rp):
|
||||
for ln in json.load(open(rp))['lines']:
|
||||
c = ln[0]
|
||||
col = tuple(int(c[i:i + 2], 16) for i in (1, 3, 5)) + (255,)
|
||||
pts = [P(ln[i], ln[i + 1]) for i in range(1, len(ln), 2)]
|
||||
dr.line(pts, fill=col, width=3)
|
||||
ep = os.path.join(d, 'extra.json')
|
||||
if os.path.exists(ep):
|
||||
for x, y, nm in json.load(open(ep))['helipads']:
|
||||
px, py = P(x, y)
|
||||
dr.ellipse((px - 7, py - 7, px + 7, py + 7), outline=(255, 255, 0, 255), width=3)
|
||||
out = Image.new('RGB', (S * 2 + 10, S), (0, 0, 0))
|
||||
out.paste(base, (0, 0))
|
||||
out.paste(ov, (S + 10, 0))
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = [a for a in sys.argv[1:] if not a.startswith('--')]
|
||||
crop = None
|
||||
if '--crop' in sys.argv:
|
||||
i = sys.argv.index('--crop')
|
||||
crop = tuple(float(v) for v in sys.argv[i + 1:i + 4])
|
||||
args = [a for a in args if a not in sys.argv[i + 1:i + 4]]
|
||||
for slug in args or list(CITIES):
|
||||
im = render(slug, crop=crop)
|
||||
if crop:
|
||||
p = os.path.join(CACHE, f'{slug}_check_crop_{int(crop[0])}_{int(crop[1])}_{int(crop[2])}.png')
|
||||
else:
|
||||
p = os.path.join(OUT, slug, 'check.png')
|
||||
im.save(p, optimize=True)
|
||||
print('wrote', p)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Esri World Imagery mosaic -> local ENU square, 4096^2 + 2048^2 JPEG.
|
||||
|
||||
usage: python3 -I tools/city/fetch_imagery.py nyc boston [--zoom 17]
|
||||
"""
|
||||
import sys, os, math, io, time
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
from scipy.ndimage import map_coordinates
|
||||
from geo import CITIES, OUT, CACHE, Frame
|
||||
|
||||
URL = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
|
||||
TS = 256
|
||||
N = 4096
|
||||
SS = 2 # supersample factor before Lanczos downsample
|
||||
|
||||
|
||||
def merc_px(lat, lon, z):
|
||||
s = TS * 2 ** z
|
||||
x = (lon + 180.0) / 360.0 * s
|
||||
lr = np.radians(lat)
|
||||
y = (1.0 - np.log(np.tan(lr) + 1.0 / np.cos(lr)) / math.pi) / 2.0 * s
|
||||
return x, y
|
||||
|
||||
|
||||
sess = requests.Session()
|
||||
sess.headers['User-Agent'] = 'gods-eye-view-intro/1.0 (film asset build)'
|
||||
|
||||
|
||||
def get_tile(z, x, y):
|
||||
d = os.path.join(CACHE, 'tiles', str(z), str(x))
|
||||
os.makedirs(d, exist_ok=True)
|
||||
p = os.path.join(d, f'{y}.jpg')
|
||||
if os.path.exists(p) and os.path.getsize(p) > 0:
|
||||
return p, 'cache'
|
||||
last = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
r = sess.get(URL.format(z=z, x=x, y=y), timeout=60)
|
||||
if r.status_code == 200 and r.content[:2] in (b'\xff\xd8', b'\x89P'):
|
||||
with open(p, 'wb') as f:
|
||||
f.write(r.content)
|
||||
return p, 'net'
|
||||
last = f'HTTP {r.status_code} {r.headers.get("content-type")}'
|
||||
except Exception as e: # noqa
|
||||
last = str(e)[:120]
|
||||
time.sleep(1.5 * (attempt + 1))
|
||||
raise RuntimeError(f'tile {z}/{x}/{y} failed: {last}')
|
||||
|
||||
|
||||
def build(slug, z):
|
||||
c = CITIES[slug]
|
||||
F = Frame(**c)
|
||||
h = F.half
|
||||
s, w, n, e = F.bbox(h * 1.02)
|
||||
x0, y0 = merc_px(n, w, z)
|
||||
x1, y1 = merc_px(s, e, z)
|
||||
tx0, ty0 = int(x0 // TS), int(y0 // TS)
|
||||
tx1, ty1 = int(x1 // TS), int(y1 // TS)
|
||||
tiles = [(tx, ty) for ty in range(ty0, ty1 + 1) for tx in range(tx0, tx1 + 1)]
|
||||
print(f'== {slug} z{z}: {tx1-tx0+1}x{ty1-ty0+1} = {len(tiles)} tiles')
|
||||
with ThreadPoolExecutor(8) as ex:
|
||||
res = list(ex.map(lambda t: get_tile(z, t[0], t[1]), tiles))
|
||||
print(f' net {sum(1 for r in res if r[1]=="net")} cache {sum(1 for r in res if r[1]=="cache")}')
|
||||
W, H = (tx1 - tx0 + 1) * TS, (ty1 - ty0 + 1) * TS
|
||||
mos = np.zeros((H, W, 3), np.uint8)
|
||||
for (tx, ty), (p, _) in zip(tiles, res):
|
||||
im = np.asarray(Image.open(p).convert('RGB'))
|
||||
mos[(ty - ty0) * TS:(ty - ty0 + 1) * TS, (tx - tx0) * TS:(tx - tx0 + 1) * TS] = im
|
||||
M = N * SS
|
||||
t = (np.arange(M) + 0.5) / M
|
||||
xs = -h + 2 * h * t # east
|
||||
ys = h - 2 * h * t # north, row 0 at top
|
||||
lon = F.lon0 + xs / F.kx
|
||||
lat = F.lat0 + ys / F.ky
|
||||
px, _ = merc_px(np.full_like(lon, F.lat0), lon, z)
|
||||
_, py = merc_px(lat, np.full_like(lat, F.lon0), z)
|
||||
px = px - tx0 * TS - 0.5 # pixel centers
|
||||
py = py - ty0 * TS - 0.5
|
||||
PX, PY = np.meshgrid(px, py)
|
||||
out = np.empty((M, M, 3), np.uint8)
|
||||
for ch in range(3):
|
||||
out[..., ch] = np.clip(map_coordinates(mos[..., ch].astype(np.float32), [PY, PX], order=1,
|
||||
mode='nearest'), 0, 255).astype(np.uint8)
|
||||
del PX, PY
|
||||
img = Image.fromarray(out)
|
||||
if SS != 1:
|
||||
img = img.resize((N, N), Image.LANCZOS)
|
||||
d = os.path.join(OUT, slug)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
img.save(os.path.join(d, 'ground.jpg'), quality=90, optimize=True)
|
||||
img.resize((2048, 2048), Image.LANCZOS).save(os.path.join(d, 'ground_2k.jpg'), quality=90, optimize=True)
|
||||
img.resize((768, 768), Image.LANCZOS).save(os.path.join(CACHE, f'{slug}_ground_preview.jpg'), quality=85)
|
||||
mpp_src = 156543.03392 * math.cos(math.radians(F.lat0)) / 2 ** z
|
||||
print(f' wrote ground.jpg {os.path.getsize(os.path.join(d, "ground.jpg"))/1e6:.1f}MB; '
|
||||
f'source {mpp_src:.2f} m/px, output {2*h/N:.2f} m/px')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = [a for a in sys.argv[1:] if not a.startswith('--')]
|
||||
z = 17
|
||||
if '--zoom' in sys.argv:
|
||||
z = int(sys.argv[sys.argv.index('--zoom') + 1])
|
||||
for slug in args or list(CITIES):
|
||||
build(slug, z)
|
||||
@@ -0,0 +1,495 @@
|
||||
"""Fetch OSM buildings / roads / water / extras for the city scenes and write city/<slug>/*.json.
|
||||
|
||||
usage: python3 -I tools/city/fetch_osm.py nyc boston
|
||||
"""
|
||||
import sys, os, math, re, json
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from geo import (CITIES, OUT, Frame, overpass, rdp, rdp_ring, area, centroid, open_ring,
|
||||
assemble_rings, clip_polyline, clip_polygon, flat, r1, write_json, dedupe)
|
||||
|
||||
FT = 0.3048
|
||||
|
||||
|
||||
def parse_len(v):
|
||||
"""Parse an OSM length tag to meters. Returns None if unparseable."""
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip().lower().replace(',', '.')
|
||||
s = s.split(';')[0].strip()
|
||||
m = re.match(r"^(\d+(?:\.\d+)?)\s*'\s*(?:(\d+(?:\.\d+)?)\s*\"?)?$", s) # 12'6"
|
||||
if m:
|
||||
return float(m.group(1)) * FT + (float(m.group(2)) * 0.0254 if m.group(2) else 0)
|
||||
m = re.match(r'^(-?\d+(?:\.\d+)?)\s*(m|meters?|metres?|ft|feet|foot)?$', s)
|
||||
if not m:
|
||||
return None
|
||||
val = float(m.group(1))
|
||||
u = m.group(2) or 'm'
|
||||
if u in ('ft', 'feet', 'foot'):
|
||||
val *= FT
|
||||
return val
|
||||
|
||||
|
||||
def parse_num(v):
|
||||
if v is None:
|
||||
return None
|
||||
m = re.match(r'^\s*(\d+(?:\.\d+)?)', str(v).replace(',', '.'))
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
|
||||
HOUSE = {'house', 'detached', 'semidetached_house', 'terrace', 'bungalow', 'cabin', 'shed', 'garage',
|
||||
'garages', 'hut', 'carport', 'static_caravan'}
|
||||
|
||||
|
||||
def building_height(t):
|
||||
"""Returns (height, min_height, source) where source in h/lv/def."""
|
||||
h = parse_len(t.get('height'))
|
||||
src = 'h'
|
||||
if h is None or h <= 0:
|
||||
lv = parse_num(t.get('building:levels'))
|
||||
if lv is not None and lv > 0:
|
||||
h = lv * 3.2 + (parse_num(t.get('roof:levels')) or 0) * 3.2
|
||||
src = 'lv'
|
||||
else:
|
||||
b = t.get('building', 'yes')
|
||||
h = 7.0 if b in HOUSE else 12.0
|
||||
if b in ('shed', 'garage', 'garages', 'carport', 'hut'):
|
||||
h = 3.5
|
||||
src = 'def'
|
||||
mh = parse_len(t.get('min_height'))
|
||||
if mh is None:
|
||||
ml = parse_num(t.get('building:min_level'))
|
||||
mh = ml * 3.2 if ml else 0.0
|
||||
mh = max(0.0, mh)
|
||||
if mh >= h:
|
||||
mh = 0.0
|
||||
return h, mh, src
|
||||
|
||||
|
||||
def ll_list(geom):
|
||||
return [(g['lat'], g['lon']) for g in geom if g is not None]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ buildings
|
||||
|
||||
def buildings(slug, F):
|
||||
h = F.half
|
||||
s, w, n, e = F.bbox(h + 60)
|
||||
# tile the bbox to keep each response manageable
|
||||
T = 2 if h > 3000 else 1
|
||||
elems = {}
|
||||
for i in range(T):
|
||||
for j in range(T):
|
||||
ts = s + (n - s) * i / T
|
||||
tn = s + (n - s) * (i + 1) / T
|
||||
tw = w + (e - w) * j / T
|
||||
te = w + (e - w) * (j + 1) / T
|
||||
bb = f'{ts:.6f},{tw:.6f},{tn:.6f},{te:.6f}'
|
||||
q = (f'[out:json][timeout:180];(way["building"]({bb});'
|
||||
f'relation["building"]["type"="multipolygon"]({bb}););out geom;')
|
||||
print(f' buildings tile {i},{j}')
|
||||
d = overpass(q, f'{slug}_bld_{i}{j}')
|
||||
for el in d['elements']:
|
||||
elems[(el['type'], el['id'])] = el
|
||||
print(f' {len(elems)} building elements')
|
||||
out = []
|
||||
stats = dict(h=0, lv=0, df=0, dropped_small=0, dropped_out=0, rel_rings=0)
|
||||
for (typ, _id), el in elems.items():
|
||||
t = el.get('tags', {})
|
||||
if t.get('building') in ('no', 'construction_site'):
|
||||
continue
|
||||
rings = []
|
||||
if typ == 'way':
|
||||
if 'geometry' not in el:
|
||||
continue
|
||||
g = ll_list(el['geometry'])
|
||||
if len(g) >= 4 and g[0] == g[-1]:
|
||||
rings.append(g)
|
||||
else:
|
||||
outers = [ll_list(m['geometry']) for m in el.get('members', [])
|
||||
if m['type'] == 'way' and m.get('role') == 'outer' and 'geometry' in m]
|
||||
rings = assemble_rings(outers)
|
||||
stats['rel_rings'] += len(rings)
|
||||
hgt, mh, src = building_height(t)
|
||||
for g in rings:
|
||||
pts = open_ring([F.xy(la, lo) for la, lo in g])
|
||||
pts = rdp_ring(pts, 0.5)
|
||||
if len(pts) < 3 or abs(area(pts)) < 8:
|
||||
stats['dropped_small'] += 1
|
||||
continue
|
||||
cx, cy = centroid(pts)
|
||||
if abs(cx) > h or abs(cy) > h:
|
||||
stats['dropped_out'] += 1
|
||||
continue
|
||||
stats[{'h': 'h', 'lv': 'lv', 'def': 'df'}[src]] += 1
|
||||
out.append((cx * cx + cy * cy, [r1(hgt), r1(mh)] + flat(pts)))
|
||||
out.sort(key=lambda a: a[0])
|
||||
b = [o[1] for o in out]
|
||||
write_json(os.path.join(OUT, slug, 'buildings.json'), {'count': len(b), 'b': b})
|
||||
hs = sorted(x[0] for x in b)
|
||||
print(f' buildings {len(b)} height-src tag={stats["h"]} levels={stats["lv"]} default={stats["df"]} '
|
||||
f'dropped small={stats["dropped_small"]} outside={stats["dropped_out"]} tallest={hs[-5:]}')
|
||||
return dict(count=len(b), height_from_tag=stats['h'], height_from_levels=stats['lv'],
|
||||
height_default=stats['df'], tallest_m=hs[-1] if hs else None)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ roads
|
||||
|
||||
CLS = {'motorway': 0, 'trunk': 0, 'primary': 1, 'secondary': 2, 'tertiary': 3,
|
||||
'residential': 4, 'unclassified': 4, 'service': 5}
|
||||
DEF_LANES = {0: 3, 1: 2, 2: 2, 3: 2, 4: 1, 5: 1}
|
||||
|
||||
|
||||
def plen(pts):
|
||||
return sum(math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]) for i in range(len(pts) - 1))
|
||||
|
||||
|
||||
def roads(slug, F):
|
||||
h = F.half
|
||||
s, w, n, e = F.bbox(h + 100)
|
||||
bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}'
|
||||
q = (f'[out:json][timeout:180];way["highway"~"^(motorway|trunk|primary|secondary|tertiary|residential|'
|
||||
f'unclassified|service|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link)$"]({bb});out geom;')
|
||||
print(' roads')
|
||||
d = overpass(q, f'{slug}_roads')
|
||||
out = []
|
||||
cnt = [0] * 6
|
||||
tunnels = 0
|
||||
short_service = 0
|
||||
for el in d['elements']:
|
||||
t = el.get('tags', {})
|
||||
hw = t['highway']
|
||||
if t.get('area') == 'yes':
|
||||
continue
|
||||
if t.get('tunnel') in ('yes', 'covered') or t.get('layer', '0').startswith('-') and t.get('tunnel'):
|
||||
tunnels += 1
|
||||
continue
|
||||
pts = [F.xy(la, lo) for la, lo in ll_list(el.get('geometry', []))]
|
||||
pts = dedupe(pts)
|
||||
if len(pts) < 2:
|
||||
continue
|
||||
if hw.endswith('_link'):
|
||||
c = 5
|
||||
else:
|
||||
c = CLS[hw]
|
||||
if hw == 'service' and plen(pts) <= 80:
|
||||
short_service += 1
|
||||
continue
|
||||
ow = t.get('oneway', '')
|
||||
oneway = 0
|
||||
if ow in ('yes', '1', 'true'):
|
||||
oneway = 1
|
||||
elif ow == '-1':
|
||||
oneway = 1
|
||||
pts = pts[::-1]
|
||||
elif ow == 'no':
|
||||
oneway = 0
|
||||
elif hw in ('motorway', 'motorway_link') or t.get('junction') in ('roundabout', 'circular'):
|
||||
oneway = 1
|
||||
ln = parse_num(t.get('lanes'))
|
||||
lanes = int(ln) if ln else DEF_LANES[c]
|
||||
for piece in clip_polyline(pts, -h, -h, h, h):
|
||||
piece = rdp(piece, 1.0)
|
||||
if len(piece) < 2 or plen(piece) < 1:
|
||||
continue
|
||||
out.append([c, oneway, lanes] + flat(piece))
|
||||
cnt[c] += 1
|
||||
write_json(os.path.join(OUT, slug, 'roads.json'), {'roads': out})
|
||||
print(f' roads {len(out)} by class {cnt} (dropped tunnels {tunnels}, short service {short_service})')
|
||||
return dict(count=len(out), by_class=cnt, dropped_tunnel_ways=tunnels, dropped_short_service=short_service)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ water
|
||||
|
||||
def coast_water(slug, F, h):
|
||||
"""Build water polygons inside the square from natural=coastline (land left, water right)."""
|
||||
s, w, n, e = F.bbox(h * 1.6)
|
||||
bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}'
|
||||
q = f'[out:json][timeout:180];way["natural"="coastline"]({bb});out geom;'
|
||||
print(' coastline')
|
||||
d = overpass(q, f'{slug}_coast')
|
||||
ways = [[F.xy(la, lo) for la, lo in ll_list(el['geometry'])] for el in d['elements'] if 'geometry' in el]
|
||||
# directional merge into chains
|
||||
chains = []
|
||||
pool = [wy for wy in ways if len(wy) >= 2]
|
||||
while pool:
|
||||
cur = pool.pop()
|
||||
grown = True
|
||||
while grown and cur[0] != cur[-1]:
|
||||
grown = False
|
||||
for i, wy in enumerate(pool):
|
||||
if wy[0] == cur[-1]:
|
||||
cur = cur + wy[1:]
|
||||
elif wy[-1] == cur[0]:
|
||||
cur = wy + cur[1:]
|
||||
else:
|
||||
continue
|
||||
pool.pop(i)
|
||||
grown = True
|
||||
break
|
||||
chains.append(cur)
|
||||
eps = 1e-6
|
||||
|
||||
def on_edge(p):
|
||||
return abs(abs(p[0]) - h) < 1e-3 or abs(abs(p[1]) - h) < 1e-3
|
||||
|
||||
def spos(p): # clockwise perimeter coordinate from top-left
|
||||
x, y = p
|
||||
if abs(y - h) < 1e-3:
|
||||
return x + h
|
||||
if abs(x - h) < 1e-3:
|
||||
return 2 * h + (h - y)
|
||||
if abs(y + h) < 1e-3:
|
||||
return 4 * h + (h - x)
|
||||
return 6 * h + (y + h)
|
||||
|
||||
corners = [(0.0, (-h, h)), (2 * h, (h, h)), (4 * h, (h, -h)), (6 * h, (-h, -h))]
|
||||
pieces, islands, bad = [], [], 0
|
||||
for ch in chains:
|
||||
closed = ch[0] == ch[-1]
|
||||
inside_all = all(abs(x) <= h and abs(y) <= h for x, y in ch)
|
||||
if closed and inside_all:
|
||||
islands.append(open_ring(ch))
|
||||
continue
|
||||
for pc in clip_polyline(ch, -h, -h, h, h):
|
||||
if on_edge(pc[0]) and on_edge(pc[-1]):
|
||||
pieces.append(pc)
|
||||
else:
|
||||
bad += 1
|
||||
polys = []
|
||||
if pieces:
|
||||
used = [False] * len(pieces)
|
||||
starts = [spos(p[0]) for p in pieces]
|
||||
for k in range(len(pieces)):
|
||||
if used[k]:
|
||||
continue
|
||||
ring = []
|
||||
cur = k
|
||||
guard = 0
|
||||
while True:
|
||||
used[cur] = True
|
||||
ring += pieces[cur]
|
||||
se = spos(pieces[cur][-1])
|
||||
# next entry clockwise from se
|
||||
best, bd = None, None
|
||||
for m in range(len(pieces)):
|
||||
dd = (starts[m] - se) % (8 * h)
|
||||
if bd is None or dd < bd:
|
||||
best, bd = m, dd
|
||||
# add corners passed between se and se+bd
|
||||
for cs, cp in sorted(corners, key=lambda c: (c[0] - se) % (8 * h)):
|
||||
if 0 < (cs - se) % (8 * h) < bd:
|
||||
ring.append(cp)
|
||||
cur = best
|
||||
guard += 1
|
||||
if cur == k or guard > len(pieces) + 2:
|
||||
break
|
||||
if used[cur]:
|
||||
break
|
||||
polys.append([dedupe(open_ring(ring))])
|
||||
# attach islands (land holes) to the containing water polygon
|
||||
def pip(pt, ring):
|
||||
x, y = pt
|
||||
c = False
|
||||
for i in range(len(ring)):
|
||||
x1, y1 = ring[i]
|
||||
x2, y2 = ring[i - 1]
|
||||
if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:
|
||||
c = not c
|
||||
return c
|
||||
for isl in islands:
|
||||
if abs(area(isl)) < 50:
|
||||
continue
|
||||
for p in polys:
|
||||
if pip(isl[0], p[0]):
|
||||
p.append(isl)
|
||||
break
|
||||
print(f' coastline chains {len(chains)} pieces {len(pieces)} islands {len(islands)} '
|
||||
f'water polys {len(polys)} (unclosed pieces skipped {bad})')
|
||||
return polys, bad
|
||||
|
||||
|
||||
def water(slug, F):
|
||||
h = F.half
|
||||
s, w, n, e = F.bbox(h + 50)
|
||||
bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}'
|
||||
q = (f'[out:json][timeout:180];(way["natural"="water"]({bb});relation["natural"="water"]({bb});'
|
||||
f'way["waterway"="riverbank"]({bb});relation["waterway"="riverbank"]({bb}););out geom;')
|
||||
print(' water areas')
|
||||
d = overpass(q, f'{slug}_water')
|
||||
polys = []
|
||||
for el in d['elements']:
|
||||
t = el.get('tags', {})
|
||||
if t.get('water') in ('pond', 'reflecting_pool', 'fountain', 'basin', 'reservoir') and el['type'] == 'way':
|
||||
pass # keep small ponds too; they are real water
|
||||
if el['type'] == 'way':
|
||||
if 'geometry' not in el:
|
||||
continue
|
||||
g = ll_list(el['geometry'])
|
||||
if g[0] != g[-1]:
|
||||
continue
|
||||
outers, inners = [g], []
|
||||
else:
|
||||
mem = [m for m in el.get('members', []) if m['type'] == 'way' and 'geometry' in m]
|
||||
outers = assemble_rings([ll_list(m['geometry']) for m in mem if m.get('role') == 'outer'])
|
||||
inners = assemble_rings([ll_list(m['geometry']) for m in mem if m.get('role') == 'inner'])
|
||||
inn_xy = [open_ring([F.xy(*p) for p in r]) for r in inners]
|
||||
for o in outers:
|
||||
ring = open_ring([F.xy(*p) for p in o])
|
||||
ring = clip_polygon(ring, -h, -h, h, h)
|
||||
ring = rdp_ring(open_ring(ring), 1.0)
|
||||
if len(ring) < 3 or abs(area(ring)) < 20:
|
||||
continue
|
||||
p = [ring]
|
||||
for ir in inn_xy:
|
||||
ir2 = rdp_ring(open_ring(clip_polygon(ir, -h, -h, h, h)), 1.0)
|
||||
if len(ir2) >= 3 and abs(area(ir2)) >= 20:
|
||||
p.append(ir2)
|
||||
polys.append(('water', p))
|
||||
n_area = len(polys)
|
||||
cpolys, bad = coast_water(slug, F, h)
|
||||
for p in cpolys:
|
||||
p2 = [rdp_ring(r, 1.0) for r in p]
|
||||
p2 = [r for r in p2 if len(r) >= 3]
|
||||
if p2:
|
||||
polys.append(('coast', p2))
|
||||
out = [[flat(r) for r in p] for _, p in polys]
|
||||
src = [s_ for s_, _ in polys]
|
||||
write_json(os.path.join(OUT, slug, 'water.json'),
|
||||
{'count': len(out), 'format': 'polys[i] = [outerRing, holeRing...], rings flat x,y', 'src': src,
|
||||
'polys': out})
|
||||
print(f' water polys {len(out)} (osm water areas {n_area}, coastline-derived {len(cpolys)})')
|
||||
return dict(count=len(out), water_areas=n_area, coastline_polys=len(cpolys), coastline_unclosed_pieces=bad)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ extras
|
||||
|
||||
KNOWN_HELI = ['Downtown Manhattan Heliport', 'West 30th Street Heliport', 'East 34th Street Heliport']
|
||||
|
||||
|
||||
def nyc_extra(slug, F):
|
||||
s, w, n, e = F.bbox(F.half + 2000)
|
||||
bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}'
|
||||
q = (f'[out:json][timeout:180];(nwr["aeroway"~"^(helipad|heliport)$"]({bb});'
|
||||
f'nwr["name"~"heliport|helistop|helipad",i]({bb}););out center tags;')
|
||||
print(' helipads')
|
||||
d = overpass(q, f'{slug}_heli2')
|
||||
pads = []
|
||||
for el in d['elements']:
|
||||
t = el.get('tags', {})
|
||||
if el['type'] == 'node':
|
||||
la, lo = el['lat'], el['lon']
|
||||
else:
|
||||
c = el.get('center')
|
||||
if not c:
|
||||
continue
|
||||
la, lo = c['lat'], c['lon']
|
||||
x, y = F.xy(la, lo)
|
||||
name = t.get('name') or t.get('operator') or ''
|
||||
kind = t.get('aeroway') or ('heliport' if 'heliport' in name.lower() else 'named')
|
||||
pads.append(dict(x=r1(x), y=r1(y), name=name, kind=kind, ref=t.get('ref', ''), osm=f"{el['type']}/{el['id']}",
|
||||
tags={k: v for k, v in t.items() if k in ('aeroway', 'faa', 'operator', 'man_made', 'amenity')},
|
||||
inside=abs(x) <= F.half and abs(y) <= F.half))
|
||||
pads.sort(key=lambda p: (p['kind'] != 'heliport', p['x'] ** 2 + p['y'] ** 2))
|
||||
for p in pads:
|
||||
print(f" {p['kind']:9s} {p['x']:8.1f} {p['y']:8.1f} {'in ' if p['inside'] else 'OUT'} {p['name']}")
|
||||
found = {k: [p for p in pads if p['kind'] == 'heliport' and k.split()[0].lower() in p['name'].lower()
|
||||
and 'heliport' in p['name'].lower()] for k in KNOWN_HELI}
|
||||
helipads = [[p['x'], p['y'], p['name'] or p['kind']] for p in pads]
|
||||
write_json(os.path.join(OUT, slug, 'extra.json'),
|
||||
{'helipads': helipads,
|
||||
'detail': pads,
|
||||
'note': 'aeroway=helipad|heliport from OSM in a bbox 2 km wider than the scene; '
|
||||
'detail[].inside says whether the point is inside the scene square'})
|
||||
return dict(helipad_points=len(pads), named_heliports={k: (v[0]['x'], v[0]['y'], v[0]['inside']) if v else None
|
||||
for k, v in found.items()})
|
||||
|
||||
|
||||
def boston_rail(slug, F):
|
||||
h = F.half
|
||||
s, w, n, e = F.bbox(h + 100)
|
||||
bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}'
|
||||
q = (f'[out:json][timeout:180];(relation["route"~"^(subway|light_rail|train|tram)$"]({bb});'
|
||||
f'way["railway"~"^(subway|light_rail|rail|tram)$"]({bb}););out geom;')
|
||||
print(' rail')
|
||||
d = overpass(q, f'{slug}_rail')
|
||||
ways = {el['id']: el for el in d['elements'] if el['type'] == 'way'}
|
||||
rels = [el for el in d['elements'] if el['type'] == 'relation']
|
||||
lines, seen = [], set()
|
||||
rel_names = []
|
||||
for r in rels:
|
||||
t = r.get('tags', {})
|
||||
col = (t.get('colour') or t.get('color') or '').strip()
|
||||
if not re.match(r'^#?[0-9a-fA-F]{6}$', col):
|
||||
col = {'subway': '#888888', 'light_rail': '#00843D', 'tram': '#00843D', 'train': '#80276C'}[t['route']]
|
||||
if not col.startswith('#'):
|
||||
col = '#' + col
|
||||
col = col.upper()
|
||||
rel_names.append(f"{t.get('route')}:{t.get('name', t.get('ref', ''))}:{col}")
|
||||
for m in r.get('members', []):
|
||||
if m['type'] != 'way' or 'geometry' not in m or m.get('role') in ('platform', 'stop'):
|
||||
continue
|
||||
wt = ways.get(m['ref'], {}).get('tags', {})
|
||||
if wt.get('public_transport') or wt.get('railway') == 'platform':
|
||||
continue
|
||||
key = (m['ref'], col)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
pts = [F.xy(la, lo) for la, lo in ll_list(m['geometry'])]
|
||||
for pc in clip_polyline(dedupe(pts), -h, -h, h, h):
|
||||
pc = rdp(pc, 1.0)
|
||||
if len(pc) >= 2:
|
||||
lines.append([col] + flat(pc))
|
||||
covered = {k[0] for k in seen}
|
||||
extra = 0
|
||||
for wid, el in ways.items():
|
||||
t = el.get('tags', {})
|
||||
if wid in covered or t.get('service') or 'geometry' not in el:
|
||||
continue
|
||||
pts = [F.xy(la, lo) for la, lo in ll_list(el['geometry'])]
|
||||
for pc in clip_polyline(dedupe(pts), -h, -h, h, h):
|
||||
pc = rdp(pc, 1.0)
|
||||
if len(pc) >= 2:
|
||||
lines.append(['#7F7F7F'] + flat(pc))
|
||||
extra += 1
|
||||
write_json(os.path.join(OUT, slug, 'rail.json'),
|
||||
{'lines': lines, 'routes': sorted(set(rel_names)),
|
||||
'note': 'OSM route relations (subway/light_rail/train/tram) colour tags; #7F7F7F = railway way not in any route relation'})
|
||||
print(f' rail lines {len(lines)} from {len(rels)} route relations (+{extra} unassigned way pieces)')
|
||||
for nm in sorted(set(rel_names)):
|
||||
print(' ', nm)
|
||||
return dict(polylines=len(lines), route_relations=len(rels), unassigned_pieces=extra)
|
||||
|
||||
|
||||
def main():
|
||||
slugs = sys.argv[1:] or list(CITIES)
|
||||
report = {}
|
||||
for slug in slugs:
|
||||
c = CITIES[slug]
|
||||
F = Frame(**c)
|
||||
print(f'== {slug}')
|
||||
write_json(os.path.join(OUT, slug, 'meta.json'),
|
||||
{'slug': slug, 'name': c['name'], 'lat0': c['lat0'], 'lon0': c['lon0'], 'half': c['half'],
|
||||
'frame': 'ENU m',
|
||||
'projection': 'x=(lon-lon0)*cos(lat0)*111320, y=(lat-lat0)*110540',
|
||||
'source': 'OpenStreetMap contributors (ODbL) via Overpass; imagery Esri World Imagery'})
|
||||
r = {}
|
||||
r['buildings'] = buildings(slug, F)
|
||||
r['roads'] = roads(slug, F)
|
||||
try:
|
||||
r['water'] = water(slug, F)
|
||||
except Exception as ex: # noqa
|
||||
print(' WATER FAILED', ex)
|
||||
r['water'] = f'failed: {ex}'
|
||||
if slug == 'nyc':
|
||||
r['extra'] = nyc_extra(slug, F)
|
||||
if slug == 'boston':
|
||||
r['rail'] = boston_rail(slug, F)
|
||||
report[slug] = r
|
||||
print(json.dumps(report, indent=1))
|
||||
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cache', 'osm_report.json'), 'w') as f:
|
||||
json.dump(report, f, indent=1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Shared helpers for the city scene geodata fetchers (ENU frame, Overpass, geometry)."""
|
||||
import json, math, os, time, hashlib
|
||||
import requests
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, '..', '..'))
|
||||
OUT = os.path.join(ROOT, 'city')
|
||||
CACHE = os.path.join(HERE, 'cache')
|
||||
os.makedirs(CACHE, exist_ok=True)
|
||||
|
||||
CITIES = {
|
||||
'nyc': dict(name='New York — Midtown / Chelsea / Lower Manhattan', lat0=40.7400, lon0=-73.9900, half=3500),
|
||||
'boston': dict(name='Boston — Downtown / Back Bay / Cambridge / Seaport', lat0=42.3555, lon0=-71.0605, half=3200),
|
||||
}
|
||||
|
||||
OVERPASS = [
|
||||
'https://overpass-api.de/api/interpreter',
|
||||
'https://overpass.kumi.systems/api/interpreter',
|
||||
'https://overpass.private.coffee/api/interpreter',
|
||||
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
|
||||
]
|
||||
|
||||
|
||||
class Frame:
|
||||
def __init__(self, lat0, lon0, half, **_):
|
||||
self.lat0, self.lon0, self.half = lat0, lon0, half
|
||||
self.kx = math.cos(math.radians(lat0)) * 111320.0
|
||||
self.ky = 110540.0
|
||||
|
||||
def xy(self, lat, lon):
|
||||
return ((lon - self.lon0) * self.kx, (lat - self.lat0) * self.ky)
|
||||
|
||||
def ll(self, x, y):
|
||||
return (self.lat0 + y / self.ky, self.lon0 + x / self.kx)
|
||||
|
||||
def bbox(self, half=None):
|
||||
h = self.half if half is None else half
|
||||
s, w = self.ll(-h, -h)
|
||||
n, e = self.ll(h, h)
|
||||
return s, w, n, e
|
||||
|
||||
|
||||
def overpass(query, tag):
|
||||
"""Run an Overpass query, caching the raw JSON response under cache/osm_<tag>.json."""
|
||||
path = os.path.join(CACHE, f'osm_{tag}.json')
|
||||
if os.path.exists(path):
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
last = None
|
||||
for attempt in range(8):
|
||||
for url in OVERPASS:
|
||||
try:
|
||||
t = time.time()
|
||||
r = requests.post(url, data={'data': query}, timeout=400)
|
||||
if r.status_code != 200:
|
||||
last = f'{url} HTTP {r.status_code}'
|
||||
print(' overpass fail', last)
|
||||
continue
|
||||
d = r.json()
|
||||
if 'remark' in d and 'error' in d.get('remark', '').lower():
|
||||
last = f'{url} remark {d["remark"][:200]}'
|
||||
print(' overpass remark', last)
|
||||
continue
|
||||
print(f' overpass ok {url} {len(r.content)/1e6:.1f}MB {time.time()-t:.0f}s {len(d["elements"])} el')
|
||||
with open(path, 'w') as f:
|
||||
json.dump(d, f)
|
||||
return d
|
||||
except Exception as e: # noqa
|
||||
last = f'{url} {type(e).__name__}: {str(e)[:200]}'
|
||||
print(' overpass fail', last)
|
||||
time.sleep(15 + 15 * attempt)
|
||||
raise RuntimeError('all overpass endpoints failed: ' + str(last))
|
||||
|
||||
|
||||
# ---------------- geometry ----------------
|
||||
|
||||
def rdp(pts, eps):
|
||||
"""Douglas-Peucker on an open polyline (list of (x,y))."""
|
||||
n = len(pts)
|
||||
if n < 3:
|
||||
return list(pts)
|
||||
keep = [False] * n
|
||||
keep[0] = keep[-1] = True
|
||||
stack = [(0, n - 1)]
|
||||
while stack:
|
||||
a, b = stack.pop()
|
||||
ax, ay = pts[a]
|
||||
bx, by = pts[b]
|
||||
dx, dy = bx - ax, by - ay
|
||||
L = math.hypot(dx, dy)
|
||||
dmax, idx = -1.0, -1
|
||||
for i in range(a + 1, b):
|
||||
px, py = pts[i]
|
||||
if L < 1e-9:
|
||||
d = math.hypot(px - ax, py - ay)
|
||||
else:
|
||||
d = abs(dx * (ay - py) - dy * (ax - px)) / L
|
||||
if d > dmax:
|
||||
dmax, idx = d, i
|
||||
if dmax > eps and idx > 0:
|
||||
keep[idx] = True
|
||||
stack.append((a, idx))
|
||||
stack.append((idx, b))
|
||||
return [p for p, k in zip(pts, keep) if k]
|
||||
|
||||
|
||||
def rdp_ring(ring, eps):
|
||||
"""Douglas-Peucker for a closed ring given without the repeated closing vertex."""
|
||||
n = len(ring)
|
||||
if n < 4:
|
||||
return list(ring)
|
||||
# split at vertex 0 and the vertex farthest from it
|
||||
x0, y0 = ring[0]
|
||||
far = max(range(n), key=lambda i: (ring[i][0] - x0) ** 2 + (ring[i][1] - y0) ** 2)
|
||||
a = rdp(ring[0:far + 1], eps)
|
||||
b = rdp(ring[far:] + [ring[0]], eps)
|
||||
return a[:-1] + b[:-1]
|
||||
|
||||
|
||||
def area(ring):
|
||||
s = 0.0
|
||||
n = len(ring)
|
||||
for i in range(n):
|
||||
x1, y1 = ring[i]
|
||||
x2, y2 = ring[(i + 1) % n]
|
||||
s += x1 * y2 - x2 * y1
|
||||
return s / 2.0
|
||||
|
||||
|
||||
def centroid(ring):
|
||||
a = area(ring)
|
||||
if abs(a) < 1e-9:
|
||||
xs = [p[0] for p in ring]
|
||||
ys = [p[1] for p in ring]
|
||||
return sum(xs) / len(xs), sum(ys) / len(ys)
|
||||
cx = cy = 0.0
|
||||
n = len(ring)
|
||||
for i in range(n):
|
||||
x1, y1 = ring[i]
|
||||
x2, y2 = ring[(i + 1) % n]
|
||||
c = x1 * y2 - x2 * y1
|
||||
cx += (x1 + x2) * c
|
||||
cy += (y1 + y2) * c
|
||||
return cx / (6 * a), cy / (6 * a)
|
||||
|
||||
|
||||
def dedupe(pts):
|
||||
out = []
|
||||
for p in pts:
|
||||
if not out or p != out[-1]:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def open_ring(pts):
|
||||
pts = dedupe(pts)
|
||||
if len(pts) > 1 and pts[0] == pts[-1]:
|
||||
pts = pts[:-1]
|
||||
return pts
|
||||
|
||||
|
||||
def assemble_rings(ways):
|
||||
"""Join way coordinate lists (lists of (lat,lon) tuples) into closed rings by matching endpoints."""
|
||||
rings = []
|
||||
pool = [list(w) for w in ways if len(w) >= 2]
|
||||
while pool:
|
||||
cur = pool.pop()
|
||||
changed = True
|
||||
while cur[0] != cur[-1] and changed:
|
||||
changed = False
|
||||
for i, w in enumerate(pool):
|
||||
if w[0] == cur[-1]:
|
||||
cur = cur + w[1:]
|
||||
elif w[-1] == cur[-1]:
|
||||
cur = cur + w[::-1][1:]
|
||||
elif w[-1] == cur[0]:
|
||||
cur = w + cur[1:]
|
||||
elif w[0] == cur[0]:
|
||||
cur = w[::-1] + cur[1:]
|
||||
else:
|
||||
continue
|
||||
pool.pop(i)
|
||||
changed = True
|
||||
break
|
||||
if cur[0] == cur[-1] and len(cur) >= 4:
|
||||
rings.append(cur)
|
||||
return rings
|
||||
|
||||
|
||||
def clip_polyline(pts, xmin, ymin, xmax, ymax):
|
||||
"""Clip a polyline to a rectangle (Liang-Barsky per segment); returns list of pieces."""
|
||||
pieces, cur = [], []
|
||||
|
||||
def inside(p):
|
||||
return xmin <= p[0] <= xmax and ymin <= p[1] <= ymax
|
||||
|
||||
for i in range(len(pts) - 1):
|
||||
(x1, y1), (x2, y2) = pts[i], pts[i + 1]
|
||||
dx, dy = x2 - x1, y2 - y1
|
||||
t0, t1 = 0.0, 1.0
|
||||
ok = True
|
||||
for p, q in ((-dx, x1 - xmin), (dx, xmax - x1), (-dy, y1 - ymin), (dy, ymax - y1)):
|
||||
if p == 0:
|
||||
if q < 0:
|
||||
ok = False
|
||||
break
|
||||
else:
|
||||
t = q / p
|
||||
if p < 0:
|
||||
if t > t1:
|
||||
ok = False
|
||||
break
|
||||
t0 = max(t0, t)
|
||||
else:
|
||||
if t < t0:
|
||||
ok = False
|
||||
break
|
||||
t1 = min(t1, t)
|
||||
if not ok:
|
||||
if cur:
|
||||
pieces.append(cur)
|
||||
cur = []
|
||||
continue
|
||||
a = (x1 + t0 * dx, y1 + t0 * dy)
|
||||
b = (x1 + t1 * dx, y1 + t1 * dy)
|
||||
if not cur:
|
||||
cur = [a, b]
|
||||
else:
|
||||
if cur[-1] != a:
|
||||
pieces.append(cur)
|
||||
cur = [a, b]
|
||||
else:
|
||||
cur.append(b)
|
||||
if t1 < 1.0:
|
||||
pieces.append(cur)
|
||||
cur = []
|
||||
if cur:
|
||||
pieces.append(cur)
|
||||
return [dedupe(p) for p in pieces if len(dedupe(p)) >= 2]
|
||||
|
||||
|
||||
def clip_polygon(ring, xmin, ymin, xmax, ymax):
|
||||
"""Sutherland-Hodgman clip of a ring to a rectangle."""
|
||||
def clip(poly, inside, inter):
|
||||
out = []
|
||||
n = len(poly)
|
||||
for i in range(n):
|
||||
cur, prev = poly[i], poly[i - 1]
|
||||
if inside(cur):
|
||||
if not inside(prev):
|
||||
out.append(inter(prev, cur))
|
||||
out.append(cur)
|
||||
elif inside(prev):
|
||||
out.append(inter(prev, cur))
|
||||
return out
|
||||
|
||||
def ix(xc):
|
||||
return lambda p, q: (xc, p[1] + (q[1] - p[1]) * (xc - p[0]) / (q[0] - p[0]))
|
||||
|
||||
def iy(yc):
|
||||
return lambda p, q: (p[0] + (q[0] - p[0]) * (yc - p[1]) / (q[1] - p[1]), yc)
|
||||
|
||||
poly = list(ring)
|
||||
for ins, inter in ((lambda p: p[0] >= xmin, ix(xmin)), (lambda p: p[0] <= xmax, ix(xmax)),
|
||||
(lambda p: p[1] >= ymin, iy(ymin)), (lambda p: p[1] <= ymax, iy(ymax))):
|
||||
if not poly:
|
||||
break
|
||||
poly = clip(poly, ins, inter)
|
||||
return dedupe(poly)
|
||||
|
||||
|
||||
def r1(v):
|
||||
return round(v, 1)
|
||||
|
||||
|
||||
def flat(pts):
|
||||
out = []
|
||||
for x, y in pts:
|
||||
out.append(r1(x))
|
||||
out.append(r1(y))
|
||||
return out
|
||||
|
||||
|
||||
def write_json(path, obj):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, 'w') as f:
|
||||
json.dump(obj, f, separators=(',', ':'))
|
||||
print(f' wrote {os.path.relpath(path, ROOT)} {os.path.getsize(path)/1e6:.2f}MB')
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Build real-geodata scene packs. usage: python3 build_scene.py lax|bragg|everest [steps...]
|
||||
steps: osm ground elev check (default all)"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import common as C # noqa
|
||||
|
||||
SCENES = {
|
||||
"lax": dict(name="Los Angeles International Airport", lat0=33.9425, lon0=-118.4081, half=3500, z=17),
|
||||
"bragg": dict(name="Fort Bragg, North Carolina", lat0=35.1580, lon0=-79.0090, half=6000, z=16),
|
||||
"everest": dict(name="Mount Everest / Khumbu", lat0=27.9881, lon0=86.9250, half=15000, z=15),
|
||||
}
|
||||
|
||||
NT = {"lax": 4, "bragg": 6}
|
||||
AERO_Q = '[out:json][timeout:600];nwr["aeroway"~"^(runway|taxiway|apron|terminal|gate|parking_position)$"]({bb});out geom;'
|
||||
BLDG_Q = '[out:json][timeout:300];(way["building"]({bb});relation["building"]({bb}););out geom;'
|
||||
ROAD_Q = ('[out:json][timeout:300];way["highway"~"^(motorway|trunk|primary|secondary|tertiary|'
|
||||
'residential|unclassified|living_street|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link|service)$"]({bb});out geom;')
|
||||
MIL_Q = '[out:json][timeout:600];(way["landuse"="military"]({bb});relation["landuse"="military"]({bb});way["military"]({bb});relation["military"]({bb}););out geom;'
|
||||
PEAK_Q = '[out:json][timeout:300];node["natural"="peak"]({bb});out;'
|
||||
|
||||
|
||||
def step_osm(f, slug):
|
||||
bb = f.bbox_str()
|
||||
if slug in ("lax", "bragg"):
|
||||
aero = C.build_aeroway(f, C.overpass(AERO_Q.format(bb=bb), f"{slug}_aero"))
|
||||
if slug == "lax":
|
||||
C.wjson(os.path.join(f.out, "aeroway.json"), aero)
|
||||
else:
|
||||
mil = C.overpass(MIL_Q.format(bb=bb), f"{slug}_mil")
|
||||
polys = []
|
||||
for el in mil["elements"]:
|
||||
tags = el.get("tags", {})
|
||||
for ring in C.outer_rings(el, f):
|
||||
name = tags.get("name") or (f"[{tags['military']}]" if "military" in tags else "")
|
||||
polys.append([name] + C.flat(C.dp_ring(ring, 2.0)))
|
||||
names = sorted({p[0] for p in polys if p[0]})
|
||||
print(f" military polygons: {len(polys)}; names: {names[:40]}")
|
||||
out = {"military": polys, "runways": aero["runways"], "taxiways": aero["taxiways"],
|
||||
"aprons": aero["aprons"], "apronNames": aero["apronNames"], "terminals": aero["terminals"],
|
||||
"gates": aero["gates"], "parking": aero["parking"]}
|
||||
C.wjson(os.path.join(f.out, "military.json"), out)
|
||||
C.wjson(os.path.join(f.out, "buildings.json"), C.build_buildings(f, C.overpass_tiled(f, BLDG_Q, f"{slug}_bldg", NT[slug])))
|
||||
C.wjson(os.path.join(f.out, "roads.json"), C.build_roads(f, C.overpass_tiled(f, ROAD_Q, f"{slug}_roads", NT[slug] // 2)))
|
||||
else:
|
||||
d = C.overpass(PEAK_Q.format(bb=bb), f"{slug}_peaks")
|
||||
peaks = []
|
||||
skipped = 0
|
||||
for el in d["elements"]:
|
||||
t = el.get("tags", {})
|
||||
name = t.get("name:en") or t.get("name")
|
||||
ele = t.get("ele")
|
||||
if not name or not ele:
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
s = ele.lower().replace(",", ".").strip()
|
||||
v = float(s.replace("ft", "").replace("m", "").strip().split()[0].split(";")[0])
|
||||
if "ft" in s:
|
||||
v *= 0.3048
|
||||
except ValueError:
|
||||
skipped += 1
|
||||
continue
|
||||
x, y = f.xy(el["lon"], el["lat"])
|
||||
peaks.append([C.r1(x), C.r1(y), C.r1(v), name])
|
||||
peaks.sort(key=lambda p: -p[2])
|
||||
print(f" peaks with name+ele: {len(peaks)} (skipped {skipped} lacking name/ele); top: {peaks[:6]}")
|
||||
C.wjson(os.path.join(f.out, "peaks.json"), peaks)
|
||||
|
||||
|
||||
def step_elev(f):
|
||||
z = 12
|
||||
url = "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
||||
mos, ox, oy, nf = C.mosaic(f, z, url, "terrarium", "png", "RGB", pad_m=1500)
|
||||
if nf:
|
||||
print(f" WARNING: {nf} terrarium tiles missing")
|
||||
m = mos.astype(np.float32)
|
||||
h = m[..., 0] * 256 + m[..., 1] + m[..., 2] / 256 - 32768
|
||||
N = 1024
|
||||
e = C.reproject(f, h[..., None], ox, oy, z, N)[..., 0].astype("<f4")
|
||||
e.tofile(os.path.join(f.out, "elev.f32"))
|
||||
meta = {"size": N, "min": C.r1(e.min()), "max": C.r1(e.max()), "half": f.half,
|
||||
"source": "AWS Terrain Tiles terrarium z12", "rowOrder": "row0=north", "dtype": "float32le"}
|
||||
C.wjson(os.path.join(f.out, "elev_meta.json"), meta)
|
||||
print(f" elev min {e.min():.1f} max {e.max():.1f}")
|
||||
# hillshade
|
||||
cell = 2 * f.half / N
|
||||
gy, gx = np.gradient(e.astype(np.float64), cell) # gy: d/drow (southward)
|
||||
dzdx, dzdy = gx, -gy
|
||||
az, alt = np.radians(315), np.radians(45)
|
||||
slope = np.arctan(np.hypot(dzdx, dzdy))
|
||||
aspect = np.arctan2(-dzdx, -dzdy) # direction of steepest descent... simple form
|
||||
lx, ly, lz = np.sin(az) * np.cos(alt), np.cos(az) * np.cos(alt), np.sin(alt)
|
||||
nx, ny, nz = -dzdx, -dzdy, np.ones_like(dzdx)
|
||||
nn = np.sqrt(nx * nx + ny * ny + 1)
|
||||
shade = np.clip((nx * lx + ny * ly + nz * lz) / nn, 0, 1)
|
||||
t = (e - e.min()) / max(1, e.max() - e.min())
|
||||
rgb = np.stack([0.35 + 0.65 * t, 0.45 + 0.55 * t, 0.55 + 0.45 * t], -1) * shade[..., None]
|
||||
Image.fromarray((np.clip(rgb, 0, 1) * 255).astype(np.uint8)).save(os.path.join(f.out, "elev_preview.png"))
|
||||
print(" wrote elev_preview.png")
|
||||
|
||||
|
||||
def step_check(f, slug):
|
||||
N = 2048
|
||||
im = C.check_canvas(f)
|
||||
dr = ImageDraw.Draw(im, "RGBA")
|
||||
P = lambda a: C.to_px(f, a, N) # noqa
|
||||
if slug in ("lax", "bragg"):
|
||||
roads = json.load(open(os.path.join(f.out, "roads.json")))["roads"]
|
||||
col = {0: (255, 60, 60, 230), 1: (255, 160, 40, 220), 2: (255, 230, 60, 200), 3: (200, 255, 120, 180),
|
||||
4: (160, 200, 255, 150), 5: (180, 180, 180, 120)}
|
||||
for r in roads:
|
||||
dr.line(P(r[3:]), fill=col[r[0]], width=2 if r[0] < 3 else 1)
|
||||
bl = json.load(open(os.path.join(f.out, "buildings.json")))["b"]
|
||||
for b in bl:
|
||||
pts = P(b[2:])
|
||||
if len(pts) >= 3:
|
||||
dr.polygon(pts, outline=(0, 255, 255, 200))
|
||||
aero = json.load(open(os.path.join(f.out, "aeroway.json" if slug == "lax" else "military.json")))
|
||||
if slug == "bragg":
|
||||
for m in aero["military"]:
|
||||
dr.polygon(P(m[1:]), outline=(255, 0, 255, 255))
|
||||
for a in aero["aprons"]:
|
||||
dr.polygon(P(a), outline=(255, 255, 255, 200))
|
||||
for t in aero["taxiways"]:
|
||||
dr.line(P(t), fill=(255, 255, 0, 230), width=1)
|
||||
for rw in aero["runways"]:
|
||||
w = max(2, int(rw[0] / (2 * f.half) * N))
|
||||
dr.line(P(rw[2:6]), fill=(255, 40, 40, 110), width=w)
|
||||
dr.line(P(rw[2:6]), fill=(255, 255, 255, 255), width=1)
|
||||
for t in aero["terminals"]:
|
||||
dr.polygon(P(t[1:]), outline=(255, 120, 0, 255))
|
||||
for g in aero["gates"]:
|
||||
(x, y), = P(g[:2])
|
||||
dr.ellipse([x - 2, y - 2, x + 2, y + 2], fill=(0, 255, 0, 255))
|
||||
else:
|
||||
peaks = json.load(open(os.path.join(f.out, "peaks.json")))
|
||||
for x0, y0, ele, name in peaks:
|
||||
(x, y), = P([x0, y0])
|
||||
dr.ellipse([x - 4, y - 4, x + 4, y + 4], outline=(255, 60, 60, 255), width=2)
|
||||
if ele > 6000:
|
||||
dr.text((x + 6, y - 6), f"{name} {ele:.0f}", fill=(255, 255, 255, 255))
|
||||
# contour-ish overlay from DEM to check imagery/DEM alignment
|
||||
e = np.fromfile(os.path.join(f.out, "elev.f32"), "<f4").reshape(1024, 1024)
|
||||
e2 = np.asarray(Image.fromarray(e).resize((N, N), Image.BILINEAR))
|
||||
band = (np.floor(e2 / 500) % 2).astype(np.int8)
|
||||
edge = (np.abs(np.diff(band, axis=0, prepend=band[:1])) + np.abs(np.diff(band, axis=1, prepend=band[:, :1]))) > 0
|
||||
arr = np.asarray(im).copy()
|
||||
arr[edge] = (0.4 * arr[edge] + 0.6 * np.array([0, 255, 255])).astype(np.uint8)
|
||||
im = Image.fromarray(arr)
|
||||
dr = ImageDraw.Draw(im, "RGBA")
|
||||
for x0, y0, ele, name in peaks:
|
||||
(x, y), = P([x0, y0])
|
||||
dr.ellipse([x - 4, y - 4, x + 4, y + 4], outline=(255, 60, 60, 255), width=2)
|
||||
if ele > 6500:
|
||||
dr.text((x + 6, y - 6), f"{name} {ele:.0f}", fill=(255, 255, 255, 255))
|
||||
p = os.path.join(f.out, "check.png")
|
||||
im.save(p)
|
||||
print(f" wrote {p}")
|
||||
|
||||
|
||||
def main():
|
||||
slug = sys.argv[1]
|
||||
steps = sys.argv[2:] or ["osm", "ground", "elev", "check"]
|
||||
s = SCENES[slug]
|
||||
f = C.Frame(slug, s["name"], s["lat0"], s["lon0"], s["half"])
|
||||
f.write_meta()
|
||||
print(f"== {slug} bbox {f.bbox_str()}")
|
||||
if "osm" in steps:
|
||||
step_osm(f, slug)
|
||||
if "ground" in steps:
|
||||
C.build_ground(f, s["z"])
|
||||
if "ground_s2" in steps:
|
||||
# alternative uniform real imagery (EOX Sentinel-2 cloudless 2020) — Esri is a patchwork here
|
||||
C.build_ground(f, 14, C.EOX_S2, "eox_s2", "ground_s2")
|
||||
if "elev" in steps and slug == "everest":
|
||||
step_elev(f)
|
||||
if "check" in steps:
|
||||
step_check(f, slug)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,571 @@
|
||||
"""Shared helpers for real-geodata scene builds (city2).
|
||||
|
||||
Local frame: ENU metres relative to (lat0, lon0):
|
||||
x = (lon - lon0) * cos(lat0) * 111320
|
||||
y = (lat - lat0) * 110540
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CACHE = os.path.join(HERE, "cache")
|
||||
CITY = os.path.abspath(os.path.join(HERE, "..", "..", "city"))
|
||||
os.makedirs(CACHE, exist_ok=True)
|
||||
|
||||
OVERPASS = [
|
||||
"https://overpass.kumi.systems/api/interpreter",
|
||||
"https://overpass.private.coffee/api/interpreter",
|
||||
"https://maps.mail.ru/osm/tools/overpass/api/interpreter",
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
]
|
||||
UA = {"User-Agent": "gods-eye-view-intro/1.0 (cinematic geodata build)"}
|
||||
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
class Frame:
|
||||
def __init__(self, slug, name, lat0, lon0, half):
|
||||
self.slug, self.name, self.lat0, self.lon0, self.half = slug, name, lat0, lon0, half
|
||||
self.kx = math.cos(math.radians(lat0)) * 111320.0
|
||||
self.ky = 110540.0
|
||||
self.out = os.path.join(CITY, slug)
|
||||
os.makedirs(self.out, exist_ok=True)
|
||||
|
||||
def xy(self, lon, lat):
|
||||
return (lon - self.lon0) * self.kx, (lat - self.lat0) * self.ky
|
||||
|
||||
def ll(self, x, y):
|
||||
return self.lon0 + x / self.kx, self.lat0 + y / self.ky
|
||||
|
||||
def bbox(self, pad=0.0):
|
||||
"""(south, west, north, east)"""
|
||||
h = self.half + pad
|
||||
return (self.lat0 - h / self.ky, self.lon0 - h / self.kx,
|
||||
self.lat0 + h / self.ky, self.lon0 + h / self.kx)
|
||||
|
||||
def bbox_str(self, pad=0.0):
|
||||
s, w, n, e = self.bbox(pad)
|
||||
return f"{s:.6f},{w:.6f},{n:.6f},{e:.6f}"
|
||||
|
||||
def write_meta(self):
|
||||
meta = {"slug": self.slug, "name": self.name, "lat0": self.lat0, "lon0": self.lon0,
|
||||
"half": self.half, "frame": "ENU m"}
|
||||
wjson(os.path.join(self.out, "meta.json"), meta)
|
||||
|
||||
def geom_xy(self, geom):
|
||||
"""list of {lat,lon} -> Nx2 float array in local metres"""
|
||||
a = np.array([[g["lon"], g["lat"]] for g in geom if g is not None], dtype=np.float64)
|
||||
if len(a) == 0:
|
||||
return np.zeros((0, 2))
|
||||
return np.stack([(a[:, 0] - self.lon0) * self.kx, (a[:, 1] - self.lat0) * self.ky], 1)
|
||||
|
||||
|
||||
def wjson(path, obj):
|
||||
with open(path, "w") as f:
|
||||
json.dump(obj, f, separators=(",", ":"))
|
||||
print(f" wrote {path} ({os.path.getsize(path)/1e6:.2f} MB)")
|
||||
|
||||
|
||||
def r1(v):
|
||||
return round(float(v), 1)
|
||||
|
||||
|
||||
def flat(arr):
|
||||
return [r1(v) for v in np.asarray(arr).reshape(-1)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Overpass
|
||||
def overpass(query, tag):
|
||||
key = hashlib.sha1(query.encode()).hexdigest()[:12]
|
||||
path = os.path.join(CACHE, f"overpass_{tag}_{key}.json")
|
||||
if os.path.exists(path):
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
last = None
|
||||
for attempt in range(10):
|
||||
for url in OVERPASS:
|
||||
try:
|
||||
print(f" overpass {tag} -> {url}", flush=True)
|
||||
t = time.time()
|
||||
r = requests.get(url, params={"data": query}, headers=UA, timeout=900)
|
||||
if r.status_code != 200:
|
||||
last = f"{url}: HTTP {r.status_code} {r.text[:200]}"
|
||||
print(" ", last)
|
||||
continue
|
||||
d = r.json()
|
||||
rem = d.get("remark", "")
|
||||
if rem and ("runtime error" in rem or "timed out" in rem or "out of memory" in rem):
|
||||
last = f"{url}: remark {rem[:200]}"
|
||||
print(" ", last)
|
||||
continue
|
||||
print(f" {len(d.get('elements', []))} elements in {time.time()-t:.0f}s")
|
||||
with open(path, "w") as f:
|
||||
json.dump(d, f)
|
||||
return d
|
||||
except Exception as e: # noqa
|
||||
last = f"{url}: {e}"
|
||||
print(" ", last)
|
||||
time.sleep(30)
|
||||
raise RuntimeError(f"overpass failed for {tag}: {last}")
|
||||
|
||||
|
||||
def overpass_tiled(frame, qfmt, tag, n):
|
||||
"""Run a query template ({bb} placeholder) over an n x n split of the frame bbox; dedupe elements."""
|
||||
s, w, nn, e = frame.bbox()
|
||||
seen, els = set(), []
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
bb = (f"{s + (nn - s) * i / n:.6f},{w + (e - w) * j / n:.6f},"
|
||||
f"{s + (nn - s) * (i + 1) / n:.6f},{w + (e - w) * (j + 1) / n:.6f}")
|
||||
d = overpass(qfmt.format(bb=bb), f"{tag}_{i}{j}")
|
||||
for el in d["elements"]:
|
||||
k = (el["type"], el["id"])
|
||||
if k not in seen:
|
||||
seen.add(k)
|
||||
els.append(el)
|
||||
print(f" {tag}: {len(els)} unique elements over {n}x{n} tiles")
|
||||
return {"elements": els}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- geometry
|
||||
def assemble_rings(ways):
|
||||
"""ways: list of Nx2 arrays (open or closed). Returns list of closed rings."""
|
||||
rings = []
|
||||
pending = [w for w in ways if len(w) >= 2]
|
||||
while pending:
|
||||
cur = pending.pop(0)
|
||||
changed = True
|
||||
while not np.allclose(cur[0], cur[-1], atol=1e-3) and changed:
|
||||
changed = False
|
||||
for i, w in enumerate(pending):
|
||||
if np.allclose(cur[-1], w[0], atol=1e-3):
|
||||
cur = np.vstack([cur, w[1:]])
|
||||
elif np.allclose(cur[-1], w[-1], atol=1e-3):
|
||||
cur = np.vstack([cur, w[::-1][1:]])
|
||||
elif np.allclose(cur[0], w[-1], atol=1e-3):
|
||||
cur = np.vstack([w, cur[1:]])
|
||||
elif np.allclose(cur[0], w[0], atol=1e-3):
|
||||
cur = np.vstack([w[::-1], cur[1:]])
|
||||
else:
|
||||
continue
|
||||
pending.pop(i)
|
||||
changed = True
|
||||
break
|
||||
if np.allclose(cur[0], cur[-1], atol=1e-3) and len(cur) >= 4:
|
||||
rings.append(cur)
|
||||
return rings
|
||||
|
||||
|
||||
def outer_rings(el, frame):
|
||||
"""Outer rings (local metres, closed) for a way or multipolygon relation with out geom."""
|
||||
if el["type"] == "way":
|
||||
g = el.get("geometry")
|
||||
if not g:
|
||||
return []
|
||||
a = frame.geom_xy(g)
|
||||
if len(a) >= 4 and np.allclose(a[0], a[-1], atol=1e-3):
|
||||
return [a]
|
||||
return []
|
||||
if el["type"] == "relation":
|
||||
ways = [frame.geom_xy(m["geometry"]) for m in el.get("members", [])
|
||||
if m.get("type") == "way" and m.get("role", "outer") in ("outer", "") and m.get("geometry")]
|
||||
return assemble_rings(ways)
|
||||
return []
|
||||
|
||||
|
||||
def dp(pts, eps):
|
||||
"""Douglas-Peucker on Nx2 array (iterative)."""
|
||||
n = len(pts)
|
||||
if n < 3:
|
||||
return pts
|
||||
keep = np.zeros(n, bool)
|
||||
keep[0] = keep[-1] = True
|
||||
stack = [(0, n - 1)]
|
||||
while stack:
|
||||
a, b = stack.pop()
|
||||
if b <= a + 1:
|
||||
continue
|
||||
p, q = pts[a], pts[b]
|
||||
seg = pts[a + 1:b]
|
||||
d = q - p
|
||||
L = math.hypot(d[0], d[1])
|
||||
if L < 1e-9:
|
||||
dist = np.hypot(seg[:, 0] - p[0], seg[:, 1] - p[1])
|
||||
else:
|
||||
dist = np.abs(d[0] * (seg[:, 1] - p[1]) - d[1] * (seg[:, 0] - p[0])) / L
|
||||
i = int(np.argmax(dist))
|
||||
if dist[i] > eps:
|
||||
m = a + 1 + i
|
||||
keep[m] = True
|
||||
stack.append((a, m))
|
||||
stack.append((m, b))
|
||||
return pts[keep]
|
||||
|
||||
|
||||
def dp_ring(ring, eps):
|
||||
"""DP for a closed ring: split at the vertex farthest from ring[0]."""
|
||||
r = ring[:-1] if np.allclose(ring[0], ring[-1]) else ring
|
||||
if len(r) < 4:
|
||||
return r
|
||||
far = int(np.argmax(np.hypot(*(r - r[0]).T)))
|
||||
a = dp(np.vstack([r[:far + 1]]), eps)
|
||||
b = dp(np.vstack([r[far:], r[:1]]), eps)
|
||||
return np.vstack([a[:-1], b[:-1]]) # open ring (no repeated closing vertex)
|
||||
|
||||
|
||||
def area(ring):
|
||||
x, y = ring[:, 0], ring[:, 1]
|
||||
return 0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1)))
|
||||
|
||||
|
||||
def poly_len(a):
|
||||
return float(np.sum(np.hypot(*np.diff(a, axis=0).T))) if len(a) > 1 else 0.0
|
||||
|
||||
|
||||
def clip_polyline(a, lim):
|
||||
"""Split a polyline into runs inside the square |x|,|y|<=lim, cutting segments at the edge."""
|
||||
out, cur = [], []
|
||||
inside = (np.abs(a[:, 0]) <= lim) & (np.abs(a[:, 1]) <= lim)
|
||||
|
||||
def cut(p, q):
|
||||
# point on segment p->q at the square boundary (p inside, q outside)
|
||||
t = 1.0
|
||||
d = q - p
|
||||
for k in range(2):
|
||||
if d[k] > 0 and q[k] > lim:
|
||||
t = min(t, (lim - p[k]) / d[k])
|
||||
elif d[k] < 0 and q[k] < -lim:
|
||||
t = min(t, (-lim - p[k]) / d[k])
|
||||
return p + d * t
|
||||
|
||||
for i in range(len(a)):
|
||||
if inside[i]:
|
||||
if not cur and i > 0:
|
||||
cur.append(cut(a[i], a[i - 1]))
|
||||
cur.append(a[i])
|
||||
else:
|
||||
if cur:
|
||||
cur.append(cut(a[i - 1], a[i]))
|
||||
out.append(np.array(cur))
|
||||
cur = []
|
||||
if cur:
|
||||
out.append(np.array(cur))
|
||||
return [c for c in out if len(c) >= 2]
|
||||
|
||||
|
||||
def tag_height(tags, default=10.0):
|
||||
def num(s):
|
||||
try:
|
||||
s = str(s).strip().lower().replace(",", ".")
|
||||
ft = s.endswith("ft") or s.endswith("'")
|
||||
s = s.replace("m", "").replace("ft", "").replace("'", "").strip().split(";")[0].split()[0]
|
||||
v = float(s)
|
||||
return v * 0.3048 if ft else v
|
||||
except Exception:
|
||||
return None
|
||||
h = num(tags["height"]) if "height" in tags else None
|
||||
if h is None and "building:levels" in tags:
|
||||
lv = num(tags["building:levels"])
|
||||
if lv is not None:
|
||||
h = lv * 3.2
|
||||
if h is None or h <= 0:
|
||||
h = default
|
||||
mh = num(tags["min_height"]) if "min_height" in tags else None
|
||||
if mh is None and "building:min_level" in tags:
|
||||
lv = num(tags["building:min_level"])
|
||||
mh = lv * 3.2 if lv is not None else None
|
||||
mh = mh if (mh is not None and 0 <= mh < h) else 0.0
|
||||
return h, mh
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- buildings / roads
|
||||
def build_buildings(frame, data, min_area=6.0):
|
||||
b = []
|
||||
dropped = 0
|
||||
lim = frame.half
|
||||
for el in data["elements"]:
|
||||
if el["type"] not in ("way", "relation"):
|
||||
continue
|
||||
tags = el.get("tags", {})
|
||||
if "building" not in tags:
|
||||
continue
|
||||
h, mh = tag_height(tags)
|
||||
for ring in outer_rings(el, frame):
|
||||
c = ring[:-1].mean(0)
|
||||
if abs(c[0]) > lim or abs(c[1]) > lim:
|
||||
continue
|
||||
s = dp_ring(ring, 0.5)
|
||||
if len(s) < 3 or area(s) < min_area:
|
||||
dropped += 1
|
||||
continue
|
||||
b.append([r1(h), r1(mh)] + flat(s))
|
||||
print(f" buildings: {len(b)} kept, {dropped} tiny dropped")
|
||||
return {"count": len(b), "b": b}
|
||||
|
||||
|
||||
ROAD_CLASS = {
|
||||
"motorway": 0, "trunk": 0,
|
||||
"primary": 1, "secondary": 2, "tertiary": 3,
|
||||
"residential": 4, "unclassified": 4, "living_street": 4,
|
||||
"motorway_link": 5, "trunk_link": 5, "primary_link": 5, "secondary_link": 5,
|
||||
"tertiary_link": 5, "service": 5,
|
||||
}
|
||||
DEFAULT_LANES = {0: 3, 1: 2, 2: 2, 3: 2, 4: 1, 5: 1}
|
||||
|
||||
|
||||
def build_roads(frame, data):
|
||||
roads = []
|
||||
counts = {}
|
||||
lim = frame.half + 50
|
||||
for el in data["elements"]:
|
||||
if el["type"] != "way" or not el.get("geometry"):
|
||||
continue
|
||||
tags = el.get("tags", {})
|
||||
hw = tags.get("highway")
|
||||
if hw not in ROAD_CLASS:
|
||||
continue
|
||||
cls = ROAD_CLASS[hw]
|
||||
a = frame.geom_xy(el["geometry"])
|
||||
if hw == "service" and poly_len(a) <= 80:
|
||||
continue
|
||||
ow = tags.get("oneway", "")
|
||||
oneway = 0
|
||||
if ow in ("yes", "1", "true") or (hw in ("motorway",) and ow != "no") or tags.get("junction") in ("roundabout", "circular"):
|
||||
oneway = 1
|
||||
elif ow == "-1":
|
||||
oneway = 1
|
||||
a = a[::-1]
|
||||
try:
|
||||
lanes = int(str(tags.get("lanes", "")).split(";")[0])
|
||||
except ValueError:
|
||||
lanes = DEFAULT_LANES[cls] * (1 if oneway else 2) if cls < 4 else (1 if oneway else 2)
|
||||
for part in clip_polyline(a, lim):
|
||||
if poly_len(part) < 2:
|
||||
continue
|
||||
roads.append([cls, oneway, lanes] + flat(part))
|
||||
counts[cls] = counts.get(cls, 0) + 1
|
||||
print(f" roads: {len(roads)} polylines, by class {dict(sorted(counts.items()))}")
|
||||
return {"roads": roads}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- aeroway
|
||||
def build_aeroway(frame, data):
|
||||
lim = frame.half + 200
|
||||
runways, taxiways, aprons, terminals, gates, parking = [], [], [], [], [], []
|
||||
apron_names = []
|
||||
for el in data["elements"]:
|
||||
tags = el.get("tags", {})
|
||||
aw = tags.get("aeroway")
|
||||
if el["type"] == "node":
|
||||
x, y = frame.xy(el["lon"], el["lat"])
|
||||
if abs(x) > lim or abs(y) > lim:
|
||||
continue
|
||||
if aw == "gate":
|
||||
gates.append([r1(x), r1(y), tags.get("ref", tags.get("name", ""))])
|
||||
elif aw == "parking_position":
|
||||
parking.append([r1(x), r1(y), tags.get("ref", tags.get("name", ""))])
|
||||
continue
|
||||
if aw == "runway" and el["type"] == "way" and el.get("geometry"):
|
||||
a = frame.geom_xy(el["geometry"])
|
||||
closed = len(a) > 3 and np.allclose(a[0], a[-1], atol=1e-3)
|
||||
try:
|
||||
w = float(str(tags.get("width", "")).replace("m", "").strip())
|
||||
except ValueError:
|
||||
w = None
|
||||
if closed or tags.get("area") == "yes":
|
||||
# area runway: use its long axis (PCA) as centreline, width = short extent
|
||||
c = a.mean(0)
|
||||
u, s, vt = np.linalg.svd(a - c, full_matrices=False)
|
||||
ax = vt[0]
|
||||
t = (a - c) @ ax
|
||||
n = (a - c) @ vt[1]
|
||||
p1, p2 = c + ax * t.min(), c + ax * t.max()
|
||||
w = w or float(n.max() - n.min())
|
||||
else:
|
||||
p1, p2 = a[0], a[-1]
|
||||
runways.append([r1(w or 45.0), tags.get("ref", ""), r1(p1[0]), r1(p1[1]), r1(p2[0]), r1(p2[1])])
|
||||
elif aw == "taxiway" and el["type"] == "way" and el.get("geometry"):
|
||||
a = frame.geom_xy(el["geometry"])
|
||||
if tags.get("area") == "yes":
|
||||
continue
|
||||
for part in clip_polyline(a, lim):
|
||||
taxiways.append(flat(part))
|
||||
elif aw == "apron":
|
||||
for ring in outer_rings(el, frame):
|
||||
c = ring.mean(0)
|
||||
if abs(c[0]) > lim or abs(c[1]) > lim:
|
||||
continue
|
||||
aprons.append(flat(dp_ring(ring, 0.5)))
|
||||
apron_names.append(tags.get("name", ""))
|
||||
elif aw == "terminal":
|
||||
h, _ = tag_height(tags)
|
||||
for ring in outer_rings(el, frame):
|
||||
c = ring.mean(0)
|
||||
if abs(c[0]) > lim or abs(c[1]) > lim:
|
||||
continue
|
||||
terminals.append([r1(h)] + flat(dp_ring(ring, 0.5)))
|
||||
# OSM often splits one runway into several ways (displaced thresholds, blast pads):
|
||||
# merge segments sharing a ref into one centreline spanning their extreme endpoints.
|
||||
merged, groups = [], {}
|
||||
for rw in runways:
|
||||
if rw[1]:
|
||||
groups.setdefault(rw[1], []).append(rw)
|
||||
else:
|
||||
merged.append(rw)
|
||||
for ref, segs in groups.items():
|
||||
pts = np.array([[s[2], s[3]] for s in segs] + [[s[4], s[5]] for s in segs])
|
||||
main = max(segs, key=lambda s: math.hypot(s[4] - s[2], s[5] - s[3]))
|
||||
ax = np.array([main[4] - main[2], main[5] - main[3]])
|
||||
ax /= np.linalg.norm(ax)
|
||||
t = (pts - pts[0]) @ ax
|
||||
p1, p2 = pts[int(np.argmin(t))], pts[int(np.argmax(t))]
|
||||
merged.append([main[0], ref, r1(p1[0]), r1(p1[1]), r1(p2[0]), r1(p2[1])])
|
||||
if len(merged) != len(runways):
|
||||
print(f" runways: merged {len(runways)} OSM segments into {len(merged)}")
|
||||
runways = merged
|
||||
out = {"runways": runways, "taxiways": taxiways, "aprons": aprons, "apronNames": apron_names,
|
||||
"terminals": terminals, "gates": gates, "parking": parking}
|
||||
print(" aeroway: " + ", ".join(f"{k}={len(v)}" for k, v in out.items()))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- tiles / imagery
|
||||
def merc_px(lon, lat, z, ts=256):
|
||||
n = 2 ** z
|
||||
x = (lon + 180.0) / 360.0 * n * ts
|
||||
lat_r = np.radians(lat)
|
||||
y = (1.0 - np.log(np.tan(lat_r) + 1.0 / np.cos(lat_r)) / math.pi) / 2.0 * n * ts
|
||||
return x, y
|
||||
|
||||
|
||||
def fetch_tiles(url_fmt, tiles, subdir, ext, workers=8):
|
||||
d = os.path.join(CACHE, subdir)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
sess = requests.Session()
|
||||
sess.headers.update(UA)
|
||||
fails = []
|
||||
|
||||
def get(t):
|
||||
z, x, y = t
|
||||
p = os.path.join(d, f"{z}_{x}_{y}.{ext}")
|
||||
if os.path.exists(p) and os.path.getsize(p) > 0:
|
||||
return p
|
||||
for i in range(4):
|
||||
try:
|
||||
r = sess.get(url_fmt.format(z=z, x=x, y=y), timeout=60)
|
||||
if r.status_code == 200 and len(r.content) > 100:
|
||||
with open(p + ".tmp", "wb") as f:
|
||||
f.write(r.content)
|
||||
os.replace(p + ".tmp", p)
|
||||
return p
|
||||
if r.status_code == 404:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.5 * (i + 1))
|
||||
fails.append(t)
|
||||
return None
|
||||
|
||||
with ThreadPoolExecutor(workers) as ex:
|
||||
res = list(ex.map(get, tiles))
|
||||
return dict(zip(tiles, res)), fails
|
||||
|
||||
|
||||
def mosaic(frame, z, url_fmt, subdir, ext, mode, pad_m=300.0):
|
||||
s, w, n, e = frame.bbox(pad_m)
|
||||
x0, y1 = merc_px(w, s, z)
|
||||
x1, y0 = merc_px(e, n, z)
|
||||
tx0, tx1 = int(x0 // 256), int(x1 // 256)
|
||||
ty0, ty1 = int(y0 // 256), int(y1 // 256)
|
||||
tiles = [(z, x, y) for y in range(ty0, ty1 + 1) for x in range(tx0, tx1 + 1)]
|
||||
print(f" {subdir} z{z}: {tx1-tx0+1}x{ty1-ty0+1} = {len(tiles)} tiles", flush=True)
|
||||
paths, fails = fetch_tiles(url_fmt, tiles, subdir, ext)
|
||||
if fails:
|
||||
print(f" WARNING: {len(fails)} tiles failed: {fails[:10]}")
|
||||
W, H = (tx1 - tx0 + 1) * 256, (ty1 - ty0 + 1) * 256
|
||||
nb = 3 if mode == "RGB" else 3
|
||||
mos = np.zeros((H, W, nb), np.uint8)
|
||||
for (zz, x, y), p in paths.items():
|
||||
if p is None:
|
||||
continue
|
||||
im = Image.open(p).convert("RGB")
|
||||
if im.size != (256, 256):
|
||||
im = im.resize((256, 256))
|
||||
mos[(y - ty0) * 256:(y - ty0 + 1) * 256, (x - tx0) * 256:(x - tx0 + 1) * 256] = np.asarray(im)
|
||||
return mos, tx0 * 256, ty0 * 256, len(fails)
|
||||
|
||||
|
||||
def bilinear(img, px, py):
|
||||
"""img HxWxC (float or uint8), px/py float arrays in pixel coords (pixel centres at i+0.5)."""
|
||||
H, W = img.shape[:2]
|
||||
fx = np.clip(px - 0.5, 0, W - 1.000001)
|
||||
fy = np.clip(py - 0.5, 0, H - 1.000001)
|
||||
x0 = np.floor(fx).astype(np.int64)
|
||||
y0 = np.floor(fy).astype(np.int64)
|
||||
ax = (fx - x0)[..., None]
|
||||
ay = (fy - y0)[..., None]
|
||||
im = img if img.ndim == 3 else img[..., None]
|
||||
a = im[y0, x0].astype(np.float32)
|
||||
b = im[y0, x0 + 1].astype(np.float32)
|
||||
c = im[y0 + 1, x0].astype(np.float32)
|
||||
d = im[y0 + 1, x0 + 1].astype(np.float32)
|
||||
out = (a * (1 - ax) + b * ax) * (1 - ay) + (c * (1 - ax) + d * ax) * ay
|
||||
return out if img.ndim == 3 else out[..., 0]
|
||||
|
||||
|
||||
def reproject(frame, img, ox, oy, z, N, rows_per=256):
|
||||
"""Sample a Mercator mosaic (origin global px ox,oy at zoom z) onto the local NxN square."""
|
||||
h = frame.half
|
||||
out = np.zeros((N, N) + img.shape[2:], np.float32)
|
||||
xs = -h + (np.arange(N) + 0.5) * (2 * h / N)
|
||||
for r0 in range(0, N, rows_per):
|
||||
r = np.arange(r0, min(N, r0 + rows_per))
|
||||
ys = h - (r + 0.5) * (2 * h / N)
|
||||
X, Y = np.meshgrid(xs, ys)
|
||||
lon, lat = frame.ll(X, Y)
|
||||
mx, my = merc_px(lon, lat, z)
|
||||
out[r0:r0 + len(r)] = bilinear(img, mx - ox, my - oy)
|
||||
return out
|
||||
|
||||
|
||||
ESRI = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||
|
||||
|
||||
EOX_S2 = "https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2020_3857/default/g/{z}/{y}/{x}.jpg"
|
||||
|
||||
|
||||
def build_ground(frame, z, url=ESRI, sub="esri", name="ground"):
|
||||
mos, ox, oy, nf = mosaic(frame, z, url, sub, "jpg", "RGB")
|
||||
out = reproject(frame, mos, ox, oy, z, 4096)
|
||||
im = Image.fromarray(np.clip(out + 0.5, 0, 255).astype(np.uint8))
|
||||
p = os.path.join(frame.out, f"{name}.jpg")
|
||||
im.save(p, quality=90, optimize=True)
|
||||
print(f" wrote {p} ({os.path.getsize(p)/1e6:.2f} MB)")
|
||||
im2 = im.resize((2048, 2048), Image.LANCZOS)
|
||||
p2 = os.path.join(frame.out, f"{name}_2k.jpg")
|
||||
im2.save(p2, quality=88, optimize=True)
|
||||
print(f" wrote {p2} ({os.path.getsize(p2)/1e6:.2f} MB)")
|
||||
return nf
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- check overlay
|
||||
def to_px(frame, a, N):
|
||||
a = np.asarray(a, dtype=np.float64).reshape(-1, 2)
|
||||
return [((x + frame.half) / (2 * frame.half) * N, (frame.half - y) / (2 * frame.half) * N) for x, y in a]
|
||||
|
||||
|
||||
def check_canvas(frame, dark=0.45):
|
||||
from PIL import ImageEnhance
|
||||
im = Image.open(os.path.join(frame.out, "ground_2k.jpg")).convert("RGB")
|
||||
im = ImageEnhance.Brightness(im).enhance(dark)
|
||||
return im
|
||||
@@ -1,6 +1,6 @@
|
||||
// Export the film clock for the score: every visual event the audio locks to.
|
||||
import fs from 'node:fs';
|
||||
import { T, FEEDS, DURATION, captureTimes, CAPTURE_SLOW } from '../src/timeline.js';
|
||||
import { T, FEEDS, CITIES, DURATION, captureTimes, CAPTURE_SLOW } from '../src/timeline.js';
|
||||
import { angDist } from '../src/geo.js';
|
||||
|
||||
const heroes = JSON.parse(fs.readFileSync('imagery/hero/index.json', 'utf8'));
|
||||
@@ -34,7 +34,7 @@ const boot = [];
|
||||
}
|
||||
});
|
||||
|
||||
const status = [53.4, 54.5, 55.6, 56.7, 57.7];
|
||||
const status = [77.4, 78.5, 79.6, 80.7, 81.7];
|
||||
const rapid = Array.from({ length: 7 }, (_, i) => T.rapid + i * 0.22);
|
||||
fs.writeFileSync('audio/cues.json', JSON.stringify({ T, DURATION, feeds: FEEDS.map((x) => ({ key: x.key, t: x.t })), caps, tiles, boot, status, rapid }, null, 1));
|
||||
fs.writeFileSync('audio/cues.json', JSON.stringify({ T, DURATION, feeds: FEEDS.map((x) => ({ key: x.key, t: x.t })), cities: CITIES.map((c) => ({ slug: c.slug, t0: c.t0, t1: c.t1 })), caps, tiles, boot, status, rapid }, null, 1));
|
||||
console.log('cues:', caps.length, 'captures,', tiles.length, 'tiles,', boot.length, 'keystrokes');
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# v2: sound design only (made to sit under a music track). Normalise the SFX
|
||||
# mix to -16 LUFS, keep it as a stem, and mux share + master cuts.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
node tools/export-cues.mjs
|
||||
python3 audio/sfx.py
|
||||
stats=$(ffmpeg -hide_banner -i audio/sfx.wav -af loudnorm=I=-16:TP=-1.5:LRA=14:print_format=json -f null - 2>&1 | sed -n '/^{/,/^}/p')
|
||||
g() { echo "$stats" | python3 -c "import json,sys;print(json.load(sys.stdin)['$1'])"; }
|
||||
ffmpeg -y -loglevel error -i audio/sfx.wav -af "loudnorm=I=-16:TP=-1.5:LRA=14:measured_I=$(g input_i):measured_TP=$(g input_tp):measured_LRA=$(g input_lra):measured_thresh=$(g input_thresh):linear=true,aresample=48000" -c:a pcm_s24le audio/sfx_norm.wav
|
||||
mkdir -p out/final_v2
|
||||
cp audio/sfx_norm.wav out/final_v2/GodsEyeView_FirstLight_v2_SFX_stem.wav
|
||||
for tag in 16x9 9x16; do
|
||||
v=out/v2_video_$tag.mp4
|
||||
[ -s "$v" ] || { echo "missing $v"; continue; }
|
||||
ffmpeg -y -loglevel error -i $v -i audio/sfx_norm.wav -map 0:v -map 1:a -c:v copy -c:a aac -b:a 320k -shortest -movflags +faststart out/final_v2/GodsEyeView_FirstLight_v2_${tag}_master.mp4
|
||||
ffmpeg -y -loglevel error -i $v -c:v libx264 -preset slow -b:v 16M -maxrate 24M -bufsize 32M -pass 1 -passlogfile out/final_v2/p_$tag -an -f null /dev/null
|
||||
ffmpeg -y -loglevel error -i $v -i audio/sfx_norm.wav -map 0:v -map 1:a -c:v libx264 -preset slow -b:v 16M -maxrate 24M -bufsize 32M -pass 2 -passlogfile out/final_v2/p_$tag \
|
||||
-pix_fmt yuv420p -colorspace bt709 -color_primaries bt709 -color_trc bt709 -c:a aac -b:a 256k -shortest -movflags +faststart out/final_v2/GodsEyeView_FirstLight_v2_${tag}.mp4
|
||||
rm -f out/final_v2/p_$tag*
|
||||
echo "finalized $tag"
|
||||
done
|
||||
ls -la out/final_v2
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Build the flurry montage captures.
|
||||
|
||||
python3 select_cities.py # -> city_candidates.json (primary + backup per region)
|
||||
python3 build.py # -> imagery/flurry/fx_###.jpg, index.json, tools/flurry/sheet.jpg
|
||||
|
||||
Each capture: Esri World Imagery, 3x3 tiles around the point, centred 512x512
|
||||
crop, JPEG q86. Placeholder / low-variance captures are rejected automatically
|
||||
(tiles.quality_ok); captures that passed the metrics but looked wrong on the
|
||||
review sheets (forest, farmland, haze) are listed in VISUAL_REJECT. Rejected
|
||||
items are replaced by the next backup of the same region (cities) or by the
|
||||
next backup landmark.
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from landmarks import LANDMARKS # noqa: E402
|
||||
from tiles import HERE, MAX_WORKERS, OUT, capture, quality_ok # noqa: E402
|
||||
|
||||
N_CITIES = 170
|
||||
N_LANDMARKS = 90
|
||||
|
||||
# Natural Earth points that landed on a park/water/cemetery: moved to the dense centre.
|
||||
CITY_OVERRIDES = {
|
||||
"Lagos": (6.4541, 3.3947), # Lagos Island (was open lagoon)
|
||||
"Cairns": (-16.9230, 145.7740), # CBD (was mangrove mudflat)
|
||||
"Calgary": (51.0460, -114.0650), # downtown (was cemetery)
|
||||
"Dubai": (25.0805, 55.1403), # Dubai Marina (was construction lot)
|
||||
"Perth": (-31.9530, 115.8575), # CBD (was Kings Park bush)
|
||||
"Dhaka": (23.7330, 90.4172), # Motijheel (was hazy open ground)
|
||||
"Delhi": (28.6315, 77.2167), # Connaught Place
|
||||
"Montréal": (45.5017, -73.5673), # downtown (was Mount Royal park)
|
||||
"Busan": (35.1000, 129.0350), # Nampo / harbour (was forested hill)
|
||||
"Jeddah": (21.4858, 39.1925), # Al-Balad old town (was empty lot)
|
||||
"Tokyo": (35.6717, 139.7650), # Ginza (was Imperial Palace woods)
|
||||
}
|
||||
VISUAL_REJECT = {
|
||||
"Venice": "blurry low-res imagery",
|
||||
"Conakry": "heavy haze",
|
||||
"Maracaibo": "farmland / scrub",
|
||||
"Barquisimeto": "dry riverbed, no city",
|
||||
"Natal": "solid forest canopy",
|
||||
"João Pessoa": "forest canopy",
|
||||
"Mbuji-Mayi": "forest/scrub",
|
||||
"Huambo": "scrub, sparse",
|
||||
"Port Harcourt": "haze",
|
||||
"Qaraghandy": "dark featureless rail yard",
|
||||
"Herat": "low-res blur",
|
||||
"Chengdu": "smog haze",
|
||||
"Xian": "washed-out industrial lot",
|
||||
"Kaohsiung": "forested hill",
|
||||
"Sapporo": "university farm fields",
|
||||
"Isfahan": "low contrast blur",
|
||||
"Stuttgart": "hazy woodland",
|
||||
"Townsville": "airport apron, not city",
|
||||
}
|
||||
LANDMARK_BACKUPS = [
|
||||
"Glen Canyon Dam", "Grand Coulee Dam", "Mount St. Helens", "Groom Lake", "Diavik Diamond Mine",
|
||||
"Syncrude Mildred Lake oil sands", "Centre-pivot fields, Kansas", "Indianapolis Motor Speedway",
|
||||
"Brooklyn Bridge", "Panama Canal, Miraflores Locks", "Escondida Mine", "ALMA Observatory",
|
||||
"Meeting of the Waters, Manaus", "Brasilia, National Congress", "PS10 / PS20 Solar Towers", "Tower Bridge",
|
||||
"Port of Tema", "Akosombo Dam", "Okavango Delta", "FNB Stadium, Soweto", "East Oweinat pivots",
|
||||
"Kilimanjaro, Kibo crater", "Ras Laffan Industrial City", "Dubai International Airport", "Bagan temples",
|
||||
"Jiuquan Launch Centre", "Mount Bromo", "Vostochny Cosmodrome", "Pine Gap", "Tanegashima Space Center",
|
||||
"Teide", "Faa'a International Airport", "Hartsfield-Jackson Atlanta Airport", "Niagara Horseshoe Falls",
|
||||
"Iguazu Falls, Devil's Throat", "Vandenberg SLC-4E", "Paranal Observatory", "Cerro Dominador Solar",
|
||||
"Gibraltar Airport runway", "Erg Chebbi dunes", "Millau Viaduct", "Garzweiler Lignite Mine", "Inga Dams",
|
||||
"Grand Ethiopian Renaissance Dam", "Ras Tanura refinery", "Udachnaya Pipe", "Busan New Port",
|
||||
"Mount Taranaki", "Beijing National Stadium", "Port of Hamburg, Burchardkai", "Port of Santos",
|
||||
"Jebel Ali Port", "Mont-Saint-Michel", "Angkor Wat", "Mount Vesuvius", "Horseshoe Bend",
|
||||
"Benban Solar Park", "Borobudur", "Bhadla Solar Park", "Kariba Dam", "Wake Island Airfield",
|
||||
"Diamond Head", "Port Hedland salt ponds",
|
||||
]
|
||||
|
||||
|
||||
def evaluate(cands):
|
||||
def job(c):
|
||||
try:
|
||||
img, info = capture(c["lat"], c["lon"], c["zoom"])
|
||||
ok, why = quality_ok(info)
|
||||
except Exception as e: # network failure counts as a reject
|
||||
img, info, ok, why = None, {}, False, f"fetch error: {e}"
|
||||
if ok and c["name"] in VISUAL_REJECT and c.get("kind") == "city":
|
||||
ok, why = False, "visual: " + VISUAL_REJECT[c["name"]]
|
||||
return img, ok, why
|
||||
|
||||
with ThreadPoolExecutor(MAX_WORKERS) as ex:
|
||||
return list(ex.map(job, cands))
|
||||
|
||||
|
||||
def pick(cands, results, quota_by_group):
|
||||
"""Primaries first in order; failures replaced by the group's backups in order."""
|
||||
chosen, rejects = [], []
|
||||
for g, quota in quota_by_group.items():
|
||||
items = [(c, r) for c, r in zip(cands, results) if c["group"] == g]
|
||||
prim = [x for x in items if x[0]["rank"] == "primary"]
|
||||
back = [x for x in items if x[0]["rank"] == "backup"]
|
||||
got = []
|
||||
for c, r in prim + back:
|
||||
if len(got) >= quota:
|
||||
break
|
||||
if r[1]:
|
||||
got.append((c, r[0]))
|
||||
elif c["rank"] == "primary":
|
||||
rejects.append((c, r[2]))
|
||||
if len(got) < quota:
|
||||
print(f"!! group {g}: only {len(got)}/{quota}")
|
||||
chosen += got
|
||||
return chosen, rejects
|
||||
|
||||
|
||||
def main():
|
||||
cities = json.load(open(os.path.join(HERE, "city_candidates.json")))
|
||||
for c in cities:
|
||||
c["kind"], c["group"] = "city", "c:" + c["region"]
|
||||
if c["name"] in CITY_OVERRIDES:
|
||||
c["lat"], c["lon"] = CITY_OVERRIDES[c["name"]]
|
||||
cquota = {}
|
||||
for c in cities:
|
||||
if c["rank"] == "primary":
|
||||
cquota[c["group"]] = cquota.get(c["group"], 0) + 1
|
||||
assert sum(cquota.values()) == N_CITIES, cquota
|
||||
|
||||
lms = [dict(name=n, country=co, lat=la, lon=lo, zoom=z, kind="landmark", group="landmark",
|
||||
rank="backup" if n in LANDMARK_BACKUPS else "primary") for n, co, la, lo, z in LANDMARKS]
|
||||
lms.sort(key=lambda c: c["rank"] != "primary") # stable: primaries first
|
||||
nprim = sum(c["rank"] == "primary" for c in lms)
|
||||
assert nprim == N_LANDMARKS, nprim
|
||||
|
||||
allc = cities + lms
|
||||
print(f"evaluating {len(allc)} candidates ...", flush=True)
|
||||
res = evaluate(allc)
|
||||
quotas = dict(cquota)
|
||||
quotas["landmark"] = N_LANDMARKS
|
||||
chosen, rejects = pick(allc, res, quotas)
|
||||
|
||||
chosen.sort(key=lambda x: x[0]["lon"])
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
for f in glob.glob(os.path.join(OUT, "fx_*.jpg")):
|
||||
os.remove(f)
|
||||
index = []
|
||||
for i, (c, img) in enumerate(chosen):
|
||||
fn = f"fx_{i:03d}.jpg"
|
||||
img.save(os.path.join(OUT, fn), quality=86, optimize=True)
|
||||
index.append({"file": fn, "name": c["name"], "country": c["country"], "lat": round(c["lat"], 5),
|
||||
"lon": round(c["lon"], 5), "zoom": c["zoom"], "kind": c["kind"]})
|
||||
with open(os.path.join(OUT, "index.json"), "w") as f:
|
||||
json.dump(index, f, indent=1, ensure_ascii=False)
|
||||
|
||||
# contact sheet: 16 x 17 grid of 96px thumbs, west->east reading order
|
||||
cols, cell = 16, 96
|
||||
rows = (len(chosen) + cols - 1) // cols
|
||||
sheet = Image.new("RGB", (cols * cell, rows * cell), (10, 10, 10))
|
||||
for i, (c, img) in enumerate(chosen):
|
||||
sheet.paste(img.resize((cell, cell), Image.LANCZOS), ((i % cols) * cell, (i // cols) * cell))
|
||||
sheet.save(os.path.join(HERE, "sheet.jpg"), quality=88)
|
||||
|
||||
from collections import Counter
|
||||
print(f"wrote {len(index)} captures: {Counter(x['kind'] for x in index)}")
|
||||
print("rejected primaries (replaced by backups):")
|
||||
for c, why in rejects:
|
||||
print(f" - {c['name']} ({c['country']}): {why}")
|
||||
bands = Counter(int((x["lon"] + 180) // 30) for x in index)
|
||||
print("30-degree longitude bands W->E:", [bands.get(i, 0) for i in range(12)])
|
||||
json.dump([{"name": c["name"], "country": c["country"], "reason": why} for c, why in rejects],
|
||||
open(os.path.join(HERE, "rejects.json"), "w"), indent=1, ensure_ascii=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,205 @@
|
||||
"""Hand-picked non-city landmarks for the flurry montage.
|
||||
|
||||
(name, country, lat, lon, zoom). zoom 16 for compact features, 15 for very large
|
||||
ones (deltas, glaciers, big pits, pivot fields). Coordinates are verified
|
||||
visually via tools/flurry/review.py sheets; entries that came back as featureless
|
||||
terrain were corrected or dropped (see REJECTED at the bottom).
|
||||
"""
|
||||
|
||||
LANDMARKS = [
|
||||
# --- ports / container terminals ---
|
||||
("Port of Rotterdam, Maasvlakte", "Netherlands", 51.9575, 4.0500, 16),
|
||||
("Port of Singapore, Pasir Panjang", "Singapore", 1.2735, 103.7750, 16),
|
||||
("Yangshan Deep-Water Port", "China", 30.6280, 122.0650, 16),
|
||||
("Port of Los Angeles, Pier 400", "United States of America", 33.7350, -118.2550, 16),
|
||||
("Jebel Ali Port", "United Arab Emirates", 25.0100, 55.0600, 16),
|
||||
("Port of Santos", "Brazil", -23.9700, -46.3000, 16),
|
||||
("Port of Hamburg, Burchardkai", "Germany", 53.5350, 9.9200, 16),
|
||||
("Port Hedland salt ponds", "Australia", -20.3450, 118.6550, 15),
|
||||
("Busan New Port", "South Korea", 35.0800, 128.8000, 16),
|
||||
("Tanger Med Port", "Morocco", 35.8850, -5.5000, 16),
|
||||
("Port of Tema", "Ghana", 5.6330, 0.0100, 16),
|
||||
# --- airports ---
|
||||
("Hartsfield-Jackson Atlanta Airport", "United States of America", 33.6407, -84.4277, 16),
|
||||
("Dubai International Airport", "United Arab Emirates", 25.2528, 55.3644, 16),
|
||||
("Hong Kong International Airport", "Hong Kong S.A.R.", 22.3080, 113.9185, 16),
|
||||
("Denver International Airport", "United States of America", 39.8494, -104.6738, 16),
|
||||
("Kansai International Airport", "Japan", 34.4320, 135.2304, 16),
|
||||
("Beijing Daxing Airport", "China", 39.5098, 116.4105, 16),
|
||||
("Davis-Monthan Aircraft Boneyard", "United States of America", 32.1500, -110.8300, 16),
|
||||
("Princess Juliana Airport", "Sint Maarten", 18.0410, -63.1089, 16),
|
||||
("Gibraltar Airport runway", "Gibraltar", 36.1512, -5.3497, 16),
|
||||
("Faa'a International Airport", "French Polynesia", -17.5537, -149.6067, 16),
|
||||
("Funafuti Airfield", "Tuvalu", -8.5250, 179.1960, 16),
|
||||
("Midway Atoll, Sand Island", "United States of America", 28.2100, -177.3800, 16),
|
||||
("Wake Island Airfield", "United States of America", 19.2820, 166.6470, 16),
|
||||
# --- dams ---
|
||||
("Hoover Dam", "United States of America", 36.0160, -114.7377, 16),
|
||||
("Three Gorges Dam", "China", 30.8233, 111.0036, 16),
|
||||
("Itaipu Dam", "Brazil", -25.4085, -54.5888, 16),
|
||||
("Grand Coulee Dam", "United States of America", 47.9560, -118.9817, 16),
|
||||
("Kariba Dam", "Zimbabwe", -16.5222, 28.7614, 16),
|
||||
("Aswan High Dam", "Egypt", 23.9707, 32.8780, 16),
|
||||
("Grand Ethiopian Renaissance Dam", "Ethiopia", 11.2150, 35.0900, 16),
|
||||
("Glen Canyon Dam", "United States of America", 36.9375, -111.4844, 16),
|
||||
("Akosombo Dam", "Ghana", 6.2999, 0.0592, 16),
|
||||
("Inga Dams", "Congo (Kinshasa)", -5.5240, 13.6200, 16),
|
||||
# --- open-pit mines ---
|
||||
("Bingham Canyon Mine", "United States of America", 40.5230, -112.1510, 15),
|
||||
("Chuquicamata Mine", "Chile", -22.2900, -68.9000, 15),
|
||||
("Mir Diamond Mine", "Russia", 62.5275, 113.9936, 15),
|
||||
("Escondida Mine", "Chile", -24.2700, -69.0700, 15),
|
||||
("Kalgoorlie Super Pit", "Australia", -30.7775, 121.5040, 15),
|
||||
("Udachnaya Pipe", "Russia", 66.4300, 112.3200, 15),
|
||||
("Garzweiler Lignite Mine", "Germany", 51.0600, 6.4800, 15),
|
||||
("Kimberley Big Hole", "South Africa", -28.7383, 24.7585, 16),
|
||||
("Diavik Diamond Mine", "Canada", 64.4960, -110.2700, 15),
|
||||
("Syncrude Mildred Lake oil sands", "Canada", 57.0400, -111.6000, 15),
|
||||
# --- solar ---
|
||||
("Noor Ouarzazate Solar", "Morocco", 31.0498, -6.8686, 16),
|
||||
("Ivanpah Solar", "United States of America", 35.5525, -115.4680, 16),
|
||||
("Crescent Dunes Solar", "United States of America", 38.2386, -117.3636, 15),
|
||||
("Bhadla Solar Park", "India", 27.5400, 71.9100, 16),
|
||||
("Gemasolar", "Spain", 37.5606, -5.3317, 16),
|
||||
("PS10 / PS20 Solar Towers", "Spain", 37.4425, -6.2550, 16),
|
||||
("Benban Solar Park", "Egypt", 24.4500, 32.7300, 16),
|
||||
("Cerro Dominador Solar", "Chile", -22.7700, -69.4800, 16),
|
||||
# --- centre-pivot irrigation ---
|
||||
("Centre-pivot fields, Kansas", "United States of America", 37.9000, -100.9000, 15),
|
||||
("Centre-pivot fields, Wadi As-Sirhan", "Saudi Arabia", 30.2000, 38.0800, 15),
|
||||
("Kufra agricultural project", "Libya", 24.1400, 23.3500, 15),
|
||||
("East Oweinat pivots", "Egypt", 22.6000, 28.6000, 15),
|
||||
("Centre-pivot fields, Nebraska", "United States of America", 41.1000, -101.7000, 15),
|
||||
# --- river deltas / water ---
|
||||
("Lena Delta", "Russia", 72.8000, 126.5000, 15),
|
||||
("Okavango Delta", "Botswana", -19.3000, 22.9000, 15),
|
||||
("Betsiboka Estuary", "Madagascar", -15.7500, 46.3500, 15),
|
||||
("Meeting of the Waters, Manaus", "Brazil", -3.1370, -59.9100, 15),
|
||||
# --- salt / evaporation ---
|
||||
("Salar de Atacama lithium ponds", "Chile", -23.5280, -68.3680, 16),
|
||||
("Dead Sea evaporation ponds", "Israel", 31.1000, 35.4000, 15),
|
||||
("San Francisco Bay salt ponds", "United States of America", 37.5000, -122.0500, 15),
|
||||
("Lake Retba (Pink Lake)", "Senegal", 14.8400, -17.2300, 15),
|
||||
("Hutt Lagoon", "Australia", -28.1700, 114.2500, 15),
|
||||
# --- glaciers / ice ---
|
||||
("Perito Moreno Glacier", "Argentina", -50.4800, -73.0500, 15),
|
||||
("Aletsch Glacier, Konkordiaplatz", "Switzerland", 46.5000, 8.0300, 15),
|
||||
("Hubbard Glacier", "United States of America", 60.0200, -139.5000, 15),
|
||||
# Antarctica: Esri has only Landsat-class imagery here (z14+ is placeholder),
|
||||
# so these use z11-12 where the source pixels are native rather than upsampled.
|
||||
("Taylor Valley, Canada Glacier", "Antarctica", -77.6300, 162.9500, 12),
|
||||
("McMurdo Dry Valleys, Wright Valley", "Antarctica", -77.5700, 161.8500, 11),
|
||||
("Deception Island caldera", "Antarctica", -62.9600, -60.6300, 12),
|
||||
("Mount Everest", "Nepal", 27.9881, 86.9250, 15),
|
||||
# --- volcanoes / craters ---
|
||||
("Mount Bromo", "Indonesia", -7.9420, 112.9530, 15),
|
||||
("Erta Ale", "Ethiopia", 13.6065, 40.6655, 16),
|
||||
("Kilauea, Halemaumau", "United States of America", 19.4060, -155.2830, 15),
|
||||
("Mount Fuji summit crater", "Japan", 35.3628, 138.7307, 16),
|
||||
("Nyiragongo", "Congo (Kinshasa)", -1.5200, 29.2500, 16),
|
||||
("Meteor Crater", "United States of America", 35.0275, -111.0225, 15),
|
||||
("Mount St. Helens", "United States of America", 46.2000, -122.1900, 15),
|
||||
("Kawah Ijen", "Indonesia", -8.0580, 114.2420, 16),
|
||||
("Teide", "Spain", 28.2725, -16.6425, 16),
|
||||
("Mount Vesuvius", "Italy", 40.8216, 14.4263, 16),
|
||||
("Rano Kau, Easter Island", "Chile", -27.1860, -109.4350, 15),
|
||||
("Diamond Head", "United States of America", 21.2620, -157.8050, 15),
|
||||
("Darvaza Gas Crater", "Turkmenistan", 40.2525, 58.4394, 16),
|
||||
("Kilimanjaro, Kibo crater", "Tanzania", -3.0758, 37.3533, 15),
|
||||
("Mount Taranaki", "New Zealand", -39.2968, 174.0634, 15),
|
||||
("Whakaari / White Island", "New Zealand", -37.5200, 177.1800, 15),
|
||||
("Dallol", "Ethiopia", 14.2400, 40.3000, 16),
|
||||
# --- stadiums / venues ---
|
||||
("Maracana Stadium", "Brazil", -22.9122, -43.2302, 16),
|
||||
("Rungrado May Day Stadium", "North Korea", 39.0497, 125.7756, 16),
|
||||
("Camp Nou", "Spain", 41.3809, 2.1228, 16),
|
||||
("Beijing National Stadium", "China", 39.9925, 116.3910, 16),
|
||||
("Melbourne Cricket Ground", "Australia", -37.8200, 144.9834, 16),
|
||||
("Indianapolis Motor Speedway", "United States of America", 39.7950, -86.2350, 15),
|
||||
("Estadio Azteca", "Mexico", 19.3029, -99.1505, 16),
|
||||
("FNB Stadium, Soweto", "South Africa", -26.2348, 27.9825, 16),
|
||||
# --- bridges / canals ---
|
||||
("Golden Gate Bridge", "United States of America", 37.8100, -122.4770, 16),
|
||||
("Sydney Harbour Bridge & Opera House", "Australia", -33.8550, 151.2120, 16),
|
||||
("Tower Bridge", "United Kingdom", 51.5055, -0.0754, 16),
|
||||
("Oresund Bridge, Peberholm", "Denmark", 55.5900, 12.7740, 16),
|
||||
("Millau Viaduct", "France", 44.0794, 3.0225, 16),
|
||||
("Brooklyn Bridge", "United States of America", 40.7061, -73.9969, 16),
|
||||
("Panama Canal, Miraflores Locks", "Panama", 8.9970, -79.5915, 16),
|
||||
("Panama Canal, Agua Clara Locks", "Panama", 9.2720, -79.9180, 16),
|
||||
# --- launch sites ---
|
||||
("Kennedy Space Center LC-39A", "United States of America", 28.6083, -80.6041, 16),
|
||||
("Baikonur, Gagarin's Start", "Kazakhstan", 45.9203, 63.3422, 16),
|
||||
("Vandenberg SLC-4E", "United States of America", 34.6320, -120.6110, 16),
|
||||
("Starbase, Boca Chica", "United States of America", 25.9972, -97.1566, 16),
|
||||
("Wenchang Launch Site", "China", 19.6145, 110.9510, 16),
|
||||
("Jiuquan Launch Centre", "China", 40.9580, 100.2910, 16),
|
||||
("Tanegashima Space Center", "Japan", 30.4009, 130.9749, 16),
|
||||
("Vostochny Cosmodrome", "Russia", 51.8840, 128.3330, 16),
|
||||
("Rocket Lab LC-1, Mahia", "New Zealand", -39.2615, 177.8649, 16),
|
||||
# --- energy / industry ---
|
||||
("Permian Basin well pads", "United States of America", 32.2100, -103.8300, 15),
|
||||
("Ras Tanura refinery", "Saudi Arabia", 26.6400, 50.1600, 16),
|
||||
("Jamnagar Refinery", "India", 22.3500, 69.8500, 16),
|
||||
("Ras Laffan Industrial City", "Qatar", 25.9100, 51.5500, 16),
|
||||
("Yamal LNG, Sabetta", "Russia", 71.2700, 72.0700, 16),
|
||||
("Chernobyl Nuclear Power Plant", "Ukraine", 51.3890, 30.0990, 16),
|
||||
# --- monuments / sites ---
|
||||
("Jiayuguan Pass, Great Wall", "China", 39.8016, 98.2133, 16),
|
||||
("Pyramids of Giza", "Egypt", 29.9792, 31.1342, 16),
|
||||
("Palm Jumeirah", "United Arab Emirates", 25.1124, 55.1390, 15),
|
||||
("Angkor Wat", "Cambodia", 13.4125, 103.8670, 16),
|
||||
("The Pentagon", "United States of America", 38.8719, -77.0563, 16),
|
||||
("Groom Lake", "United States of America", 37.2350, -115.8111, 15),
|
||||
("FAST Radio Telescope", "China", 25.6529, 106.8566, 16),
|
||||
("Very Large Array", "United States of America", 34.0784, -107.6184, 16),
|
||||
("Uluru", "Australia", -25.3444, 131.0369, 15),
|
||||
("Mont-Saint-Michel", "France", 48.6361, -1.5115, 16),
|
||||
("Grand Prismatic Spring", "United States of America", 44.5251, -110.8382, 16),
|
||||
("Masjid al-Haram, Mecca", "Saudi Arabia", 21.4225, 39.8262, 16),
|
||||
("Forbidden City", "China", 39.9155, 116.3910, 16),
|
||||
("Taj Mahal", "India", 27.1751, 78.0421, 16),
|
||||
("Greenhouses of Almeria", "Spain", 36.7500, -2.7200, 16),
|
||||
("Pine Gap", "Australia", -23.7990, 133.7370, 16),
|
||||
("ALMA Observatory", "Chile", -23.0229, -67.7552, 16),
|
||||
("Paranal Observatory", "Chile", -24.6275, -70.4044, 16),
|
||||
("Brasilia, National Congress", "Brazil", -15.7998, -47.8645, 16),
|
||||
("Iguazu Falls, Devil's Throat", "Argentina", -25.6950, -54.4367, 16),
|
||||
("Niagara Horseshoe Falls", "Canada", 43.0773, -79.0757, 16),
|
||||
("Victoria Falls", "Zambia", -17.9243, 25.8572, 16),
|
||||
("Horseshoe Bend", "United States of America", 36.8791, -111.5103, 16),
|
||||
("Sossusvlei dunes", "Namibia", -24.7300, 15.2900, 15),
|
||||
("Erg Chebbi dunes", "Morocco", 31.1500, -3.9800, 15),
|
||||
("Bora Bora", "French Polynesia", -16.4950, -151.7050, 15),
|
||||
("Borobudur", "Indonesia", -7.6079, 110.2038, 16),
|
||||
("Bagan temples", "Myanmar", 21.1700, 94.8600, 16),
|
||||
("Svalbard, Longyearbyen", "Norway", 78.2200, 15.6500, 16),
|
||||
("Edinburgh of the Seven Seas, Tristan da Cunha", "United Kingdom", -37.0670, -12.3100, 16),
|
||||
("Diego Garcia", "United Kingdom", -7.3100, 72.4100, 15),
|
||||
("Kwajalein Atoll", "Marshall Islands", 8.7200, 167.7300, 16),
|
||||
]
|
||||
|
||||
REJECTED = {
|
||||
'Pituffik Space Base': 'placeholder above z13; z13 too blurry',
|
||||
'Ilulissat Icefjord': 'placeholder above z13; open water at z13',
|
||||
'McMurdo Station': 'placeholder above z13; blurry ice at z13',
|
||||
'Rothera Research Station': 'placeholder above z13; mostly sea',
|
||||
'Mount Erebus crater': 'placeholder above z13; cloud-like blur',
|
||||
'Port of Durban': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Port of Mombasa': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Sundarbans, Ganges Delta': "featureless/cloud/low-res/off-target at tested coords",
|
||||
"Mississippi Bird's-Foot Delta": "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Volga Delta': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Great Salt Lake causeway': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Jokulsarlon Glacier Lagoon': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Baltoro Glacier, Concordia': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Cotopaxi': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Guiana Space Centre ELA-3': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Satish Dhawan Space Centre': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Abqaiq processing plant': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Prudhoe Bay oil field': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Great Wall at Mutianyu': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Machu Picchu': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Baltra Island airport, Galapagos': "featureless/cloud/low-res/off-target at tested coords",
|
||||
'Moynaq ship graveyard, Aral Sea': "featureless/cloud/low-res/off-target at tested coords",
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Locator sheets: a wider mosaic around a guess with a lat/lon grid, to find exact coords.
|
||||
|
||||
usage: python3 locate.py out.jpg "name|lat|lon|z|ntiles" ...
|
||||
Each panel is ntiles x ntiles tiles at zoom z (resized to 640px) with grid
|
||||
lines every `step` degrees, labelled. Panels are stacked in a 2-column grid.
|
||||
"""
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from tiles import fetch_tile, pixel_xy # noqa: E402
|
||||
|
||||
P = 640
|
||||
|
||||
|
||||
def panel(name, lat, lon, z, nt):
|
||||
px, py = pixel_xy(lat, lon, z)
|
||||
tx, ty = int(px // 256), int(py // 256)
|
||||
h = nt // 2
|
||||
coords = [(tx - h + i, ty - h + j) for j in range(nt) for i in range(nt)]
|
||||
with ThreadPoolExecutor(8) as ex:
|
||||
datas = list(ex.map(lambda c: fetch_tile(z, c[0], c[1]), coords))
|
||||
m = Image.new("RGB", (nt * 256, nt * 256))
|
||||
for (x, y), d in zip(coords, datas):
|
||||
m.paste(Image.open(io.BytesIO(d)).convert("RGB"), ((x - tx + h) * 256, (y - ty + h) * 256))
|
||||
ox, oy = (tx - h) * 256, (ty - h) * 256
|
||||
s = P / (nt * 256)
|
||||
m = m.resize((P, P), Image.LANCZOS)
|
||||
d = ImageDraw.Draw(m)
|
||||
n = 2 ** z * 256
|
||||
|
||||
def lon2x(lo):
|
||||
return ((lo + 180) / 360 * n - ox) * s
|
||||
|
||||
def lat2y(la):
|
||||
si = math.sin(math.radians(la))
|
||||
return ((0.5 - math.log((1 + si) / (1 - si)) / (4 * math.pi)) * n - oy) * s
|
||||
|
||||
span = nt * 256 / n * 360
|
||||
step = [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5][
|
||||
min(range(9), key=lambda k: abs(span / [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5][k] - 6))]
|
||||
lo0 = math.floor((lon - span) / step) * step
|
||||
for k in range(40):
|
||||
lo = lo0 + k * step
|
||||
x = lon2x(lo)
|
||||
if 0 <= x <= P:
|
||||
d.line((x, 0, x, P), fill=(255, 255, 0), width=1)
|
||||
d.text((x + 2, P - 12), f"{lo:.3f}", fill=(255, 255, 0))
|
||||
la0 = math.floor((lat - span) / step) * step
|
||||
for k in range(40):
|
||||
la = la0 + k * step
|
||||
y = lat2y(la)
|
||||
if 0 <= y <= P:
|
||||
d.line((0, y, P, y), fill=(0, 255, 255), width=1)
|
||||
d.text((2, y + 1), f"{la:.3f}", fill=(0, 255, 255))
|
||||
cx, cy = lon2x(lon), lat2y(lat)
|
||||
d.ellipse((cx - 5, cy - 5, cx + 5, cy + 5), outline=(255, 0, 0), width=2)
|
||||
d.rectangle((0, 0, P, 14), fill=(0, 0, 0))
|
||||
d.text((3, 1), f"{name} z{z}", fill=(255, 255, 255))
|
||||
return m
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out = sys.argv[1]
|
||||
specs = [a.split("|") for a in sys.argv[2:]]
|
||||
ps = [panel(s[0], float(s[1]), float(s[2]), int(s[3]), int(s[4])) for s in specs]
|
||||
cols = 2 if len(ps) > 1 else 1
|
||||
rows = (len(ps) + cols - 1) // cols
|
||||
sheet = Image.new("RGB", (cols * P, rows * P))
|
||||
for i, p in enumerate(ps):
|
||||
sheet.paste(p, ((i % cols) * P, (i // cols) * P))
|
||||
sheet.save(out, quality=85)
|
||||
print(out)
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"name": "Venice",
|
||||
"country": "Italy",
|
||||
"reason": "visual: blurry low-res imagery"
|
||||
},
|
||||
{
|
||||
"name": "Chengdu",
|
||||
"country": "China",
|
||||
"reason": "visual: smog haze"
|
||||
},
|
||||
{
|
||||
"name": "Xian",
|
||||
"country": "China",
|
||||
"reason": "visual: washed-out industrial lot"
|
||||
},
|
||||
{
|
||||
"name": "Maracaibo",
|
||||
"country": "Venezuela",
|
||||
"reason": "visual: farmland / scrub"
|
||||
},
|
||||
{
|
||||
"name": "Barquisimeto",
|
||||
"country": "Venezuela",
|
||||
"reason": "visual: dry riverbed, no city"
|
||||
},
|
||||
{
|
||||
"name": "Conakry",
|
||||
"country": "Guinea",
|
||||
"reason": "visual: heavy haze"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Fetch candidates and render labelled review sheets for visual verification.
|
||||
|
||||
usage: python3 review.py landmarks|cities [start] [count]
|
||||
Writes tools/flurry/review/<kind>_NN.jpg (5x4 grid, 256px cells, labelled).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from tiles import HERE, MAX_WORKERS, capture, quality_ok # noqa: E402
|
||||
|
||||
REV = os.path.join(HERE, "review")
|
||||
|
||||
|
||||
def load(kind):
|
||||
if kind == "landmarks":
|
||||
from landmarks import LANDMARKS
|
||||
return [dict(name=n, country=c, lat=la, lon=lo, zoom=z) for n, c, la, lo, z in LANDMARKS]
|
||||
return json.load(open(os.path.join(HERE, "city_candidates.json")))
|
||||
|
||||
|
||||
def run(cands, prefix, cell=256, cols=5, rows=4):
|
||||
os.makedirs(REV, exist_ok=True)
|
||||
|
||||
def job(c):
|
||||
try:
|
||||
img, info = capture(c["lat"], c["lon"], c["zoom"])
|
||||
return img, info
|
||||
except Exception as e:
|
||||
return None, {"error": str(e)}
|
||||
|
||||
with ThreadPoolExecutor(MAX_WORKERS) as ex:
|
||||
res = list(ex.map(job, cands))
|
||||
per = cols * rows
|
||||
report = []
|
||||
for s in range(0, len(cands), per):
|
||||
sheet = Image.new("RGB", (cols * cell, rows * cell), (20, 20, 20))
|
||||
d = ImageDraw.Draw(sheet)
|
||||
for k, (c, (img, info)) in enumerate(zip(cands[s:s + per], res[s:s + per])):
|
||||
x, y = (k % cols) * cell, (k // cols) * cell
|
||||
idx = s + k
|
||||
if img is not None:
|
||||
sheet.paste(img.resize((cell, cell), Image.LANCZOS), (x, y))
|
||||
ok, why = quality_ok(info)
|
||||
lab2 = f"s{info['std']:.0f} g{info['grad']:.1f}" + ("" if ok else " X " + why[:18])
|
||||
else:
|
||||
ok, lab2 = False, "ERR"
|
||||
d.rectangle((x, y, x + cell, y + 24), fill=(0, 0, 0))
|
||||
d.text((x + 3, y + 1), f"{idx} {c['name'][:36]}", fill=(255, 255, 0))
|
||||
d.text((x + 3, y + 12), lab2, fill=(255, 120, 120) if not ok else (160, 255, 160))
|
||||
report.append((idx, c["name"], ok, lab2))
|
||||
out = os.path.join(REV, f"{prefix}_{s // per:02d}.jpg")
|
||||
sheet.save(out, quality=85)
|
||||
print(out)
|
||||
for r in report:
|
||||
if not r[2]:
|
||||
print("REJECT", r)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
kind = sys.argv[1]
|
||||
cands = load(kind)
|
||||
st = int(sys.argv[2]) if len(sys.argv) > 2 else 0
|
||||
n = int(sys.argv[3]) if len(sys.argv) > 3 else len(cands)
|
||||
run(cands[st:st + n], kind[:2] + (f"_{st}" if st else ""))
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Pick ~170 city centres from data/cities.json with geographic spread.
|
||||
|
||||
Writes tools/flurry/city_candidates.json: ordered list of dicts with
|
||||
rank ("primary"/"backup") and region. Backups replace rejected primaries of the
|
||||
same region in build.py.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
|
||||
AFRICA = {
|
||||
"Nigeria", "Egypt", "Congo (Kinshasa)", "South Africa", "Algeria", "Sudan", "Morocco", "Tanzania",
|
||||
"Cameroon", "Mozambique", "Libya", "Ethiopia", "Ivory Coast", "Tunisia", "Zambia", "Angola", "Ghana",
|
||||
"Uganda", "Kenya", "Guinea", "Madagascar", "Senegal", "Zimbabwe", "Mali", "Somalia", "Chad", "Niger",
|
||||
"Benin", "Eritrea", "Somaliland", "South Sudan", "Sierra Leone", "Mauritania", "Malawi", "Togo",
|
||||
"Congo (Brazzaville)", "Burkina Faso", "Rwanda", "Mauritius", "Gabon", "Equatorial Guinea", "Liberia",
|
||||
"Djibouti", "Central African Republic", "Guinea Bissau", "Lesotho", "Burundi", "Namibia", "Botswana",
|
||||
"The Gambia", "Comoros", "Cape Verde", "eSwatini",
|
||||
}
|
||||
SOUTH_AMERICA = {"Brazil", "Argentina", "Colombia", "Venezuela", "Peru", "Chile", "Ecuador", "Bolivia",
|
||||
"Paraguay", "Uruguay", "Guyana", "Suriname"}
|
||||
OCEANIA = {"Australia", "New Zealand", "Papua New Guinea", "Fiji", "French Polynesia", "Guam"}
|
||||
CENTRAL_ASIA = {"Kazakhstan", "Uzbekistan", "Turkmenistan", "Kyrgyzstan", "Tajikistan", "Afghanistan", "Mongolia"}
|
||||
|
||||
# (region, primary quota, backups, per-country cap, min spacing km)
|
||||
PASSES = [
|
||||
("africa", AFRICA, 31, 16, 3, 300),
|
||||
("south_america", SOUTH_AMERICA, 22, 10, 5, 250),
|
||||
("oceania", OCEANIA, 12, 4, 5, 120),
|
||||
("central_asia", CENTRAL_ASIA, 9, 5, 3, 150),
|
||||
("rest", None, 96, 40, 11, 200),
|
||||
]
|
||||
# Picked first (in "rest" unless their country is in a quota region): fill the
|
||||
# Pacific / high-latitude longitude bands and add visually distinctive cities.
|
||||
FORCED = [
|
||||
"Honolulu", "Anchorage", "Papeete", "Agana", "Petropavlovsk-Kamchatsky", "Vancouver", "San Francisco",
|
||||
"Las Vegas", "Seattle", "Phoenix", "Denver", "Calgary", "Guatemala City", "Havana", "Reykjavík",
|
||||
"Dubai", "Doha", "Abu Dhabi", "Hong Kong", "Macau", "Venice", "Amsterdam", "København", "Oslo",
|
||||
"Stockholm", "Helsinki", "Prague", "Kathmandu", "Colombo", "Vladivostok", "Novosibirsk", "Irkutsk",
|
||||
"Yakutsk", "Norilsk", "Murmansk", "Manaus", "Mombasa", "Windhoek", "Nouakchott", "Marrakesh",
|
||||
"Samarkand", "Cairns", "Muscat", "Québec",
|
||||
]
|
||||
# Natural Earth rows whose point lands in farmland/hills rather than a dense centre.
|
||||
EXCLUDE = {"Amaravati", "Hechi", "Saidu", "Cranbourne", "Manukau", "Waitakere", "North Shore", "Wahiawa"}
|
||||
COUNTRY_CAP_TOTAL = 12
|
||||
|
||||
|
||||
def km(a, b):
|
||||
la1, lo1, la2, lo2 = map(math.radians, (a[1], a[0], b[1], b[0]))
|
||||
h = math.sin((la2 - la1) / 2) ** 2 + math.cos(la1) * math.cos(la2) * math.sin((lo2 - lo1) / 2) ** 2
|
||||
return 6371 * 2 * math.asin(math.sqrt(h))
|
||||
|
||||
|
||||
def main():
|
||||
rows = json.load(open(os.path.join(ROOT, "data", "cities.json")))["rows"]
|
||||
special = AFRICA | SOUTH_AMERICA | OCEANIA | CENTRAL_ASIA
|
||||
chosen, out = [], []
|
||||
byname = {}
|
||||
for r in rows:
|
||||
byname.setdefault(r[3], r) # first = most populous
|
||||
per = {}
|
||||
|
||||
def region_of(country):
|
||||
for reg, mem, *_ in PASSES:
|
||||
if mem and country in mem:
|
||||
return reg
|
||||
return "rest"
|
||||
|
||||
for nm in FORCED:
|
||||
lon, lat, pop, name, country = byname[nm]
|
||||
chosen.append((lon, lat))
|
||||
per[country] = per.get(country, 0) + 1
|
||||
out.append(dict(name=name, country=country, lat=lat, lon=lon, zoom=16, pop=pop,
|
||||
region=region_of(country), rank="primary", forced=True))
|
||||
for region, members, quota, nback, cap, spacing in PASSES:
|
||||
got = sum(1 for c in out if c["region"] == region)
|
||||
for lon, lat, pop, name, country in rows:
|
||||
if name in EXCLUDE or name in byname and any(c["name"] == name and c["country"] == country for c in out):
|
||||
continue
|
||||
inreg = (country in members) if members else (country not in special)
|
||||
if not inreg:
|
||||
continue
|
||||
if per.get(country, 0) >= cap and got < quota or per.get(country, 0) >= COUNTRY_CAP_TOTAL:
|
||||
continue
|
||||
if any(km((lon, lat), (c[0], c[1])) < spacing for c in chosen):
|
||||
continue
|
||||
rank = "primary" if got < quota else "backup"
|
||||
chosen.append((lon, lat))
|
||||
per[country] = per.get(country, 0) + 1
|
||||
out.append(dict(name=name, country=country, lat=lat, lon=lon, zoom=16, pop=pop,
|
||||
region=region, rank=rank))
|
||||
got += 1
|
||||
if got >= quota + nback:
|
||||
break
|
||||
json.dump(out, open(os.path.join(HERE, "city_candidates.json"), "w"), indent=0, ensure_ascii=False)
|
||||
prim = [c for c in out if c["rank"] == "primary"]
|
||||
print(len(prim), "primary,", len(out) - len(prim), "backup")
|
||||
from collections import Counter
|
||||
print(Counter(c["region"] for c in prim))
|
||||
print(Counter(c["country"] for c in prim).most_common(12))
|
||||
bands = Counter(int((c["lon"] + 180) // 30) for c in prim)
|
||||
print("30deg bands:", [bands.get(i, 0) for i in range(12)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Polite cached Esri World Imagery fetch + 512px centred capture + quality metrics."""
|
||||
import hashlib
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CACHE = os.path.join(HERE, "cache")
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
OUT = os.path.join(ROOT, "imagery", "flurry")
|
||||
|
||||
ESRI = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||
UA = "gods-eye-view-intro/1.0 (flurry montage prep)"
|
||||
MAX_WORKERS = 8
|
||||
PLACEHOLDER_MD5 = {"f27d9de7f80c13501f470595e327aa6d"} # "Map data not yet available"
|
||||
|
||||
_local = threading.local()
|
||||
|
||||
|
||||
def _session():
|
||||
s = getattr(_local, "s", None)
|
||||
if s is None:
|
||||
s = requests.Session()
|
||||
s.headers["User-Agent"] = UA
|
||||
_local.s = s
|
||||
return s
|
||||
|
||||
|
||||
def fetch_tile(z, x, y, retries=6):
|
||||
n = 2 ** z
|
||||
x %= n
|
||||
path = os.path.join(CACHE, str(z), str(x), f"{y}.jpg")
|
||||
if os.path.exists(path) and os.path.getsize(path) > 0:
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
url = ESRI.format(z=z, x=x, y=y)
|
||||
last = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
r = _session().get(url, timeout=45)
|
||||
if r.status_code == 200 and r.content:
|
||||
tmp = path + f".{threading.get_ident()}.part"
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(r.content)
|
||||
os.replace(tmp, path)
|
||||
return r.content
|
||||
last = f"HTTP {r.status_code}"
|
||||
if r.status_code in (400, 404):
|
||||
break
|
||||
except Exception as e:
|
||||
last = repr(e)
|
||||
time.sleep(min(20, 1.0 * 2 ** attempt))
|
||||
raise RuntimeError(f"failed {url}: {last}")
|
||||
|
||||
|
||||
def is_placeholder(data):
|
||||
if hashlib.md5(data).hexdigest() in PLACEHOLDER_MD5:
|
||||
return True
|
||||
a = np.asarray(Image.open(io.BytesIO(data)).convert("RGB")).astype(np.float32)
|
||||
m = a.mean(axis=(0, 1))
|
||||
return bool(abs(m - 204.7).max() < 4 and a.std() < 9)
|
||||
|
||||
|
||||
def pixel_xy(lat, lon, z):
|
||||
n = 2 ** z * 256
|
||||
px = (lon + 180.0) / 360.0 * n
|
||||
s = math.sin(math.radians(lat))
|
||||
py = (0.5 - math.log((1 + s) / (1 - s)) / (4 * math.pi)) * n
|
||||
return px, py
|
||||
|
||||
|
||||
def capture(lat, lon, z, size=512):
|
||||
"""Return (image, info). info has placeholder flag and quality metrics."""
|
||||
px, py = pixel_xy(lat, lon, z)
|
||||
tx, ty = int(px // 256), int(py // 256)
|
||||
mosaic = Image.new("RGB", (768, 768))
|
||||
ox, oy = (tx - 1) * 256, (ty - 1) * 256
|
||||
left, top = int(round(px - ox - size / 2)), int(round(py - oy - size / 2))
|
||||
ph = False
|
||||
for j in range(3):
|
||||
for i in range(3):
|
||||
data = fetch_tile(z, tx - 1 + i, ty - 1 + j)
|
||||
# does this tile overlap the crop window?
|
||||
tl, tt = i * 256, j * 256
|
||||
overlaps = tl < left + size and tl + 256 > left and tt < top + size and tt + 256 > top
|
||||
if overlaps and is_placeholder(data):
|
||||
ph = True
|
||||
mosaic.paste(Image.open(io.BytesIO(data)).convert("RGB"), (tl, tt))
|
||||
img = mosaic.crop((left, top, left + size, top + size))
|
||||
info = metrics(img)
|
||||
info["placeholder"] = ph
|
||||
return img, info
|
||||
|
||||
|
||||
def metrics(img):
|
||||
a = np.asarray(img).astype(np.float32)
|
||||
lum = a @ np.array([0.299, 0.587, 0.114], np.float32)
|
||||
small = np.asarray(img.convert("L").resize((128, 128), Image.BILINEAR)).astype(np.float32)
|
||||
gx = np.abs(np.diff(lum, axis=1)).mean()
|
||||
gy = np.abs(np.diff(lum, axis=0)).mean()
|
||||
# coarse-scale structure: std of 32px block means (catches flat ocean/cloud/ice)
|
||||
blocks = lum.reshape(16, 32, 16, 32).mean(axis=(1, 3))
|
||||
sat = (a.max(axis=2) - a.min(axis=2)).mean()
|
||||
return {
|
||||
"std": float(lum.std()),
|
||||
"grad": float((gx + gy) / 2),
|
||||
"block_std": float(blocks.std()),
|
||||
"small_std": float(small.std()),
|
||||
"sat": float(sat),
|
||||
"mean": float(lum.mean()),
|
||||
}
|
||||
|
||||
|
||||
def quality_ok(info):
|
||||
if info["placeholder"]:
|
||||
return False, "placeholder"
|
||||
if info["std"] < 12:
|
||||
return False, f"low variance (std {info['std']:.1f})"
|
||||
if info["grad"] < 1.3:
|
||||
return False, f"featureless (grad {info['grad']:.1f})"
|
||||
if info["mean"] > 225 and info["std"] < 25:
|
||||
return False, "cloud/snow white-out"
|
||||
if info["mean"] < 22:
|
||||
return False, "too dark"
|
||||
return True, ""
|
||||
@@ -0,0 +1,82 @@
|
||||
"""MBTA static GTFS -> city/boston/transit.json (subway, Mattapan, commuter rail, bus).
|
||||
usage: python3 build_transit.py <dir with routes.txt shapes.txt trips.txt>"""
|
||||
import csv, json, math, os, sys
|
||||
from collections import defaultdict
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import SCENES, enu
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
G = sys.argv[1]
|
||||
H = SCENES['boston'][2]; M = H + 1000
|
||||
|
||||
def rdp(pts, eps):
|
||||
if len(pts) < 3: return pts
|
||||
keep = [False] * len(pts); keep[0] = keep[-1] = True
|
||||
stack = [(0, len(pts) - 1)]
|
||||
while stack:
|
||||
a, b = stack.pop()
|
||||
ax, ay = pts[a]; bx, by = pts[b]; dx, dy = bx - ax, by - ay; L = math.hypot(dx, dy)
|
||||
best, bi = -1, -1
|
||||
for i in range(a + 1, b):
|
||||
px, py = pts[i]
|
||||
d = abs(dy * (px - ax) - dx * (py - ay)) / L if L else math.hypot(px - ax, py - ay)
|
||||
if d > best: best, bi = d, i
|
||||
if best > eps:
|
||||
keep[bi] = True; stack += [(a, bi), (bi, b)]
|
||||
return [p for p, k in zip(pts, keep) if k]
|
||||
|
||||
def length(p): return sum(math.hypot(p[i+1][0]-p[i][0], p[i+1][1]-p[i][1]) for i in range(len(p)-1))
|
||||
|
||||
routes = {}
|
||||
for r in csv.DictReader(open(os.path.join(G, 'routes.txt'), encoding='utf-8-sig')):
|
||||
t = int(r['route_type'])
|
||||
if t not in (0, 1, 2, 3): continue
|
||||
if 'Replacement' in r['route_desc'] or r['route_id'].startswith('Shuttle'): continue
|
||||
name = r['route_short_name'] or r['route_long_name']
|
||||
if t == 3 and r['route_short_name'] and r['route_long_name']:
|
||||
name = f"{r['route_short_name']} {r['route_long_name']}"
|
||||
routes[r['route_id']] = {'name': name, 'color': '#' + (r['route_color'] or '888888').upper(), 'type': t}
|
||||
|
||||
shape_rd = {}
|
||||
for t in csv.DictReader(open(os.path.join(G, 'trips.txt'), encoding='utf-8-sig')):
|
||||
if t['route_id'] in routes and t['shape_id']:
|
||||
shape_rd.setdefault(t['shape_id'], (t['route_id'], t['direction_id']))
|
||||
|
||||
shapes = defaultdict(list)
|
||||
for s in csv.DictReader(open(os.path.join(G, 'shapes.txt'), encoding='utf-8-sig')):
|
||||
if s['shape_id'] in shape_rd:
|
||||
shapes[s['shape_id']].append((int(s['shape_pt_sequence']), float(s['shape_pt_lat']), float(s['shape_pt_lon'])))
|
||||
|
||||
best = {} # (route, dir) -> (len_in_square, segments)
|
||||
for sid, pts in shapes.items():
|
||||
pts.sort()
|
||||
xy = [enu('boston', la, lo) for _, la, lo in pts]
|
||||
if not any(abs(x) <= H and abs(y) <= H for x, y in xy): continue # must pass within the square
|
||||
segs, cur = [], []
|
||||
for i, (x, y) in enumerate(xy):
|
||||
if abs(x) <= M and abs(y) <= M:
|
||||
if not cur and i > 0: cur.append(xy[i-1]) # carry edge point
|
||||
cur.append((x, y))
|
||||
elif cur:
|
||||
cur.append((x, y)); segs.append(cur); cur = []
|
||||
if cur: segs.append(cur)
|
||||
segs = [rdp(s, 2.0) for s in segs if len(s) > 1]
|
||||
L = sum(length(s) for s in segs)
|
||||
key = shape_rd[sid]
|
||||
if key not in best or L > best[key][0]: best[key] = (L, segs)
|
||||
|
||||
out_shapes, seen = [], set()
|
||||
for (rid, d), (L, segs) in sorted(best.items()):
|
||||
for s in segs:
|
||||
flat = tuple(round(v, 1) for p in s for v in p)
|
||||
if (rid, flat) in seen: continue
|
||||
seen.add((rid, flat))
|
||||
out_shapes.append([rid] + list(flat))
|
||||
used = {s[0] for s in out_shapes}
|
||||
out = {'routes': {k: v for k, v in routes.items() if k in used}, 'shapes': out_shapes,
|
||||
'source': 'MBTA GTFS ' + open(os.path.join(G, 'feed_info.txt')).read().splitlines()[1].split(',"')[1].split('"')[0]}
|
||||
json.dump(out, open(os.path.join(ROOT, 'city', 'boston', 'transit.json'), 'w'), separators=(',', ':'))
|
||||
bytype = defaultdict(int)
|
||||
for k in used: bytype[routes[k]['type']] += 1
|
||||
print('routes', len(used), dict(bytype), 'shapes', len(out_shapes), 'pts', sum((len(s)-1)//2 for s in out_shapes))
|
||||
print('rail routes:', sorted(k for k in used if routes[k]['type'] != 3))
|
||||
print('missing requested:', [k for k in ['Red','Orange','Blue','Green-B','Green-C','Green-D','Green-E','Mattapan'] if k not in used])
|
||||
@@ -0,0 +1,15 @@
|
||||
import math
|
||||
SCENES = {
|
||||
'nyc': (40.7400, -73.9900, 3500),
|
||||
'boston': (42.3555, -71.0605, 3200),
|
||||
'lax': (33.9425, -118.4081, 3500),
|
||||
'bragg': (35.1580, -79.0090, 6000),
|
||||
}
|
||||
def enu(slug, lat, lon):
|
||||
lat0, lon0, _ = SCENES[slug]
|
||||
x = (lon - lon0) * math.cos(math.radians(lat0)) * 111320
|
||||
y = (lat - lat0) * 110540
|
||||
return round(x, 1), round(y, 1)
|
||||
def inside(slug, x, y, margin=0.0):
|
||||
h = SCENES[slug][2] + margin
|
||||
return abs(x) <= h and abs(y) <= h
|
||||
@@ -0,0 +1,76 @@
|
||||
"""YOLOv8n (ONNX, CPU) over cctv/cam_*.jpg and cctv/nyc/*.jpg -> cctv/detections.json.
|
||||
Model auto-downloads to tools/live/models/yolov8n.onnx (git-ignored)."""
|
||||
import glob, json, os, sys, time, requests
|
||||
import numpy as np, onnxruntime as ort
|
||||
from PIL import Image, ImageDraw
|
||||
from collections import Counter
|
||||
HERE = os.path.dirname(os.path.abspath(__file__)); ROOT = os.path.abspath(os.path.join(HERE, '..', '..'))
|
||||
MODEL = os.path.join(HERE, 'models', 'yolov8n.onnx')
|
||||
MODEL_URL = 'https://huggingface.co/Kalray/yolov8/resolve/main/yolov8n.onnx'
|
||||
KEEP = {0: 'person', 1: 'bicycle', 2: 'car', 3: 'motorcycle', 5: 'bus', 7: 'truck', 9: 'traffic light', 11: 'stop sign'}
|
||||
CONF, IOU, S = 0.16, 0.45, 640
|
||||
if not os.path.exists(MODEL):
|
||||
os.makedirs(os.path.dirname(MODEL), exist_ok=True)
|
||||
open(MODEL, 'wb').write(requests.get(MODEL_URL, timeout=120).content)
|
||||
sess = ort.InferenceSession(MODEL, providers=['CPUExecutionProvider'])
|
||||
inp = sess.get_inputs()[0].name
|
||||
|
||||
def nms(b, s, thr):
|
||||
order = s.argsort()[::-1]; keep = []
|
||||
while order.size:
|
||||
i = order[0]; keep.append(i)
|
||||
xx0 = np.maximum(b[i, 0], b[order[1:], 0]); yy0 = np.maximum(b[i, 1], b[order[1:], 1])
|
||||
xx1 = np.minimum(b[i, 2], b[order[1:], 2]); yy1 = np.minimum(b[i, 3], b[order[1:], 3])
|
||||
inter = np.clip(xx1 - xx0, 0, None) * np.clip(yy1 - yy0, 0, None)
|
||||
a = (b[:, 2] - b[:, 0]) * (b[:, 3] - b[:, 1])
|
||||
iou = inter / (a[i] + a[order[1:]] - inter + 1e-9)
|
||||
order = order[1:][iou <= thr]
|
||||
return keep
|
||||
|
||||
def detect(path):
|
||||
im = Image.open(path).convert('RGB'); W, H = im.size
|
||||
r = min(S / W, S / H); nw, nh = round(W * r), round(H * r); px, py = (S - nw) // 2, (S - nh) // 2
|
||||
canvas = Image.new('RGB', (S, S), (114, 114, 114)); canvas.paste(im.resize((nw, nh), Image.BILINEAR), (px, py))
|
||||
x = np.asarray(canvas, dtype=np.float32).transpose(2, 0, 1)[None] / 255.0
|
||||
p = sess.run(None, {inp: x})[0][0].T # 8400 x 84
|
||||
sc = p[:, 4:]; cls = sc.argmax(1); conf = sc.max(1)
|
||||
m = (conf >= CONF) & np.isin(cls, list(KEEP))
|
||||
p, cls, conf = p[m], cls[m], conf[m]
|
||||
if not len(p): return []
|
||||
b = np.stack([p[:, 0] - p[:, 2] / 2, p[:, 1] - p[:, 3] / 2, p[:, 0] + p[:, 2] / 2, p[:, 1] + p[:, 3] / 2], 1)
|
||||
out = []
|
||||
for c in np.unique(cls): # class-wise NMS
|
||||
idx = np.where(cls == c)[0]
|
||||
for k in nms(b[idx], conf[idx], IOU):
|
||||
j = idx[k]; x0, y0, x1, y1 = b[j]
|
||||
x0 = min(max((x0 - px) / r / W, 0), 1); x1 = min(max((x1 - px) / r / W, 0), 1)
|
||||
y0 = min(max((y0 - py) / r / H, 0), 1); y1 = min(max((y1 - py) / r / H, 0), 1)
|
||||
if x1 - x0 > 0 and y1 - y0 > 0:
|
||||
out.append([round(float(x0), 4), round(float(y0), 4), round(float(x1), 4), round(float(y1), 4), round(float(conf[j]), 3), KEEP[int(c)]])
|
||||
return sorted(out, key=lambda d: -d[4])
|
||||
|
||||
COL = {'person': (255, 60, 60), 'car': (60, 200, 255), 'truck': (255, 180, 0), 'bus': (255, 0, 255), 'bicycle': (0, 255, 120),
|
||||
'motorcycle': (180, 255, 0), 'traffic light': (255, 255, 255), 'stop sign': (255, 0, 0)}
|
||||
def render(path, dets, dst):
|
||||
im = Image.open(path).convert('RGB'); W, H = im.size; d = ImageDraw.Draw(im)
|
||||
for x0, y0, x1, y1, c, n in dets:
|
||||
d.rectangle([x0 * W, y0 * H, x1 * W, y1 * H], outline=COL[n], width=2)
|
||||
d.text((x0 * W + 2, max(0, y0 * H - 11)), f'{n} {c:.2f}', fill=COL[n])
|
||||
im.save(dst, quality=90)
|
||||
|
||||
if __name__ == '__main__':
|
||||
files = sorted(glob.glob(os.path.join(ROOT, 'cctv', 'cam_*.jpg'))) + sorted(glob.glob(os.path.join(ROOT, 'cctv', 'nyc', '*.jpg')))
|
||||
res, tot, t0 = {}, Counter(), time.time()
|
||||
for f in files:
|
||||
rel = os.path.relpath(f, os.path.join(ROOT, 'cctv'))
|
||||
res[rel] = detect(f); tot.update(d[5] for d in res[rel])
|
||||
dt = time.time() - t0
|
||||
json.dump(res, open(os.path.join(ROOT, 'cctv', 'detections.json'), 'w'), separators=(',', ':'))
|
||||
print('images', len(files), 'secs', round(dt, 1), 'ms/img', round(1000 * dt / max(1, len(files))))
|
||||
print('totals', dict(tot.most_common()), 'all', sum(tot.values()))
|
||||
print('nyc totals', dict(Counter(d[5] for k, v in res.items() if k.startswith('nyc/') for d in v)))
|
||||
picks = sys.argv[1:] or [max((k for k in res if k.startswith('nyc/')), key=lambda k: len(res[k])),
|
||||
max((k for k in res if not k.startswith('nyc/')), key=lambda k: len(res[k])),
|
||||
max(res, key=lambda k: sum(d[5] == 'person' for d in res[k]))]
|
||||
for i, k in enumerate(picks, 1):
|
||||
render(os.path.join(ROOT, 'cctv', k), res[k], os.path.join(HERE, f'det_check_{i}.jpg')); print('render', i, k, len(res[k]))
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Poll live ADS-B (adsb.lol, fallback adsb.fi) around each scene 3x, 20 s apart.
|
||||
Writes city/<slug>/air.json with latest sample per aircraft + short track history."""
|
||||
import json, os, sys, time, requests
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import SCENES, enu
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
RAD = {'nyc': 25, 'lax': 25, 'bragg': 25, 'boston': 20}
|
||||
HELI_TYPES = {'EC35','EC30','EC45','EC55','EC20','EC25','EC75','EC13','EC40','AS50','AS55','AS65','AS32','AS3B','H125','H130','H135','H145','H160','H175','B06','B06T','B407','B412','B427','B429','B430','B47G','B505','B212','B222','B230','B105','BK17','R22','R44','R66','S76','S92','S61','S64','S70','H60','UH60','MH60','HH60','H47','CH47','H53','A109','A119','A139','A169','A189','AW09','MI8','MI17','UH1','UH1Y','AH1','AH64','H64','V22','MV22','CABR','G2CA','EN28','EN48','H269','H500','MD52','MD60','NH90','LYNX','PUMA','GAZL','EH10','H1','B505'}
|
||||
H = {'User-Agent': 'gods-eye-view-intro/1.0 (film research)'}
|
||||
def fetch(slug):
|
||||
lat, lon, _ = SCENES[slug]; nm = RAD[slug]
|
||||
for url, key in ((f'https://api.adsb.lol/v2/point/{lat}/{lon}/{nm}', 'ac'),
|
||||
(f'https://opendata.adsb.fi/api/v2/lat/{lat}/lon/{lon}/dist/{nm}', 'aircraft')):
|
||||
try:
|
||||
r = requests.get(url, headers=H, timeout=20); r.raise_for_status(); j = r.json()
|
||||
ac = j.get('ac') or j.get('aircraft') or []
|
||||
now = j.get('now') or time.time() * 1000
|
||||
return url, now / 1000 if now > 1e11 else now, ac
|
||||
except Exception as e:
|
||||
print(slug, 'fail', url, e, file=sys.stderr)
|
||||
return None, time.time(), []
|
||||
def num(v):
|
||||
try: return float(v)
|
||||
except Exception: return None
|
||||
def conv(slug, a):
|
||||
lat, lon = num(a.get('lat')), num(a.get('lon'))
|
||||
if lat is None or lon is None:
|
||||
lp = a.get('lastPosition') or {}
|
||||
lat, lon = num(lp.get('lat')), num(lp.get('lon'))
|
||||
if lat is None: return None
|
||||
x, y = enu(slug, lat, lon)
|
||||
ab = a.get('alt_baro')
|
||||
if ab == 'ground': alt = 0.0
|
||||
else:
|
||||
v = num(ab)
|
||||
if v is None: v = num(a.get('alt_geom'))
|
||||
alt = round(v * 0.3048, 1) if v is not None else None
|
||||
trk = num(a.get('track')); trk = num(a.get('true_heading')) if trk is None else trk
|
||||
gs = num(a.get('gs')); gs = round(gs * 0.514444, 1) if gs is not None else None
|
||||
vr = num(a.get('baro_rate')); vr = num(a.get('geom_rate')) if vr is None else vr
|
||||
vr = round(vr * 0.00508, 2) if vr is not None else None
|
||||
return [x, y, alt, trk, gs, vr, (a.get('flight') or '').strip(), a.get('t') or '', a.get('category') or '']
|
||||
def main():
|
||||
polls = {s: [] for s in SCENES}
|
||||
for i in range(3):
|
||||
t0 = time.time()
|
||||
for s in SCENES:
|
||||
url, t, ac = fetch(s); polls[s].append((url, t, ac)); print(i, s, len(ac), url, flush=True)
|
||||
if i < 2: time.sleep(max(0, 20 - (time.time() - t0)))
|
||||
summary = {}
|
||||
for s in SCENES:
|
||||
latest, tracks, src = {}, {}, set()
|
||||
tlast = 0
|
||||
for url, t, ac in polls[s]:
|
||||
if url: src.add(url.split('/')[2])
|
||||
tlast = max(tlast, t)
|
||||
for a in ac:
|
||||
hx = a.get('hex'); row = conv(s, a)
|
||||
if not hx or row is None: continue
|
||||
if (num(a.get('seen_pos')) or 0) > 60: continue
|
||||
latest[hx] = row
|
||||
tr = tracks.setdefault(hx, [])
|
||||
p = [row[0], row[1], row[2]]
|
||||
if not tr or tr[-1][:2] != p[:2]: tr.append(p)
|
||||
heli = [h for h, r in latest.items() if r[8] == 'A7' or r[7] in HELI_TYPES]
|
||||
out = {'t': int(tlast), 'source': sorted(src), 'radius_nm': RAD[s], 'hex': list(latest.keys()),
|
||||
'a': list(latest.values()), 'tracks': {h: v for h, v in tracks.items() if len(v) > 1}}
|
||||
json.dump(out, open(os.path.join(ROOT, 'city', s, 'air.json'), 'w'), separators=(',', ':'))
|
||||
summary[s] = {'aircraft': len(latest), 'with_track': len(out['tracks']), 'heli': len(heli),
|
||||
'heli_list': [[latest[h][6], latest[h][7], latest[h][8], latest[h][2]] for h in heli]}
|
||||
print(json.dumps(summary, indent=1))
|
||||
if __name__ == '__main__': main()
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Live MBTA vehicle positions -> city/boston/vehicles.json (square + 1 km)."""
|
||||
import json, os, sys, requests
|
||||
from collections import Counter
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import enu, inside
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
j = requests.get('https://cdn.mbta.com/realtime/VehiclePositions_enhanced.json', timeout=30).json()
|
||||
rows, total = [], 0
|
||||
for e in j['entity']:
|
||||
v = e.get('vehicle') or {}; p = v.get('position') or {}
|
||||
if 'latitude' not in p: continue
|
||||
total += 1
|
||||
x, y = enu('boston', p['latitude'], p['longitude'])
|
||||
if not inside('boston', x, y, 1000): continue
|
||||
rid = (v.get('trip') or {}).get('route_id', '')
|
||||
lab = (v.get('vehicle') or {}).get('label') or (v.get('vehicle') or {}).get('id', '')
|
||||
sp = p.get('speed'); rows.append([x, y, p.get('bearing'), round(sp, 2) if sp is not None else None, rid, lab])
|
||||
out = {'t': int(j['header']['timestamp']), 'source': 'cdn.mbta.com VehiclePositions_enhanced.json', 'v': rows}
|
||||
json.dump(out, open(os.path.join(ROOT, 'city', 'boston', 'vehicles.json'), 'w'), separators=(',', ':'))
|
||||
c = Counter(r[4].split('-')[0] if r[4].startswith(('Green', 'CR')) else ('bus' if r[4][:1].isdigit() or r[4][:2] in ('SL','CT') else r[4]) for r in rows)
|
||||
print('t', out['t'], 'feed total', total, 'in square+1km', len(rows), dict(c))
|
||||
@@ -0,0 +1,53 @@
|
||||
"""NYC DOT TMC cameras -> city/nyc/cameras.json + current stills for ~24 nearest cams."""
|
||||
import hashlib, io, json, os, sys, time, requests
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps
|
||||
from collections import Counter
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import enu, inside
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
OUT = os.path.join(ROOT, 'cctv', 'nyc'); DBG = os.path.join(os.path.dirname(__file__), 'nyc_rejects')
|
||||
os.makedirs(OUT, exist_ok=True); os.makedirs(DBG, exist_ok=True)
|
||||
H = {'User-Agent': 'gods-eye-view-intro/1.0'}
|
||||
cat = requests.get('https://webcams.nyctmc.org/api/cameras', headers=H, timeout=30).json()
|
||||
cams = []
|
||||
for c in cat:
|
||||
if str(c.get('isOnline')).lower() != 'true': continue
|
||||
try: lat, lon = float(c['latitude']), float(c['longitude'])
|
||||
except Exception: continue
|
||||
x, y = enu('nyc', lat, lon)
|
||||
if inside('nyc', x, y): cams.append((x, y, c['id'], c['name'].strip(), lat, lon))
|
||||
json.dump([[x, y, i, n] for x, y, i, n, _, _ in cams], open(os.path.join(ROOT, 'city', 'nyc', 'cameras.json'), 'w'), separators=(',', ':'))
|
||||
print('catalog', len(cat), 'online in square', len(cams))
|
||||
cams.sort(key=lambda c: c[0]**2 + c[1]**2)
|
||||
for f in os.listdir(OUT):
|
||||
if f.startswith('cam_') and f.endswith('.jpg'): os.remove(os.path.join(OUT, f))
|
||||
idx, rejects, hashes, raw_imgs = [], Counter(), Counter(), []
|
||||
fetched_utc = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
for x, y, cid, name, lat, lon in cams[:80]:
|
||||
if len(raw_imgs) >= 40: break
|
||||
try:
|
||||
r = requests.get(f'https://webcams.nyctmc.org/api/cameras/{cid}/image', headers=H, timeout=20)
|
||||
r.raise_for_status(); im = Image.open(io.BytesIO(r.content)).convert('RGB')
|
||||
except Exception as e:
|
||||
rejects['error'] += 1; continue
|
||||
h = hashlib.md5(r.content).hexdigest(); hashes[h] += 1
|
||||
g = np.asarray(im.convert('L'), dtype=np.float32)
|
||||
raw_imgs.append(dict(x=x, y=y, id=cid, name=name, lat=lat, lon=lon, im=im, h=h, mean=float(g.mean()), std=float(g.std()), size=im.size, n=len(r.content)))
|
||||
n = 0
|
||||
for d in raw_imgs:
|
||||
reason = None
|
||||
if hashes[d['h']] > 1: reason = 'duplicate/placeholder'
|
||||
elif d['mean'] < 14: reason = 'black'
|
||||
elif d['std'] < 10: reason = 'flat'
|
||||
elif d['size'][0] < 200: reason = 'tiny'
|
||||
if reason:
|
||||
rejects[reason] += 1; d['im'].save(os.path.join(DBG, f"{reason.split('/')[0]}_{d['id'][:8]}.jpg")); continue
|
||||
if n >= 24: break
|
||||
n += 1; fn = f'cam_{n:03d}.jpg'
|
||||
ImageOps.fit(d['im'], (640, 360), Image.LANCZOS).save(os.path.join(OUT, fn), quality=88)
|
||||
idx.append({'file': fn, 'id': d['id'], 'name': d['name'], 'x': d['x'], 'y': d['y'], 'lat': d['lat'], 'lon': d['lon'],
|
||||
'orig_size': list(d['size']), 'fetched_utc': fetched_utc, 'source': 'NYC DOT TMC webcams.nyctmc.org'})
|
||||
print(fn, d['name'], d['size'], round(d['mean']), round(d['std']), round((d['x']**2+d['y']**2)**.5))
|
||||
json.dump(idx, open(os.path.join(OUT, 'index.json'), 'w'), indent=1)
|
||||
print('saved', n, 'rejects', dict(rejects), 'tried', len(raw_imgs))
|
||||
@@ -3,12 +3,13 @@
|
||||
# tools/render-all.sh 1920 1080 16x9
|
||||
set -u
|
||||
W=$1; H=$2; TAG=$3
|
||||
CH=240; TOTAL=2160
|
||||
mkdir -p out/chunks_$TAG
|
||||
CH=240; TOTAL=${4:-2850}
|
||||
mkdir -p out/${PREFIX:-}chunks_$TAG
|
||||
for ((s=0; s<TOTAL; s+=CH)); do
|
||||
e=$((s+CH)); f=out/chunks_$TAG/c_$(printf %05d $s).mp4
|
||||
e=$((s+CH)); f=out/${PREFIX:-}chunks_$TAG/c_$(printf %05d $s).mp4
|
||||
[ -s "$f" ] && continue
|
||||
node tools/render.mjs --w $W --h $H --from $s --to $e --video "$f.tmp.mp4" --crf 14 --preset slow && mv "$f.tmp.mp4" "$f"
|
||||
e=$(( e > TOTAL ? TOTAL : e ))
|
||||
node tools/render.mjs --port ${PORT:-8766} --w $W --h $H --from $s --to $e --video "$f.tmp.mp4" --crf 14 --preset slow && mv "$f.tmp.mp4" "$f"
|
||||
done
|
||||
ls out/chunks_$TAG/c_*.mp4 | sed "s|^|file '$PWD/|; s|$|'|" > out/chunks_$TAG/list.txt
|
||||
ffmpeg -y -loglevel error -f concat -safe 0 -i out/chunks_$TAG/list.txt -c copy out/video_$TAG.mp4 && echo "CONCAT OK $TAG"
|
||||
ls out/${PREFIX:-}chunks_$TAG/c_*.mp4 | sed "s|^|file '$PWD/|; s|$|'|" > out/${PREFIX:-}chunks_$TAG/list.txt
|
||||
ffmpeg -y -loglevel error -f concat -safe 0 -i out/${PREFIX:-}chunks_$TAG/list.txt -c copy out/${PREFIX:-}video_$TAG.mp4 && echo "CONCAT OK $TAG"
|
||||
Reference in new issue
Block a user