v0.6.0: city parking-map overlay + local time tracking, no IPS API
All checks were successful
build-apk / build (push) Successful in 10m38s
All checks were successful
build-apk / build (push) Successful in 10m38s
Adds the City of Sandpoint's printed "Downtown & Waterfront Public Parking" map as a georeferenced overlay, and lets you track your time on any of its areas without ever touching the ParkSmarter/IPS API. Georeferencing (tools/citymap/) - The PDF carries no geo metadata, so the page->WebMercator affine is recovered by fitting the drawing to OSM street centrelines. - pdftocairo writes stroked street segments with per-path matrix() transforms in local coords while filled lots are absolute; both are handled. The five legend swatches share the real geometry's colours and are identified by stroke-width and position, then dropped. - 49 areas, fitted to RMS 4.1 m (X) / 3.5 m (Y). On-street segments land a mean 4.0 m from the nearest OSM road. sp-039/040 sit further out because they are angled bays along the old rail corridor, on no named road at all. - Sandpoint's grid jogs 38 m between N 2nd Ave and S 2nd Ave; the page shows the same jog at the fitted scale, which independently confirms the fit. App - Map tab: "City map" layer in the legend's colours, tappable. - "Park here" pins the car from GPS and auto-detects the containing area (40 m snap). With no fix it asks you to tap the spot instead, so the pin never depends on GPS working. - The pin lives in its own storage key, not inside the session: pinning the car without starting a timer must survive backing out of the screen. - Durations cap at the posted limit — a 2-hour space is not offered a 4-hour timer. Lots and no-limit spots get the long options. - Reuses the existing foreground-service countdown. The second notification button reads "+1 hr" for a city area rather than "Extend": there is nothing to buy, so it edits the local timer and says so. - Account -> Align city map: nudge/scale/rotate the whole overlay against a live GPS fix. Save-on-phone needs no admin token, since the person who can see the misalignment is the one standing on the street. Server - parking_areas + map_overlay tables, public read, admin replace-all. The areas come from one source document, so replacement is wholesale rather than an upsert. Dropped geometryCenter from the geo module: on the real data it returns a point in the water for the crescent City Beach lot and mid-block for L-shaped runs. Nothing used it. Tests: 8 geometry tests in app/, 5 area/overlay tests in server/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ad55559f55
commit
148e1635d3
23 changed files with 3027 additions and 21 deletions
114
tools/citymap/georef.py
Normal file
114
tools/citymap/georef.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Georeference the parking-map page coordinates against OSM.
|
||||
|
||||
The base map is a north-up Web Mercator screenshot, so page -> mercator is a
|
||||
uniform scale plus a translation (3 free params, not 6). Control points are
|
||||
street centrelines identified by name: an avenue pins X, a street pins Y.
|
||||
|
||||
Streets in Sandpoint jog between the north and south halves of the grid, so each
|
||||
control street's mercator coordinate is measured only over the span the page
|
||||
segment actually covers, not over the whole way.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
|
||||
R = 6378137.0
|
||||
|
||||
|
||||
def merc(lat, lon):
|
||||
return (math.radians(lon) * R, math.log(math.tan(math.pi / 4 + math.radians(lat) / 2)) * R)
|
||||
|
||||
|
||||
def unmerc(X, Y):
|
||||
return (math.degrees(2 * math.atan(math.exp(Y / R)) - math.pi / 2), math.degrees(X / R))
|
||||
|
||||
|
||||
osm = json.load(open("osm.json"))
|
||||
ways = {}
|
||||
for w in osm["elements"]:
|
||||
n = w.get("tags", {}).get("name")
|
||||
if not n or "geometry" not in w:
|
||||
continue
|
||||
ways.setdefault(n, []).append([merc(p["lat"], p["lon"]) for p in w["geometry"]])
|
||||
|
||||
|
||||
def centreline(name, axis, lo, hi):
|
||||
"""Mean coordinate on `axis` of `name`, over the other axis' [lo,hi] window."""
|
||||
other = 1 - axis
|
||||
vals = []
|
||||
for g in ways.get(name, []):
|
||||
for (x0, y0), (x1, y1) in zip(g, g[1:]):
|
||||
p0, p1 = (x0, y0), (x1, y1)
|
||||
if not (lo <= p0[other] <= hi or lo <= p1[other] <= hi):
|
||||
continue
|
||||
vals.append((p0[axis] + p1[axis]) / 2)
|
||||
return sum(vals) / len(vals) if vals else None
|
||||
|
||||
|
||||
# Page grid lines read off the rendered map, with the mercator window each spans.
|
||||
# X window for E-W streets / Y window for N-S avenues, in mercator metres.
|
||||
XW = (-12974700, -12974000) # 5th Ave .. 1st Ave
|
||||
YW = (6152200, 6153300) # Lake St .. Poplar St
|
||||
|
||||
AVENUES = [ # page x, OSM name
|
||||
(83.1, "North 5th Avenue"),
|
||||
(120.0, "North 4th Avenue"),
|
||||
(163.8, "North 3rd Avenue"),
|
||||
(207.7, "North 2nd Avenue"),
|
||||
(235.7, "North 1st Avenue"),
|
||||
(164.3, "South 3rd Avenue"),
|
||||
(198.4, "South 2nd Avenue"),
|
||||
]
|
||||
STREETS = [ # page y, OSM name
|
||||
(184.4, "Poplar Street"),
|
||||
(228.3, "Alder Street"),
|
||||
(272.3, "Cedar Street"),
|
||||
(314.9, "Oak Street"),
|
||||
(359.1, "Church Street"),
|
||||
(398.0, "Pine Street"),
|
||||
(438.2, "Lake Street"),
|
||||
(492.1, "Superior Street"),
|
||||
]
|
||||
|
||||
|
||||
def fit(pairs, flip):
|
||||
"""Least-squares v = s*p + t. Returns (s, t, residuals)."""
|
||||
n = len(pairs)
|
||||
sp = sum(p for p, v in pairs)
|
||||
sv = sum(v for p, v in pairs)
|
||||
spp = sum(p * p for p, v in pairs)
|
||||
spv = sum(p * v for p, v in pairs)
|
||||
s = (n * spv - sp * sv) / (n * spp - sp * sp)
|
||||
t = (sv - s * sp) / n
|
||||
return s, t, [(p, v, s * p + t - v) for p, v in pairs]
|
||||
|
||||
|
||||
ax = [(px, centreline(n, 0, *YW)) for px, n in AVENUES]
|
||||
ay = [(py, centreline(n, 1, *XW)) for py, n in STREETS]
|
||||
print("control points (mercator metres):")
|
||||
for (px, n), (_, v) in zip(AVENUES, ax):
|
||||
print(f" x {px:7.1f} {n:20s} {v if v is None else round(v,1)}")
|
||||
for (py, n), (_, v) in zip(STREETS, ay):
|
||||
print(f" y {py:7.1f} {n:20s} {v if v is None else round(v,1)}")
|
||||
|
||||
ax = [(p, v) for p, v in ax if v is not None]
|
||||
ay = [(p, v) for p, v in ay if v is not None]
|
||||
|
||||
COS = math.cos(math.radians(48.278)) # mercator metres -> ground metres here
|
||||
|
||||
|
||||
def report(label, pairs, names):
|
||||
s, t, res = fit(pairs, False)
|
||||
print(f"\n{label}: scale={s:.4f} merc-m/pt ({abs(s)*COS:.4f} ground-m/pt), offset={t:.1f}")
|
||||
for (p, v, r), nm in zip(res, names):
|
||||
print(f" {nm:20s} page={p:7.1f} residual={r*COS:7.1f} ground-m")
|
||||
rms = math.sqrt(sum(r * r for _, _, r in res) / len(res)) * COS
|
||||
print(f" RMS = {rms:.1f} ground-m")
|
||||
return s, t, rms
|
||||
|
||||
|
||||
sx, tx, rx = report("X (avenues)", ax, [n for _, n in AVENUES])
|
||||
sy, ty, ry = report("Y (streets)", ay, [n for _, n in STREETS])
|
||||
print(f"\nscale ratio |sy/sx| = {abs(sy/sx):.4f} (1.0 == truly uniform / north-up)")
|
||||
|
||||
json.dump({"sx": sx, "tx": tx, "sy": sy, "ty": ty}, open("fit_raw.json", "w"), indent=1)
|
||||
Loading…
Add table
Add a link
Reference in a new issue