diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/make-park-match-review.py | 305 | ||||
| -rw-r--r-- | scripts/park-editor.html | 11 | ||||
| -rw-r--r-- | scripts/update-osm-parks.py | 244 |
3 files changed, 556 insertions, 4 deletions
diff --git a/scripts/make-park-match-review.py b/scripts/make-park-match-review.py new file mode 100644 index 0000000..b8a0d4f --- /dev/null +++ b/scripts/make-park-match-review.py @@ -0,0 +1,305 @@ +"""OSMの公園ポリゴンのうち、市の公園一覧と一致しなかったものをレビュー用に出力する。 + +出力: + reference/park-matching-review.csv … Excelで開ける一覧(UTF-8 BOM) + reference/park-matching-review.geojson … 未一致ポリゴン(QGIS用) + reference/kodaira-parks-points.geojson … 市の公園の位置(QGIS用) + src/data/park-name-overrides.json … OSM ID→市の公園名の手動上書き(未作成時のみ) + +CSVやQGISで確認したうえで、JSONの "city" を埋めて `npm run osm:update` を +実行すると照合結果に反映される。市の公園一覧にない施設・広場は +"status": "unlisted" にすると、候補の提示を止めて「一覧外」として記録する。 +""" + +import csv +import difflib +import json +import math +import os +import sys +import time +import unicodedata + +GEOJSON = "src/data/kodaira-parks-osm.json" +OURS = "src/data/kodaira-parks.json" +OUT_CSV = "reference/park-matching-review.csv" +OUT_REVIEW_GEOJSON = "reference/park-matching-review.geojson" +OUT_POINTS_GEOJSON = "reference/kodaira-parks-points.geojson" +OUT_OVERRIDES = "src/data/park-name-overrides.json" + +CSV_FIELDS = [ + "osm_type", + "osm_id", + "OSM名", + "OSM URL", + "緯度", + "経度", + "面積㎡", + "頂点数", + "候補1", + "距離1m", + "候補2", + "距離2m", + "候補3", + "距離3m", + "名前類似候補", + "類似度", + "類似候補距離m", + "推定", + "判定", + "メモ", +] + + +def norm(s): + s = unicodedata.normalize("NFKC", str(s)).replace(" ", "").replace(" ", "") + return s.replace("ヶ", "ケ").replace("ツ", "ッ") + + +def rings(geom): + if geom["type"] == "Polygon": + return [geom["coordinates"][0]] + if geom["type"] == "MultiPolygon": + return [poly[0] for poly in geom["coordinates"]] + return [] + + +def center(geom): + pts = [p for ring in rings(geom) for p in ring] + if not pts: + return None, None + lats = [p[1] for p in pts] + lons = [p[0] for p in pts] + return (min(lats) + max(lats)) / 2, (min(lons) + max(lons)) / 2 + + +def area_m2(geom): + total = 0.0 + for ring in rings(geom): + if len(ring) < 4: + continue + lat0 = sum(p[1] for p in ring) / len(ring) + kx = 111320 * math.cos(math.radians(lat0)) + ky = 111320 + s = 0.0 + for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]): + s += (x1 * kx) * (y2 * ky) - (x2 * kx) * (y1 * ky) + total += abs(s) / 2 + return total + + +def dist_m(lat1, lon1, lat2, lon2): + dy = (lat1 - lat2) * 111320 + dx = (lon1 - lon2) * 111320 * math.cos(math.radians((lat1 + lat2) / 2)) + return math.hypot(dx, dy) + + +def main(): + osm = json.load(open(GEOJSON, encoding="utf-8")) + ours = json.load(open(OURS, encoding="utf-8")) + parks = [] + for r in ours: + try: + lat = float(r.get("緯度") or "") + lon = float(r.get("経度") or "") + except ValueError: + continue + parks.append({"name": r["公園名"], "lat": lat, "lon": lon, "address": r.get("住所", "")}) + park_names = {q["name"] for q in parks} + + decisions = {} + if os.path.exists(OUT_OVERRIDES): + raw = json.load(open(OUT_OVERRIDES, encoding="utf-8")) + decisions = {k: v for k, v in raw.items() if isinstance(v, dict)} + + unmatched = [f for f in osm["features"] if not f["properties"].get("matched")] + rows = [] + review_features = [] + for f in unmatched: + p = f["properties"] + lat, lon = center(f["geometry"]) + if lat is None: + continue + area = area_m2(f["geometry"]) + pts = sum(len(r) for r in rings(f["geometry"])) + near = sorted( + ( + { + "name": q["name"], + "dist": dist_m(lat, lon, q["lat"], q["lon"]), + "similar": difflib.SequenceMatcher(None, norm(p.get("name", "")), norm(q["name"])).ratio(), + } + for q in parks + ), + key=lambda x: x["dist"], + )[:3] + same = sorted( + ( + { + "name": q["name"], + "dist": dist_m(lat, lon, q["lat"], q["lon"]), + "similar": difflib.SequenceMatcher(None, norm(p.get("name", "")), norm(q["name"])).ratio(), + } + for q in parks + if p.get("name") + ), + key=lambda x: -x["similar"], + ) + best_similar = same[0] if same and same[0]["similar"] >= 0.6 else None + + suggestion = "" + if near[0]["dist"] <= 30: + suggestion = near[0]["name"] + elif best_similar and best_similar["similar"] >= 0.75 and best_similar["dist"] <= 300: + suggestion = best_similar["name"] + elif best_similar and best_similar["dist"] <= 100: + suggestion = best_similar["name"] + + memo = [] + if area < 5: + memo.append("面積が極小(形状エラーの可能性)") + if near[0]["dist"] <= 30: + memo.append("最寄り公園の30m以内(同一の可能性)") + if p.get("name") and best_similar: + memo.append(f"名前が類似: {best_similar['name']} ({best_similar['similar']:.2f})") + + key = f"{p.get('osm_type', '')}/{p.get('osm_id', '')}" + decision = decisions.get(key, {}) + decided_city = str(decision.get("city", "") or "").strip() + status = str(decision.get("status", "") or "").strip() + invalid_city = bool(decided_city) and decided_city not in park_names + + if invalid_city: + memo.append(f"上書き先が一覧にありません: {decided_city}") + decided_city = "" + if decided_city: + suggestion = decided_city + memo.append("手動で対応済み") + elif status == "unlisted": + suggestion = "" + memo = [m for m in memo if not m.startswith("名前が類似")] + memo.append("一覧外(手動確認済み)") + + if decided_city: + verdict = f"対応済み: {decided_city}" + elif status == "unlisted": + verdict = "一覧外" + elif invalid_city: + verdict = "要確認: 一覧にない名前" + else: + verdict = "" + + row = { + "osm_type": p.get("osm_type", ""), + "osm_id": p.get("osm_id", ""), + "OSM名": p.get("name", ""), + "OSM URL": f"https://www.openstreetmap.org/{p.get('osm_type', '')}/{p.get('osm_id', '')}", + "緯度": f"{lat:.6f}", + "経度": f"{lon:.6f}", + "面積㎡": f"{area:.1f}", + "頂点数": pts, + "候補1": near[0]["name"] if len(near) > 0 else "", + "距離1m": f"{near[0]['dist']:.0f}" if len(near) > 0 else "", + "候補2": near[1]["name"] if len(near) > 1 else "", + "距離2m": f"{near[1]['dist']:.0f}" if len(near) > 1 else "", + "候補3": near[2]["name"] if len(near) > 2 else "", + "距離3m": f"{near[2]['dist']:.0f}" if len(near) > 2 else "", + "名前類似候補": best_similar["name"] if best_similar else "", + "類似度": f"{best_similar['similar']:.2f}" if best_similar else "", + "類似候補距離m": f"{best_similar['dist']:.0f}" if best_similar else "", + "推定": suggestion, + "判定": verdict, + "メモ": " / ".join(memo), + } + rows.append(row) + review_features.append( + { + "type": "Feature", + "properties": { + "osm": f"{row['osm_type']}/{row['osm_id']}", + "osm_name": row["OSM名"], + "area_m2": round(area, 1), + "nearest": row["候補1"], + "dist_m": row["距離1m"], + "similar": row["名前類似候補"], + "similar_ratio": row["類似度"], + "similar_dist_m": row["類似候補距離m"], + "status": status or ("mapped" if decided_city else ""), + "memo": row["メモ"], + }, + "geometry": f["geometry"], + } + ) + + rows.sort(key=lambda r: (not r["OSM名"], float(r["距離1m"] or 1e9), r["OSM名"])) + os.makedirs(os.path.dirname(OUT_CSV), exist_ok=True) + out_csv = OUT_CSV + try: + fh = open(out_csv, "w", encoding="utf-8-sig", newline="") + except PermissionError: + out_csv = OUT_CSV.replace(".csv", f"-{time.strftime('%H%M%S')}.csv") + print(f"warning: {OUT_CSV} is locked; wrote {out_csv}", file=sys.stderr) + fh = open(out_csv, "w", encoding="utf-8-sig", newline="") + with fh: + w = csv.DictWriter(fh, fieldnames=CSV_FIELDS) + w.writeheader() + w.writerows(rows) + + json.dump( + {"type": "FeatureCollection", "features": review_features}, + open(OUT_REVIEW_GEOJSON, "w", encoding="utf-8"), + ensure_ascii=False, + separators=(",", ":"), + ) + points = [] + for q in parks: + points.append( + { + "type": "Feature", + "properties": {"name": q["name"], "address": q["address"]}, + "geometry": {"type": "Point", "coordinates": [q["lon"], q["lat"]]}, + } + ) + json.dump( + {"type": "FeatureCollection", "features": points}, + open(OUT_POINTS_GEOJSON, "w", encoding="utf-8"), + ensure_ascii=False, + separators=(",", ":"), + ) + + if not os.path.exists(OUT_OVERRIDES): + data = { + f"{r['osm_type']}/{r['osm_id']}": { + "osm_name": r["OSM名"], + "suggested": r.get("推定", ""), + "status": "", + "city": "", + "candidates": [c for c in (r["候補1"], r["候補2"], r["候補3"]) if c], + } + for r in rows + } + with open(OUT_OVERRIDES, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + fh.write("\n") + print(f"overrides template -> {OUT_OVERRIDES}") + else: + print(f"overrides exists (not overwritten) -> {OUT_OVERRIDES}") + + named = [r for r in rows if r["OSM名"]] + print(f"unmatched {len(rows)} (named {len(named)}, unnamed {len(rows) - len(named)})") + print(f" csv: {out_csv}") + print(f" geojson: {OUT_REVIEW_GEOJSON} / {OUT_POINTS_GEOJSON}") + print(f" near<=30m: {sum(1 for r in rows if r['距離1m'] and float(r['距離1m']) <= 30)}") + print(f" suggested: {sum(1 for r in rows if r.get('推定'))}") + print(f" area<5m2: {sum(1 for r in rows if float(r['面積㎡']) < 5)}") + print("named:") + for r in named: + print( + f" {r['osm_type']}/{r['osm_id']} {r['OSM名']} -> " + f"類似 {r['名前類似候補'] or '-'} ({r['類似度'] or '-'}, {r['類似候補距離m'] or '-'}m) / " + f"最寄り {r['候補1']} {r['距離1m']}m" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/park-editor.html b/scripts/park-editor.html index 9ce109a..8c519e9 100644 --- a/scripts/park-editor.html +++ b/scripts/park-editor.html @@ -38,7 +38,7 @@ </header> <div class="wrap"><table id="tbl"><thead></thead><tbody></tbody></table></div> <script> -const FIELDS = ["公園名","住所","町名","面積㎡","500㎡以上","種別","南西部指定管理","トイレ","水飲み場","ベンチ・椅子","テーブル","パーゴラ","東屋","防災用具","ボール遊び","遊具","備考"]; +const FIELDS = ["公園名","住所","町名","面積㎡","500㎡以上","種別","南西部指定管理","設置者","トイレ","水飲み場","ベンチ・椅子","テーブル","パーゴラ","東屋","防災用具","ボール遊び","遊具","緯度","経度","備考"]; let parks = []; const tbl = document.getElementById("tbl"); const thead = tbl.querySelector("thead"); @@ -47,8 +47,11 @@ const q = document.getElementById("q"); const count = document.getElementById("count"); const status = document.getElementById("status"); -function mapsUrl(name) { - return "https://www.google.com/maps/search/?api=1&query=" + encodeURIComponent("小平市" + (name || "")); +function mapsUrl(p) { + if (p["緯度"] && p["経度"]) { + return "https://www.google.com/maps/search/?api=1&query=" + p["緯度"] + "," + p["経度"]; + } + return "https://www.google.com/maps/search/?api=1&query=" + encodeURIComponent("小平市" + (p["公園名"] || "")); } async function load() { @@ -71,7 +74,7 @@ function render() { const mapA = document.createElement("a"); mapA.textContent = "🗺"; mapA.title = "Googleマップで開く"; - mapA.href = mapsUrl(p["公園名"] || ""); + mapA.href = mapsUrl(p); mapA.target = "_blank"; mapA.rel = "noopener"; mapA.addEventListener("click", function (ev) { diff --git a/scripts/update-osm-parks.py b/scripts/update-osm-parks.py new file mode 100644 index 0000000..fffb2f3 --- /dev/null +++ b/scripts/update-osm-parks.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""小平市の公園ポリゴンをOpenStreetMapから取得して更新する。 + +使い方: + py -X utf8 scripts/update-osm-parks.py + +出力: + src/data/kodaira-parks-osm.json + src/data/kodaira-boundary.json(小平市の境界線) + +OpenStreetMapのデータは ODbL 1.0。地図表示時は +「© OpenStreetMap contributors」の帰属表示が必要。 +""" +import json +import os +import sys +import time +import unicodedata +import urllib.request + +CITY = "小平市" +OURS = "src/data/kodaira-parks.json" +OUT = "src/data/kodaira-parks-osm.json" +BOUNDARY_OUT = "src/data/kodaira-boundary.json" +OVERRIDES = "src/data/park-name-overrides.json" + +# OSMの位置補正(メートル)。地図側のLeafletペインに付いていた +# 16pxの余白をCSSで解消したため、現在は0(補正なし)。 +# 実位置との差が残る場合だけ正の値を入れる。 +NORTH_SHIFT_M = 0 +LAT_PER_M = 1 / 111320 + +ENDPOINTS = [ + "https://overpass-api.de/api/interpreter", + "https://overpass.kumi.systems/api/interpreter", + "https://overpass.private.coffee/api/interpreter", +] + +QUERY = f"""[out:json][timeout:90]; +area["name"="{CITY}"]["boundary"="administrative"]->.a; +( + way["leisure"="park"](area.a); + relation["leisure"="park"](area.a); +); +out geom; +""" + +BOUNDARY_QUERY = f"""[out:json][timeout:60]; +rel["name"="{CITY}"]["boundary"="administrative"]; +out geom; +""" + +# 名称が一致しないが、住所・座標から同一と確認できた公園。 +ALIAS = { + "小平中央公園": "中央公園", + "じゃぶじゃぶ公園": "天神じゃぶじゃぶ公園", + "リサイクルセンター東公園": "リサイクルセンター南公園", + "小平市立薬用植物園前公園": "薬用植物園前公園", + "花南ポケットパーク": "花南ポケットパーク公園", + "花5四季公園": "花五四季公園", + "天神町のびのび広場": "天神町のびのび広場公園", +} + + +def norm(s): + s = unicodedata.normalize("NFKC", str(s)).replace(" ", "").replace(" ", "") + return s.replace("ヶ", "ケ").replace("ツ", "ッ") + + +def fetch(query): + last = None + for ep in ENDPOINTS: + try: + req = urllib.request.Request( + ep, + data=query.encode("utf-8"), + headers={"User-Agent": "yasutakeyohei.com park map updater"}, + ) + with urllib.request.urlopen(req, timeout=120) as r: + data = json.loads(r.read().decode("utf-8")) + if data.get("elements"): + return data + last = "0 elements" + except Exception as e: # noqa: BLE001 + last = e + print(f" retry: {ep}: {last}", file=sys.stderr) + time.sleep(2) + raise SystemExit(f"Overpass API failed: {last}") + + +def pt(g): + return (round(g["lon"], 6), round(g["lat"] + NORTH_SHIFT_M * LAT_PER_M, 6)) + + +def ring_from_ways(ways): + rings = [] + remaining = [list(w) for w in ways if len(w) >= 2] + while remaining: + ring = remaining.pop(0) + while ring[0] != ring[-1] and remaining: + found = False + for i, w in enumerate(remaining): + if w[0] == ring[-1]: + ring.extend(w[1:]); remaining.pop(i); found = True; break + if w[-1] == ring[-1]: + ring.extend(list(reversed(w))[1:]); remaining.pop(i); found = True; break + if w[0] == ring[0]: + ring = list(reversed(w))[:-1] + ring; remaining.pop(i); found = True; break + if w[-1] == ring[0]: + ring = w[:-1] + ring; remaining.pop(i); found = True; break + if not found: + break + if ring[0] != ring[-1]: + ring.append(ring[0]) + if len(ring) >= 4: + rings.append(ring) + return rings + + +def build_boundary(): + """小平市の行政境界をGeoJSONにする。""" + data = fetch(BOUNDARY_QUERY) + if not data.get("elements"): + print(" boundary relation not found", file=sys.stderr) + return None + rel = data["elements"][0] + ways = [] + for m in rel.get("members", []): + if m.get("role") == "outer" and m.get("geometry"): + ways.append([pt(p) for p in m["geometry"]]) + rings = ring_from_ways(ways) + if not rings: + print(" boundary ring not assembled", file=sys.stderr) + return None + if len(rings) == 1: + geom = {"type": "Polygon", "coordinates": [rings[0]]} + else: + geom = {"type": "MultiPolygon", "coordinates": [[r] for r in rings]} + props = { + "name": rel.get("tags", {}).get("name", CITY), + "boundary": "administrative", + "admin_level": rel.get("tags", {}).get("admin_level", ""), + } + fc = {"type": "FeatureCollection", "features": [{"type": "Feature", "properties": props, "geometry": geom}]} + return fc, len(rings), sum(len(r) for r in rings) + + +def main(): + ours = json.load(open(OURS, encoding="utf-8")) + our_names = {norm(r["公園名"]): r["公園名"] for r in ours} + alias_norm = {norm(k): v for k, v in ALIAS.items()} + + # OSM ID単位の手動上書き(osm_type/osm_id -> 市の公園名) + overrides = {} + if os.path.exists(OVERRIDES): + raw = json.load(open(OVERRIDES, encoding="utf-8")) + for key, entry in raw.items(): + city = entry.get("city", "") if isinstance(entry, dict) else str(entry) + city = str(city).strip() + if not city: + continue + canonical = our_names.get(norm(city)) + if canonical: + overrides[key] = canonical + else: + print(f" warning: override city not found: {key} -> {city}", file=sys.stderr) + + def match(name): + k = norm(name) + if k in our_names: + return our_names[k] + if k in alias_norm: + return alias_norm[k] + # 「〇〇児童公園」→「〇〇公園」のような表記差を吸収 + k2 = k.replace("児童", "") + if k2 in our_names: + return our_names[k2] + return "" + + data = fetch(QUERY) + features = [] + applied = 0 + for e in data["elements"]: + tags = e.get("tags", {}) + name = tags.get("name", "") + key = f"{e['type']}/{e['id']}" + our_name = overrides.get(key) or match(name) + if key in overrides: + applied += 1 + props = { + "name": name, + "our_name": our_name, + "matched": bool(our_name), + "osm_type": e["type"], + "osm_id": e["id"], + } + for k in ["leisure", "addr:city", "addr:street", "addr:housenumber", "opening_hours"]: + if k in tags: + props[k] = tags[k] + + if e["type"] == "way": + ring = [pt(p) for p in e.get("geometry", [])] + if len(ring) < 3: + continue + if ring[0] != ring[-1]: + ring.append(ring[0]) + geom = {"type": "Polygon", "coordinates": [ring]} + else: + outers, inners = [], [] + for m in e.get("members", []): + pts = [pt(p) for p in m.get("geometry", [])] + if len(pts) < 2: + continue + (inners if m.get("role") == "inner" else outers).append(pts) + outer_rings = ring_from_ways(outers) + inner_rings = ring_from_ways(inners) + if not outer_rings: + continue + if len(outer_rings) == 1: + geom = {"type": "Polygon", "coordinates": [outer_rings[0]] + inner_rings} + else: + geom = {"type": "MultiPolygon", "coordinates": [[r] + inner_rings for r in outer_rings]} + + features.append({"type": "Feature", "properties": props, "geometry": geom}) + + features.sort(key=lambda f: (f["properties"].get("name", ""), f["properties"]["osm_type"], f["properties"]["osm_id"])) + out = {"type": "FeatureCollection", "features": features} + json.dump(out, open(OUT, "w", encoding="utf-8"), ensure_ascii=False, separators=(",", ":")) + + matched = sum(1 for f in features if f["properties"]["matched"]) + named = sum(1 for f in features if f["properties"].get("name")) + print(f"features {len(features)} (named {named}, matched {matched}) -> {OUT}") + if overrides: + print(f" overrides applied {applied} / {len(overrides)}") + + boundary = build_boundary() + if boundary: + boundary_data, ring_count, point_count = boundary + json.dump(boundary_data, open(BOUNDARY_OUT, "w", encoding="utf-8"), ensure_ascii=False, separators=(",", ":")) + print(f"boundary rings {ring_count}, points {point_count} -> {BOUNDARY_OUT}") + + +if __name__ == "__main__": + main() |
