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:
2026-08-03 21:15:25 -04:00
parent 1d23c187ad
commit c297b20aed
34 changed files with 2206 additions and 46 deletions
+64
View File
@@ -0,0 +1,64 @@
# PA Ramp Build Spec — reconciled
Written 2026-08-03 after research + measurement. Supersedes ad-hoc azimuth
tuning in `MapBuilder.luau`.
## Source conflict — READ THIS FIRST
Two sources disagree, and the disagreement matters:
| | Generic standard (AASHTO/FHWA/JP, via research) | Measured Daikoku (OSM circle fits) |
|---|---|---|
| Ramp form | short shallow **parallel slip road** | **loop helix**, 150–220° of turn |
| Radius | 80–120 m (285–430 studs) | 71–204 m, mean 131 m (**469 studs**) |
| Arc | 30–45° | 217° (main helix, rms 1.2 = truly circular) |
**Resolution: measured Daikoku wins for THIS build.** The generic spec describes
a typical roadside PA on a flat ground-level expressway. Daikoku PA is not that
— it sits at the bottom of a **five-level junction stack** on reclaimed land, so
its ramps are genuinely helical loops, not slip roads. The satellite plan view
(`aerial/daikoku_z19.jpg`) shows one large-radius arc sweeping around the lot.
The generic spec is still authoritative for the **merge detail** (below), which
is not Daikoku-specific.
## Numbers to build to
| Quantity | Value | Source |
|---|---|---|
| Scale | 3.571 studs/m (1 stud = 0.28 m) | project |
| Main entry helix radius | **525 studs** (147 m) | OSM way 32403901, rms 1.2 |
| Secondary helix radius | **440 studs** (124 m) | OSM way 779045459, rms 0.7 |
| Tight connector radius | 255 studs (71 m) | OSM way 32355896 |
| Turn direction | **clockwise (right) — BOTH ramps** | 6 of 8 OSM ways; video 「右、右」 |
| Divergence at merge | 2–5° (current build: 0.1–0.2°, tangent) | AASHTO/FHWA |
| Deceleration lane | 150–200 m before gore | AASHTO |
| Acceleration lane | 200–300 m after gore | AASHTO |
| Gore apex angle | < 5°, chevron-painted over 30–50 m | AASHTO |
| Superelevation | 4–8% bank on curves | AASHTO |
| Deck-to-deck spacing | 36–43 studs/level (10–12 m) | JP standard |
## Hard constraints (checkable in a probe)
1. **Entry and exit ramps must NEVER intersect.** Current build fails this:
2547 point-pairs within 30 studs planar at < 12 studs dY, worst case
**0.5 studs apart at dY = 0.0**. This is the dominant visual defect —
it reads as a bowtie tangle. Probe must assert 0 such pairs.
2. Both ramps curve **the same direction** (clockwise). They are at opposite
ends of the lot and wrap the same way; they do not mirror into each other.
3. Ramp arc wraps **outside** the parking apron, forming its boundary — it does
not cut across the lot interior.
4. Merge is tangent (already achieved: 0.1–0.2°).
5. No ramp part intersects any Workspace-level building model (obstacle scope
must be INVERTED, not a name whitelist — see verification skill).
## Edge / structure treatment (from onboard video + Commons photos)
- Edge = **low concrete jersey base + see-through chain-link mesh fence above**.
NOT a tall solid parapet. Current build's heavy kerb chain is why the ramps
read as thick white rails.
- **Red/orange chevron shoulder paint** through every ramp curve — present in
all five reference videos, day and night. Cheapest high-value detail.
- Piers: **large cylindrical columns**, widely spaced, under a **trapezoidal
box-girder** deck underside. White/pale concrete.
- White zebra hatching fills the gore wedge.
Binary file not shown.

After

Width:  |  Height:  |  Size: 852 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+38
View File
@@ -0,0 +1,38 @@
# PA Ramp Reference Images
Downloaded 2026-08-03. All files verified present on disk and integrity-checked
with PIL (12/12 valid, 0 corrupt).
## Wikimedia Commons (fetched by research agent)
| File | Px | Shows | License |
|---|---|---|---|
| `Daikoku-PA.JPG` | 2487x1797 | Daikoku PA overview | CC BY 3.0 |
| `Daikoku-JCT.JPG` | 2560x1920 | Daikoku JCT elevated stack | CC BY 3.0 |
| `DaikokuJCT-Bnishi.JPG` | 640x480 | Daikoku JCT bayside west | CC BY-SA 3.0 |
| `Shutoko_daikokuPA.jpg` | 1280x960 | Daikoku PA elevated structure | CC BY-SA 3.0 |
| `Shutoko_oiPA_east.jpg` | 1280x960 | Oi PA east | CC BY-SA 3.0 |
| `Shutoko_oiPA_west.jpg` | 1280x960 | Oi PA west (ground level, facility bldg) | CC BY-SA 3.0 |
| `Shutoko_heiwajimaPA_up.jpg` | 1280x960 | Heiwajima PA inbound | CC BY-SA 3.0 |
| `Shutoko_heiwajimaPA_down.jpg` | 1280x960 | Heiwajima PA outbound | CC BY-SA 3.0 |
| `Shutoko_shibauraPA.jpg` | 1280x960 | Shibaura PA | CC BY-SA 3.0 |
| `Shuto_expressway_shibaura_pa.jpg` | 1000x680 | Shibaura PA / expressway | CC BY-SA 3.0 |
License note: all CC BY / CC BY-SA. Reference-only for shape study. Do NOT
trace directly into shipped assets without attribution (see
`reference-image-research` skill, license matrix).
## From project NAS archive (higher value for geometry)
| File | Px | Shows |
|---|---|---|
| `aerial/daikoku_z19.jpg` | Esri z19 | **Best plan view.** Satellite of Daikoku PA: single large-radius arc wrapping the lot |
| `aerial/daikoku_bldg_big.jpg` | | Daikoku facility building aerial |
| `daikoku_jct_sheet2.jpg` | 1728x972 | Onboard contact sheet, Daikoku JCT |
| `exit_to_baybridge_sheet2.jpg` | 1728x1296 | **Best ground view.** Off-ramp divergence, gore hatching, jersey+mesh edge, box girder |
## Measured source of truth
`docs/daikoku-reference-spec.md` — OSM Overpass geometry with Kasa circle fits
of the actual ramp ways. This is measured, not estimated, and outranks generic
standards for Daikoku-specific radii.
Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

@@ -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
```
+182
View File
@@ -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()
+91
View File
@@ -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}")
+55
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB