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>
114 lines
3.9 KiB
Python
114 lines
3.9 KiB
Python
#!/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)
|