1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
|
#!/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 difflib
import json
import math
import os
import re
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
# 名前が一致しないポリゴンの自動照合。誤リンクを避けるため保守的に絞る。
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|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;
"""
BOUNDARY_QUERY = f"""[out:json][timeout:60];
rel["name"="{CITY}"]["boundary"="administrative"];
out geom;
"""
# 名称が一致しないが、住所・座標から同一と確認できた公園。
ALIAS = {
"小平中央公園": "中央公園",
"じゃぶじゃぶ公園": "天神じゃぶじゃぶ公園",
"リサイクルセンター東公園": "リサイクルセンター南公園",
"小平市立薬用植物園前公園": "薬用植物園前公園",
"花南ポケットパーク": "花南ポケットパーク公園",
"花5四季公園": "花五四季公園",
"天神町のびのび広場": "天神町のびのび広場公園",
}
def norm(s):
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:
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()}
# 市の公園の代表点(緯度・経度)と台帳面積
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):
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 exact_match(name):
k = norm(name)
if k in our_names:
return our_names[k], "exact"
if k in alias_norm:
return alias_norm[k], "alias"
# 「〇〇児童公園」→「〇〇公園」のような表記差を吸収
k2 = k.replace("児童", "")
if k2 in our_names:
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
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']}"
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]}
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=(",", ":"))
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)}")
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:
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()
|