Disable PA on/off ramps; restore mainline to pre-ramp state
Three iterations of the Daikoku PA ramps never read as a real
interchange. Measured why, rather than tuning further:
* the entry and exit decks INTERSECTED -- 2547 sample pairs within
30 studs horizontally at <12 studs dY, worst case 0.5 studs apart
at dY=0.0. On screen that is the bowtie tangle of white rails.
* both real Daikoku ramps turn the SAME way (clockwise; the guide
video says "right, right"). This build mirrored them into each
other.
* the real arc wraps OUTSIDE the lot and forms its north/east
boundary. This build cut across the apron interior.
Gated behind BUILD_PA_RAMPS=false rather than deleted -- the radius
was already correct (470 studs vs an OSM-measured mean of 469) and
the merge lands tangent at 0.1-0.2 deg divergence. Both worth keeping.
Also removed two leftovers that would have outlived the ramps:
* rampOpen punched holes in the mainline's outer parapet, railing
and kill wall for ramps that no longer exist -- a barrier gap with
a drop behind it. Sealed via RAMP_EDGE_OPENINGS=false. Verified:
Barrier/EdgeRail/KillWall = 1646 each = 823 samples x 2 edges,
0 road samples without a guard within 90 studs.
* gore zebra hatching painted 1024 bars + 64 boundary lines marking
a junction that is not there. Sealed via RAMP_GORE_MARKINGS=false.
Verified after a cache-busting fresh rebuild (Studio's require() cache
otherwise serves the previous map and reports a false pass):
parts 22628 -> 15988
all ramp part classes = 0
Road = 823 (unchanged), spawn pad + PASpawn intact
clip probe TRUSTWORTHY (control +11/-0), CLIPPING=0
Research and rebuild spec kept for when this is revisited:
reference/ramps/BUILD_SPEC.md, INDEX.md (10 CC-licensed Commons
photos), docs/daikoku-reference-spec.md (OSM circle fits).
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
# Daikoku Futo PA — Measured Reference Spec
|
||||
|
||||
Source-of-truth numbers for building the Daikoku PA entrance/exit ramps and
|
||||
apron in `MapBuilder.luau`. Everything here is **measured**, not estimated:
|
||||
OSM geometry via Overpass (`/tmp/osm_daikoku.py`), Esri aerial imagery, and
|
||||
frame-sampled onboard video.
|
||||
|
||||
Generated 2026-08-03.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scale
|
||||
|
||||
| Quantity | Value |
|
||||
|---|---|
|
||||
| Project scale | `M = 1/0.28` = **3.571 studs per metre** (`MapBuilder.luau:69`) |
|
||||
| Reciprocal | 1 stud = 0.28 m |
|
||||
| `Circuit.STUDS_TO_KM` | 0.28 / 1000 |
|
||||
|
||||
> NOTE: `c1_georeference.json` records `scale_studs_per_meter: 4.3` for the C1
|
||||
> loop georeference. That is a **different** mapping (real-Tokyo lat/lon → C1
|
||||
> world) with a stated `median_error_m: 366`. Do **not** use 4.3 for PA
|
||||
> geometry. PA work uses 3.571.
|
||||
|
||||
---
|
||||
|
||||
## 2. The real PA footprint (OSM)
|
||||
|
||||
Overpass, bbox 35.4545/139.6700 → 35.4705/139.6935. ENU metres about
|
||||
`35.46246657, 139.68149001` (user's Google Earth point).
|
||||
|
||||
| OSM way | Type | Extent (m) | Perimeter | Name |
|
||||
|---|---|---|---|---|
|
||||
| `405966005` | `highway=rest_area` | **203 × 227** | 646 m | 大黒PA |
|
||||
| `59608205` | `amenity=parking` | **181 × 174** | 507 m | 大黒ふ頭パーキングエリア |
|
||||
| `88702527` | `amenity=parking` | 102 × 107 | 359 m | (satellite lot, W of main) |
|
||||
|
||||
**Current build:** apron is `APRON_X = 210*M`, `APRON_Z = 200*M`
|
||||
(`MapBuilder.luau:991`) = 210 × 200 m. That sits between the parking polygon
|
||||
(181×174) and the rest_area polygon (203×227) — **already a good match.** The
|
||||
apron extent is not the problem.
|
||||
|
||||
The *shape* is the divergence: the build is an axis-aligned square; the real
|
||||
lot is a rounded wedge bounded on the N/E by the loop ramp arc.
|
||||
|
||||
---
|
||||
|
||||
## 3. Loop ramp radii — MEASURED
|
||||
|
||||
Kasa circle fit over each OSM ramp way with |cumulative turn| ≥ 120°.
|
||||
`rms` = fit residual (low = genuinely circular).
|
||||
|
||||
| OSM way | Length | Turn | **Radius** | rms | Layer | Direction |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `32355896` | 475 m | −272° | **70.9 m** | 14.4 | — | CW / right |
|
||||
| `32403901` | 601 m | −217° | **147.4 m** | **1.2** | 5 | CW / right |
|
||||
| `32592491` | 729 m | +208° | **197.4 m** | 12.7 | 2 | CCW / left |
|
||||
| `307397558` | 167 m | −178° | **27.5 m** | 9.0 | — | CW / right |
|
||||
| `779045459` | 371 m | +161° | **123.6 m** | **0.7** | 3 | CCW / left |
|
||||
| `32403902` | 656 m | −150° | **204.2 m** | 13.3 | 3 | CW / right |
|
||||
| `32592481` | 359 m | −146° | **120.4 m** | 12.0 | 1 | CW / right |
|
||||
| `32592487` | 488 m | −122° | **158.8 m** | 11.3 | 2 | CW / right |
|
||||
|
||||
**Headline numbers**
|
||||
|
||||
- Radius range **27.5 – 204 m**; mean **131 m** = **469 studs**
|
||||
- The two cleanest circular fits (rms < 1.5) are the true helixes:
|
||||
- `32403901` — **147 m** (526 studs), 217° of turn, layer 5
|
||||
- `779045459` — **124 m** (441 studs), 161° of turn, layer 3
|
||||
- **6 of 8 curve clockwise (right).** Matches the video captions exactly:
|
||||
`右、右と覚えておくとPAに着きます` — "remember right, right".
|
||||
|
||||
**Recommended build values**
|
||||
|
||||
| Ramp | Radius (m) | Radius (studs) | Turn |
|
||||
|---|---|---|---|
|
||||
| Main entry helix | 147 | **525** | ~220° CW |
|
||||
| Secondary helix | 124 | **440** | ~160° CCW |
|
||||
| Tight connector | 71 | **255** | ~270° CW |
|
||||
|
||||
Current `Circuit.PA_RADIUS = 260` studs = 72.8 m — which is almost exactly the
|
||||
tight connector (`32355896`, 70.9 m). The *main* helixes are roughly 2× that.
|
||||
|
||||
---
|
||||
|
||||
## 4. Deck layers — the vertical stack
|
||||
|
||||
OSM `layer` tags on bridge ways in the interchange:
|
||||
|
||||
| Layer | Bridge ways | Total length |
|
||||
|---|---|---|
|
||||
| 1 | 11 | 17,696 m |
|
||||
| 2 | 7 | 5,637 m |
|
||||
| 3 | 8 | 5,368 m |
|
||||
| 4 | 10 | 10,096 m |
|
||||
| 5 | 6 | 1,417 m |
|
||||
|
||||
**Five distinct deck levels.** This is the single most important structural
|
||||
fact: Daikoku JCT is a five-level stack, and the PA sits at the bottom of it.
|
||||
|
||||
Current `Circuit.PA_LIFT = 56` studs (15.7 m) of clearance over the apron —
|
||||
that is one level. A faithful build wants at least 3 visible levels overhead.
|
||||
|
||||
Typical Japanese expressway deck-to-deck spacing is ~10–12 m; at 3.571 studs/m
|
||||
that is **36–43 studs per level**.
|
||||
|
||||
---
|
||||
|
||||
## 5. Video-derived detail
|
||||
|
||||
Sources archived at
|
||||
`unraid-1:/mnt/user/data/yy-roblox-games/0-tokyo-wangan-racing/004-video-reference/`
|
||||
|
||||
| Route | Video ID | Len | Light | Value |
|
||||
|---|---|---|---|---|
|
||||
| CMT TOKYO "How to go to?" | `91suWmNr62c` | 2m50 | night | narrated turn-by-turn |
|
||||
| Bay Bridge up (ベイブリッジ〜大黒PA) | `PK4BachUlYo` | 2m36 | night | full entry sequence |
|
||||
| Yokohane down (横羽線下り) | `zlmdaR0opgE` | 5m15 | **day** | best structural read |
|
||||
| Honmoku JCT (攻略車載#18) | `fNHucFnFQ1c` | 10m07 | **day** | approach + JCT |
|
||||
| Night walkaround | `7i0-KCwMDyc` | 10m40 | night | apron-level atmosphere |
|
||||
|
||||
### Confirmed visual details
|
||||
|
||||
1. **Red/orange chevron shoulder paint** on the outer kerb through every ramp
|
||||
curve. Present in all five videos, day and night. Highest-value cheap
|
||||
detail available.
|
||||
2. **White splayed / V-shaped piers** with trapezoidal box-girder deck
|
||||
underside (clearest at `yokohane_down` t=218–242s).
|
||||
3. **Chain-link mesh fencing** along ramp edges — not solid barrier. Above a
|
||||
low concrete jersey base.
|
||||
4. **Green route shields** `大黒線 K5`; blue circular regulatory signs;
|
||||
`50` km/h limit discs on ramps.
|
||||
5. **`/P` + up-right arrow gantry** marks the parking entry (`91suWmNr62c`
|
||||
t=104s).
|
||||
6. Apron at night: warm sodium pole lighting, **white stall lines clearly
|
||||
visible**, painted directional arrows, low guardrail perimeter.
|
||||
7. Arrival reads as **descending into a canyon** — decks close in overhead.
|
||||
|
||||
---
|
||||
|
||||
## 6. Gaps / not yet measured
|
||||
|
||||
- **Deck heights above ground.** OSM `layer` gives ordering, not elevation.
|
||||
Needs either a DEM lookup or estimation from video pier counts.
|
||||
- **Third approach route.** Confirmed to exist (video states "3 ways"); two
|
||||
are captured in full, the Honmoku run covers the approach only.
|
||||
- **Exit ramp geometry.** All captured footage is *entry*. Exits not yet
|
||||
sampled.
|
||||
- **Stall grid angle.** Aerial shows a diagonal ~40–45° grid; exact bearing
|
||||
not yet fitted.
|
||||
|
||||
---
|
||||
|
||||
## 7. Reproduce
|
||||
|
||||
```bash
|
||||
python3 /tmp/osm_daikoku.py # OSM geometry + circle fits
|
||||
python3 /tmp/route.py <videoid> <name> <step_s> # video -> contact sheets
|
||||
python3 /tmp/sat.py # Esri aerial tiles
|
||||
```
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pull real Daikoku JCT / PA geometry from OpenStreetMap via Overpass.
|
||||
|
||||
Outputs measured, real-world numbers:
|
||||
- PA service-area polygon (extent, area)
|
||||
- Each ramp/link way: length, turn direction, fitted circle radius
|
||||
- Bridge levels present
|
||||
|
||||
All distances in metres. Local ENU projection about the PA centre so we can
|
||||
fit circles in a flat plane without map-projection distortion.
|
||||
"""
|
||||
import json, math, urllib.request, urllib.parse, sys
|
||||
|
||||
LAT0, LON0 = 35.46246657, 139.68149001 # user's Google Earth point
|
||||
R_EARTH = 6378137.0
|
||||
|
||||
# Generous bbox around the whole interchange
|
||||
S, W, N, E = 35.4545, 139.6700, 35.4705, 139.6935
|
||||
|
||||
Q = f"""
|
||||
[out:json][timeout:120];
|
||||
(
|
||||
way["highway"]({S},{W},{N},{E});
|
||||
way["amenity"="parking"]({S},{W},{N},{E});
|
||||
way["highway"="services"]({S},{W},{N},{E});
|
||||
way["highway"="rest_area"]({S},{W},{N},{E});
|
||||
);
|
||||
out body geom;
|
||||
"""
|
||||
|
||||
ENDPOINTS = [
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
"https://overpass.kumi.systems/api/interpreter",
|
||||
]
|
||||
|
||||
|
||||
def fetch():
|
||||
last = None
|
||||
for ep in ENDPOINTS:
|
||||
try:
|
||||
data = urllib.parse.urlencode({"data": Q}).encode()
|
||||
req = urllib.request.Request(ep, data=data,
|
||||
headers={"User-Agent": "daikoku-ref/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=150) as r:
|
||||
return json.loads(r.read().decode())
|
||||
except Exception as e:
|
||||
last = f"{ep}: {e}"
|
||||
print(f" endpoint failed {last}", file=sys.stderr)
|
||||
raise SystemExit(f"all overpass endpoints failed: {last}")
|
||||
|
||||
|
||||
def enu(lat, lon):
|
||||
"""Local east/north metres relative to (LAT0, LON0)."""
|
||||
x = math.radians(lon - LON0) * R_EARTH * math.cos(math.radians(LAT0))
|
||||
y = math.radians(lat - LAT0) * R_EARTH
|
||||
return x, y
|
||||
|
||||
|
||||
def polyline_len(pts):
|
||||
return sum(math.dist(pts[i], pts[i + 1]) for i in range(len(pts) - 1))
|
||||
|
||||
|
||||
def fit_circle(pts):
|
||||
"""Algebraic (Kasa) circle fit. Returns (cx, cy, R, rms_residual)."""
|
||||
n = len(pts)
|
||||
if n < 3:
|
||||
return None
|
||||
sx = sum(p[0] for p in pts) / n
|
||||
sy = sum(p[1] for p in pts) / n
|
||||
u = [p[0] - sx for p in pts]
|
||||
v = [p[1] - sy for p in pts]
|
||||
Suu = sum(a * a for a in u); Svv = sum(b * b for b in v)
|
||||
Suv = sum(u[i] * v[i] for i in range(n))
|
||||
Suuu = sum(a ** 3 for a in u); Svvv = sum(b ** 3 for b in v)
|
||||
Suvv = sum(u[i] * v[i] * v[i] for i in range(n))
|
||||
Svuu = sum(v[i] * u[i] * u[i] for i in range(n))
|
||||
d = 2 * (Suu * Svv - Suv * Suv)
|
||||
if abs(d) < 1e-9:
|
||||
return None
|
||||
uc = (Svv * (Suuu + Suvv) - Suv * (Svvv + Svuu)) / d
|
||||
vc = (Suu * (Svvv + Svuu) - Suv * (Suuu + Suvv)) / d
|
||||
cx, cy = uc + sx, vc + sy
|
||||
rs = [math.dist(p, (cx, cy)) for p in pts]
|
||||
R = sum(rs) / len(rs)
|
||||
rms = math.sqrt(sum((r - R) ** 2 for r in rs) / len(rs))
|
||||
return cx, cy, R, rms
|
||||
|
||||
|
||||
def total_turn(pts):
|
||||
"""Signed cumulative heading change in degrees. +ccw / -cw."""
|
||||
tot = 0.0
|
||||
for i in range(1, len(pts) - 1):
|
||||
a1 = math.atan2(pts[i][1] - pts[i - 1][1], pts[i][0] - pts[i - 1][0])
|
||||
a2 = math.atan2(pts[i + 1][1] - pts[i][1], pts[i + 1][0] - pts[i][0])
|
||||
d = math.degrees(a2 - a1)
|
||||
while d > 180: d -= 360
|
||||
while d < -180: d += 360
|
||||
tot += d
|
||||
return tot
|
||||
|
||||
|
||||
def main():
|
||||
data = fetch()
|
||||
els = data.get("elements", [])
|
||||
print(f"overpass returned {len(els)} ways\n")
|
||||
|
||||
ramps, areas = [], []
|
||||
for el in els:
|
||||
if el.get("type") != "way" or "geometry" not in el:
|
||||
continue
|
||||
t = el.get("tags", {})
|
||||
pts = [enu(g["lat"], g["lon"]) for g in el["geometry"]]
|
||||
if len(pts) < 2:
|
||||
continue
|
||||
rec = {"id": el["id"], "tags": t, "pts": pts,
|
||||
"len": polyline_len(pts)}
|
||||
hw = t.get("highway", "")
|
||||
if hw in ("services", "rest_area") or t.get("amenity") == "parking":
|
||||
areas.append(rec)
|
||||
elif hw.endswith("_link") or t.get("oneway") == "yes":
|
||||
ramps.append(rec)
|
||||
|
||||
# ---- PA area ----
|
||||
print("=" * 70)
|
||||
print("PARKING / SERVICE AREA POLYGONS")
|
||||
print("=" * 70)
|
||||
for a in sorted(areas, key=lambda r: -r["len"])[:6]:
|
||||
xs = [p[0] for p in a["pts"]]; ys = [p[1] for p in a["pts"]]
|
||||
w = max(xs) - min(xs); h = max(ys) - min(ys)
|
||||
nm = a["tags"].get("name", a["tags"].get("name:en", "-"))
|
||||
print(f" way {a['id']:>12} {a['tags'].get('highway') or a['tags'].get('amenity'):<10}"
|
||||
f" extent {w:6.0f} x {h:6.0f} m perim {a['len']:7.0f} m {nm}")
|
||||
print(f" E {min(xs):7.0f}..{max(xs):7.0f} N {min(ys):7.0f}..{max(ys):7.0f}")
|
||||
|
||||
# ---- curved ramps: the loops ----
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("CURVED RAMPS (|turn| >= 120 deg) -- the helix loops")
|
||||
print("=" * 70)
|
||||
print(f" {'way id':>12} {'len(m)':>7} {'turn':>8} {'radius(m)':>10} {'rms':>6} layer/bridge")
|
||||
loops = []
|
||||
for r in ramps:
|
||||
turn = total_turn(r["pts"])
|
||||
if abs(turn) < 120 or r["len"] < 60:
|
||||
continue
|
||||
fc = fit_circle(r["pts"])
|
||||
if not fc:
|
||||
continue
|
||||
cx, cy, R, rms = fc
|
||||
if R > 400: # not a tight loop
|
||||
continue
|
||||
loops.append((r, turn, cx, cy, R, rms))
|
||||
for r, turn, cx, cy, R, rms in sorted(loops, key=lambda z: -abs(z[1])):
|
||||
t = r["tags"]
|
||||
lb = f"layer={t.get('layer','-')} bridge={t.get('bridge','-')}"
|
||||
print(f" {r['id']:>12} {r['len']:7.0f} {turn:7.0f}\u00b0 {R:10.1f} {rms:6.1f} {lb}")
|
||||
print(f" centre E {cx:7.0f} N {cy:7.0f} "
|
||||
f"{'CCW/left' if turn>0 else 'CW/right'}")
|
||||
|
||||
if loops:
|
||||
rr = [z[4] for z in loops]
|
||||
print(f"\n loop radius: min {min(rr):.0f} m max {max(rr):.0f} m "
|
||||
f"mean {sum(rr)/len(rr):.0f} m")
|
||||
print(f" in studs (1 stud = 0.28 m): mean {sum(rr)/len(rr)/0.28:.0f} studs")
|
||||
|
||||
# ---- layers present ----
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("DECK LAYERS (bridge ways)")
|
||||
print("=" * 70)
|
||||
lay = {}
|
||||
for r in ramps:
|
||||
if r["tags"].get("bridge"):
|
||||
L = r["tags"].get("layer", "0")
|
||||
lay.setdefault(L, []).append(r["len"])
|
||||
for L in sorted(lay, key=lambda s: int(s) if s.lstrip("-").isdigit() else 0):
|
||||
v = lay[L]
|
||||
print(f" layer {L:>3}: {len(v):3d} bridge ways, total {sum(v):8.0f} m")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download a route video, sample frames every N seconds, and build labelled
|
||||
contact sheets. Built for Daikoku entrance/exit reference gathering.
|
||||
|
||||
Usage: route.py <videoid> <shortname> [step_seconds]
|
||||
"""
|
||||
import os, re, subprocess, sys, glob, json
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
YTDLP = os.path.expanduser("~/.local/bin/yt-dlp")
|
||||
ROOT = "/tmp/routes"
|
||||
|
||||
|
||||
def run(cmd, **kw):
|
||||
return subprocess.run(cmd, shell=True, capture_output=True, text=True, **kw)
|
||||
|
||||
|
||||
def meta(vid):
|
||||
r = run(f'{YTDLP} --no-warnings -J --skip-download "https://www.youtube.com/watch?v={vid}" 2>/dev/null')
|
||||
try:
|
||||
d = json.loads(r.stdout)
|
||||
except Exception:
|
||||
return {}
|
||||
return {k: d.get(k) for k in ("title", "uploader", "duration", "upload_date", "width", "height")}
|
||||
|
||||
|
||||
def fetch(vid, name):
|
||||
d = f"{ROOT}/{name}"
|
||||
os.makedirs(d, exist_ok=True)
|
||||
mp4 = f"{d}/{name}.mp4"
|
||||
if not os.path.exists(mp4):
|
||||
r = run(f'{YTDLP} -f "bv*[height<=1080][ext=mp4]/bv*[height<=1080]/b" '
|
||||
f'--no-warnings -o "{mp4}" "https://www.youtube.com/watch?v={vid}"')
|
||||
if not os.path.exists(mp4):
|
||||
print(f" DOWNLOAD FAILED: {r.stderr[-300:]}")
|
||||
return None
|
||||
return mp4
|
||||
|
||||
|
||||
def sample(mp4, d, step):
|
||||
dur = float(run(f'ffprobe -v error -show_entries format=duration '
|
||||
f'-of default=nw=1:nk=1 "{mp4}"').stdout.strip() or 0)
|
||||
for f in glob.glob(f"{d}/fr_*.jpg"):
|
||||
os.remove(f)
|
||||
ts = list(range(2, int(dur), step))
|
||||
for t in ts:
|
||||
run(f'ffmpeg -v error -ss {t} -i "{mp4}" -frames:v 1 -q:v 2 '
|
||||
f'-vf scale=960:-1 "{d}/fr_{t:04d}.jpg" -y')
|
||||
return dur, ts
|
||||
|
||||
|
||||
def sheets(d, name, cols=3, per=9):
|
||||
fs = sorted(glob.glob(f"{d}/fr_*.jpg"))
|
||||
out = []
|
||||
for page in range((len(fs) + per - 1) // per):
|
||||
sub = fs[page * per:(page + 1) * per]
|
||||
ims = [Image.open(f).convert("RGB") for f in sub]
|
||||
w, h = ims[0].size
|
||||
rows = (len(sub) + cols - 1) // cols
|
||||
s = Image.new("RGB", (w * cols, h * rows), (10, 10, 10))
|
||||
for i, im in enumerate(ims):
|
||||
s.paste(im, ((i % cols) * w, (i // cols) * h))
|
||||
dr = ImageDraw.Draw(s)
|
||||
for i, f in enumerate(sub):
|
||||
t = int(re.search(r"fr_(\d+)", f).group(1))
|
||||
x, y = (i % cols) * w, (i // cols) * h
|
||||
dr.rectangle([x + 6, y + 6, x + 190, y + 54], fill=(0, 0, 0))
|
||||
dr.text((x + 20, y + 22), f"t={t}s", fill=(255, 225, 0))
|
||||
s = s.resize((int(s.width * 0.60), int(s.height * 0.60)), Image.LANCZOS)
|
||||
o = f"{d}/{name}_sheet{page+1}.jpg"
|
||||
s.save(o, quality=90)
|
||||
out.append(o)
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
vid, name = sys.argv[1], sys.argv[2]
|
||||
step = int(sys.argv[3]) if len(sys.argv) > 3 else 8
|
||||
m = meta(vid)
|
||||
print(f"=== {name} [{vid}] ===")
|
||||
print(f" title: {m.get('title')}")
|
||||
print(f" dur : {m.get('duration')}s {m.get('width')}x{m.get('height')}")
|
||||
mp4 = fetch(vid, name)
|
||||
if not mp4:
|
||||
sys.exit(1)
|
||||
d = f"{ROOT}/{name}"
|
||||
dur, ts = sample(mp4, d, step)
|
||||
sh = sheets(d, name)
|
||||
print(f" frames: {len(ts)} @ {step}s sheets: {len(sh)}")
|
||||
for s in sh:
|
||||
print(f" {s}")
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch + stitch Esri World Imagery tiles (no API key) around a lat/lon.
|
||||
|
||||
Esri World Imagery over Japan is high-res aerial. Tile scheme is standard
|
||||
Web Mercator XYZ, same as OSM.
|
||||
"""
|
||||
import math, sys, urllib.request, io
|
||||
from PIL import Image
|
||||
|
||||
URL = "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||
UA = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) research/1.0"}
|
||||
|
||||
|
||||
def deg2tile(lat, lon, z):
|
||||
lat_r = math.radians(lat)
|
||||
n = 2.0 ** z
|
||||
xt = (lon + 180.0) / 360.0 * n
|
||||
yt = (1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n
|
||||
return xt, yt
|
||||
|
||||
|
||||
def fetch(z, x, y):
|
||||
u = URL.format(z=z, x=x, y=y)
|
||||
req = urllib.request.Request(u, headers=UA)
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return Image.open(io.BytesIO(r.read())).convert("RGB")
|
||||
|
||||
|
||||
def build(lat, lon, z, half, out):
|
||||
xt, yt = deg2tile(lat, lon, z)
|
||||
cx, cy = int(xt), int(yt)
|
||||
n = half * 2 + 1
|
||||
canvas = Image.new("RGB", (256 * n, 256 * n))
|
||||
ok = 0
|
||||
for i, x in enumerate(range(cx - half, cx + half + 1)):
|
||||
for j, y in enumerate(range(cy - half, cy + half + 1)):
|
||||
try:
|
||||
canvas.paste(fetch(z, x, y), (i * 256, j * 256))
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
print(f" tile {z}/{x}/{y} FAIL {e}", file=sys.stderr)
|
||||
canvas.save(out, quality=92)
|
||||
# metres per pixel at this latitude
|
||||
mpp = 156543.03392 * math.cos(math.radians(lat)) / (2 ** z)
|
||||
span = mpp * 256 * n
|
||||
print(f"{out} z={z} tiles={ok}/{n*n} size={canvas.size} "
|
||||
f"mpp={mpp:.3f} span={span:.0f}m")
|
||||
return canvas
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
LAT, LON = 35.46246657, 139.68149001
|
||||
build(LAT, LON, 17, 2, "/tmp/daikoku_z17.jpg") # wide: whole loop
|
||||
build(LAT, LON, 18, 2, "/tmp/daikoku_z18.jpg") # closer
|
||||
build(LAT, LON, 19, 2, "/tmp/daikoku_z19.jpg") # detail: stalls
|
||||
Reference in New Issue
Block a user