aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-18 22:16:21 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-18 22:16:21 +0900
commit9a169c21e1032493a68d575b534cd31179b437e3 (patch)
treeba9d63f456e1eb403379926c9de89eb5ce100c84 /scripts
parent4e84129d4b2b3e806689371f8912ffa2f4767113 (diff)
公園一覧: 非市立公園・市民広場の追加と地図調整
未一致のOSMポリゴンを一覧に追加し、都立・民間・未確認のバッジを表示 市民広場3件を追加し、花小金井四丁目市民広場の名称を修正 地図の初期表示を市境に合わせ、市外をマスク Googleマップリンクを座標優先に変更 手動照合用のレビューCSV生成とOSM ID上書きを追加
Diffstat (limited to 'scripts')
-rw-r--r--scripts/make-park-match-review.py305
-rw-r--r--scripts/park-editor.html11
-rw-r--r--scripts/update-osm-parks.py244
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()