From a71727538f65ab4f10f5239550be82590f9d47d1 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:29:24 +0900 Subject: parks: 公園一覧と地図、公共施設の情報を更新 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 近隣公園のクリックを市内公園と同じ挙動にし、Googleマップを公園名で開く - 公共施設のトイレを○にし、ルネこだいらを追加 - 未取得だった施設の座標を追加し、再ジオコーディング時の上書きを防止 - 手入力ポリゴン、近隣公園、PWA、ライセンス表示を追加 --- scripts/update-osm-parks.py | 266 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 244 insertions(+), 22 deletions(-) (limited to 'scripts/update-osm-parks.py') diff --git a/scripts/update-osm-parks.py b/scripts/update-osm-parks.py index fffb2f3..fcca883 100644 --- a/scripts/update-osm-parks.py +++ b/scripts/update-osm-parks.py @@ -11,8 +11,11 @@ OpenStreetMapのデータは ODbL 1.0。地図表示時は 「© OpenStreetMap contributors」の帰属表示が必要。 """ +import difflib import json +import math import os +import re import sys import time import unicodedata @@ -30,17 +33,39 @@ OVERRIDES = "src/data/park-name-overrides.json" NORTH_SHIFT_M = 0 LAT_PER_M = 1 / 111320 +# 名前が一致しないポリゴンの自動照合。誤リンクを避けるため保守的に絞る。 +NEAR_DIST_M = 20 # 公園の点がポリゴンからこの距離以内なら同一とみなす +NEAR_NAME_MIN = 0.5 # 近接照合で、名前付きポリゴンに求める名前類似度 +FUZZY_NAME_MIN = 0.8 # 正規化後の名前類似度がこれを超えたら候補(厳密に大) +FUZZY_MAX_DIST_M = 500 # 名前類似だけで照合する場合の距離上限 +# ポリゴン面積と台帳面積の比。極端に違うものは別施設とみなす +AREA_RATIO_MIN = 0.2 +AREA_RATIO_MAX = 5.0 + +# 「緑地(OSM 1234567)」は OSM の ID を名前に含んでいる +OSM_ID_NAME_RE = re.compile(r"^緑地(OSM (\d+))$") + +# 数字・方位・新旧など「位置の違い」を表す文字。これだけが違う名前は +# 別公園の可能性が高い(例: 七小西第1公園 / 七小西第2公園)。 +POSITIONAL_CHARS = set("01234567890123456789一二三四五六七八九十第東西南北上中下前後新") + ENDPOINTS = [ "https://overpass-api.de/api/interpreter", "https://overpass.kumi.systems/api/interpreter", "https://overpass.private.coffee/api/interpreter", ] +# leisure=park だけでは取りこぼす児童遊園・広場・緑地も取得する。 +# 照合は下の厳しめの条件(完全一致→近接→名前類似)で行う。 QUERY = f"""[out:json][timeout:90]; area["name"="{CITY}"]["boundary"="administrative"]->.a; ( - way["leisure"="park"](area.a); - relation["leisure"="park"](area.a); + way["leisure"~"^(park|playground|garden|recreation_ground|village_green)$"](area.a); + relation["leisure"~"^(park|playground|garden|recreation_ground|village_green)$"](area.a); + way["landuse"~"^(recreation_ground|village_green)$"](area.a); + relation["landuse"~"^(recreation_ground|village_green)$"](area.a); + way["landuse"="forest"]["name"~"コゲラの森|こげらの森"](area.a); + relation["landuse"="forest"]["name"~"コゲラの森|こげらの森"](area.a); ); out geom; """ @@ -63,10 +88,74 @@ ALIAS = { def norm(s): - s = unicodedata.normalize("NFKC", str(s)).replace(" ", "").replace(" ", "") + s = "".join(unicodedata.normalize("NFKC", str(s or "")).split()) return s.replace("ヶ", "ケ").replace("ツ", "ッ") +def similarity(a, b): + return difflib.SequenceMatcher(None, a, b).ratio() + + +def strip_positional(s): + return "".join(c for c in s if c not in POSITIONAL_CHARS) + + +def rings(geom): + if geom["type"] == "Polygon": + return [geom["coordinates"][0]] + return [poly[0] for poly in geom["coordinates"]] + + +def point_in_ring(lon, lat, ring): + inside = False + for (x1, y1), (x2, y2) in zip(ring, ring[1:]): + if (y1 > lat) != (y2 > lat): + if lon < (x2 - x1) * (lat - y1) / (y2 - y1) + x1: + inside = not inside + return inside + + +def segment_dist_m(lon, lat, a, b): + """点から線分までの距離(メートル)。""" + k = math.cos(math.radians(lat)) + x1, y1 = a[0] * k, a[1] + x2, y2 = b[0] * k, b[1] + px, py = lon * k, lat + dx, dy = x2 - x1, y2 - y1 + if dx == 0 and dy == 0: + t = 0.0 + else: + t = max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy))) + return math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)) * 111320 + + +def polygon_dist_m(geom, lat, lon): + """公園の代表点からポリゴンまでの距離。内側なら0。""" + rs = rings(geom) + for ring in rs: + if point_in_ring(lon, lat, ring): + return 0.0 + return min( + (segment_dist_m(lon, lat, a, b) for ring in rs for a, b in zip(ring, ring[1:])), + default=float("inf"), + ) + + +def polygon_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 fetch(query): last = None for ep in ENDPOINTS: @@ -150,6 +239,41 @@ def main(): our_names = {norm(r["公園名"]): r["公園名"] for r in ours} alias_norm = {norm(k): v for k, v in ALIAS.items()} + # 市の公園の代表点(緯度・経度)と台帳面積 + our_points = [] + our_areas = {} + our_id_names = {} + for r in ours: + name = r["公園名"] + m = OSM_ID_NAME_RE.match(name) + if m: + our_id_names[int(m.group(1))] = name + try: + our_areas[name] = float(r.get("面積㎡") or "") + except ValueError: + pass + try: + lat = float(r.get("緯度") or "") + lon = float(r.get("経度") or "") + except ValueError: + continue + our_points.append((name, lat, lon)) + + # 前回の出力(新規に照合できたもののレポートと、部分取得時の補完に使う) + previous_matched = set() + previous_features = {} + if os.path.exists(OUT): + try: + prev = json.load(open(OUT, encoding="utf-8")) + for f in prev.get("features", []): + p = f.get("properties", {}) + key = f"{p.get('osm_type')}/{p.get('osm_id')}" + previous_features[key] = f + if p.get("matched"): + previous_matched.add(key) + except (ValueError, KeyError): + pass + # OSM ID単位の手動上書き(osm_type/osm_id -> 市の公園名) overrides = {} if os.path.exists(OVERRIDES): @@ -165,38 +289,72 @@ def main(): else: print(f" warning: override city not found: {key} -> {city}", file=sys.stderr) - def match(name): + def exact_match(name): k = norm(name) if k in our_names: - return our_names[k] + return our_names[k], "exact" if k in alias_norm: - return alias_norm[k] + return alias_norm[k], "alias" # 「〇〇児童公園」→「〇〇公園」のような表記差を吸収 k2 = k.replace("児童", "") if k2 in our_names: - return our_names[k2] - return "" + return our_names[k2], "exact" + return "", "" + + def area_ok(park, osm_area): + official = our_areas.get(park) + if not official: + return True + return AREA_RATIO_MIN <= osm_area / official <= AREA_RATIO_MAX + + def fuzzy_match(name, geom): + """ポリゴンが重なる、または名前が十分似ている市の公園を返す。""" + dists = [(polygon_dist_m(geom, q[1], q[2]), q[0]) for q in our_points] + near = sorted(dists) + near_dist, near_park = near[0] + osm_area = polygon_area_m2(geom) + if near_dist <= NEAR_DIST_M: + # 複数の公園が重なる場合はどちらか判定できないので見送る + if len(near) > 1 and near[1][0] <= NEAR_DIST_M: + return "", "", near_dist, 0.0 + # 別名の公園は、ポリゴンが同じ場所にあっても結びつけない + if name and similarity(norm(name), norm(near_park)) < NEAR_NAME_MIN: + return "", "", near_dist, 0.0 + # 極端に面積が違う場合は別施設(大きなグラウンド等)とみなす + if not area_ok(near_park, osm_area): + return "", "", near_dist, 0.0 + return near_park, "near", near_dist, 0.0 + + if not name: + return "", "", near_dist, 0.0 + k = norm(name) + ranked = sorted( + ((similarity(k, norm(park)), dist, park) for dist, park in dists), + key=lambda x: (-x[0], x[1]), + ) + sim, dist, park = ranked[0] + if sim <= FUZZY_NAME_MIN or dist > FUZZY_MAX_DIST_M: + return "", "", dist, sim + # 数字・方位だけの違いは別公園の可能性が高い。重なっている場合のみ採用 + if strip_positional(k) == strip_positional(norm(park)) and dist > NEAR_DIST_M: + return "", "", dist, sim + if not area_ok(park, osm_area): + return "", "", dist, sim + return park, "fuzzy", dist, sim data = fetch(QUERY) + fetched_keys = {f"{e['type']}/{e['id']}" for e in data["elements"]} features = [] applied = 0 - for e in data["elements"]: + covered = set() + new_matches = [] + + # leisure=park を先に処理し、公園本体が広場・遊び場より先に枠を取るようにする + elements = sorted(data["elements"], key=lambda e: (e.get("tags", {}).get("leisure") != "park", e["type"], e["id"])) + for e in 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", [])] @@ -221,8 +379,63 @@ def main(): else: geom = {"type": "MultiPolygon", "coordinates": [[r] + inner_rings for r in outer_rings]} + our_name, reason = overrides.get(key, ""), "override" if key in overrides else "" + dist = sim = None + if key in overrides: + applied += 1 + elif not our_name and e["id"] in our_id_names: + # 「緑地(OSM 1234567)」は ID が名前に入っているので直接結びつける + our_name, reason = our_id_names[e["id"]], "osm_id" + elif not our_name and not exact_match(name)[0]: + our_name, reason, dist, sim = fuzzy_match(name, geom) + if not our_name: + our_name, reason = exact_match(name) + + # 公園本体(leisure=park)以外が既に整備済みの公園に重なる場合は、 + # 公園内の遊び場・広場とみなして取り込まない。 + core = tags.get("leisure") == "park" + if our_name in covered and not (core and reason in ("exact", "alias", "override")): + continue + + props = { + "name": name, + "our_name": our_name, + "matched": bool(our_name), + "osm_type": e["type"], + "osm_id": e["id"], + } + if our_name: + props["match"] = reason + for k in ["leisure", "landuse", "addr:city", "addr:street", "addr:housenumber", "opening_hours"]: + if k in tags: + props[k] = tags[k] + + if our_name: + covered.add(our_name) + if key not in previous_matched: + new_matches.append((our_name, key, reason, tags.get("leisure") or tags.get("landuse", ""), dist, sim)) + features.append({"type": "Feature", "properties": props, "geometry": geom}) + # Overpassが部分的な結果を返すと、override対象のFeatureが抜けることがある。 + # その場合は前回のデータを保持して、公園のポリゴンを失わないようにする。 + for key, city in overrides.items(): + if key in fetched_keys: + continue + prev_feat = previous_features.get(key) + if not prev_feat: + continue + feat = json.loads(json.dumps(prev_feat)) + feat["properties"]["our_name"] = city + feat["properties"]["matched"] = True + feat["properties"]["match"] = "override" + features.append(feat) + applied += 1 + print( + f" warning: {key} は今回の取得に含まれないため前回のデータを保持しました", + file=sys.stderr, + ) + 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=(",", ":")) @@ -232,6 +445,15 @@ def main(): print(f"features {len(features)} (named {named}, matched {matched}) -> {OUT}") if overrides: print(f" overrides applied {applied} / {len(overrides)}") + if new_matches: + print(f" newly matched: {len(new_matches)}") + for our_name, key, reason, tag, dist, sim in sorted(new_matches): + detail = [] + if sim: + detail.append(f"類似{sim:.2f}") + if dist is not None: + detail.append(f"{dist:.0f}m") + print(f" {our_name} <- {key} [{tag}/{reason}] {' '.join(detail)}") boundary = build_boundary() if boundary: -- cgit v1.3.1