diff options
Diffstat (limited to 'scripts/make-park-qgis-kit.py')
| -rw-r--r-- | scripts/make-park-qgis-kit.py | 278 |
1 files changed, 278 insertions, 0 deletions
diff --git a/scripts/make-park-qgis-kit.py b/scripts/make-park-qgis-kit.py new file mode 100644 index 0000000..0df6d5a --- /dev/null +++ b/scripts/make-park-qgis-kit.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""QGISで公園ポリゴンを手描きするための下地データを出力する。 + +使い方: + py -X utf8 scripts/make-park-qgis-kit.py + +出力(reference/ は .gitignore 対象): + reference/qgis/parks-without-polygon.geojson … ポリゴンが未整備の市の公園(点) + reference/qgis/existing-parks.geojson … 整備済みポリゴン(参考・重複防止) + reference/qgis/README.md … QGISでの手順 + +描いたポリゴンは scripts/import-manual-parks.py で +src/data/kodaira-parks-manual.json に取り込む。 +""" + +import argparse +import json +import math +import os +import re +import sys +import unicodedata + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +OURS = os.path.join(ROOT, "src", "data", "kodaira-parks.json") +OSM = os.path.join(ROOT, "src", "data", "kodaira-parks-osm.json") +MANUAL = os.path.join(ROOT, "src", "data", "kodaira-parks-manual.json") +OUT_DIR = os.path.join(ROOT, "reference", "qgis") +MANUAL_QGIS = os.path.join(OUT_DIR, "manual-parks.geojson") + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + + +def load(path): + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def norm(s): + s = "".join(unicodedata.normalize("NFKC", str(s or "")).split()) + return s.replace("ヶ", "ケ").replace("ツ", "ッ") + + +def has_coords(geom): + """空のポリゴン(QGISの未完成Feature)を弾く。""" + if not geom: + return False + coords = geom.get("coordinates") or [] + if geom.get("type") == "Polygon": + return bool(coords and coords[0]) + if geom.get("type") == "MultiPolygon": + return any(poly and poly[0] for poly in coords) + return False + + +def center(geom): + if geom["type"] == "Polygon": + pts = geom["coordinates"][0] + else: + pts = [p for poly in geom["coordinates"] for p in poly[0]] + return ( + (min(p[1] for p in pts) + max(p[1] for p in pts)) / 2, + (min(p[0] for p in pts) + max(p[0] for p in pts)) / 2, + ) + + +def dist_m(a, b): + dy = (a[0] - b[0]) * 111320 + dx = (a[1] - b[1]) * 111320 * math.cos(math.radians((a[0] + b[0]) / 2)) + return math.hypot(dx, dy) + + +def write_geojson(path, features): + with open(path, "w", encoding="utf-8") as f: + json.dump({"type": "FeatureCollection", "features": features}, f, ensure_ascii=False) + f.write("\n") + + +def main(): + ap = argparse.ArgumentParser(description="QGISで公園ポリゴンを手描きするための下地データを出力する") + ap.add_argument("--list", action="store_true", help="ポリゴン未整備の公園一覧も表示する") + args = ap.parse_args() + + ours = load(OURS) + osm = load(OSM) + manual = load(MANUAL) + + # QGISで描いたが、まだ取り込んでいないポリゴンの公園名 + drawn_names = set() + if os.path.exists(MANUAL_QGIS): + try: + for feat in load(MANUAL_QGIS).get("features", []): + p = feat.get("properties") or {} + if not has_coords(feat.get("geometry")): + continue + name = str(p.get("our_name") or "").strip() + if name: + drawn_names.add(norm(name)) + except (ValueError, OSError) as e: + print(f" warning: {MANUAL_QGIS} を読めません: {e}", file=sys.stderr) + + polygons = [] + covered = set() + osm_by_id = {} + osm_unmatched_centers = [] + for feat in osm.get("features", []): + p = feat.get("properties", {}) + osm_by_id[int(p.get("osm_id") or 0)] = p.get("our_name") or "" + if p.get("matched") and p.get("our_name"): + covered.add(p["our_name"]) + polygons.append( + { + "type": "Feature", + "properties": { + "our_name": p["our_name"], + "source": f"OSM {p.get('osm_type', '')}/{p.get('osm_id', '')}", + }, + "geometry": feat["geometry"], + } + ) + else: + osm_unmatched_centers.append((center(feat["geometry"]), p)) + for feat in manual.get("features", []): + p = feat.get("properties", {}) + if p.get("our_name"): + covered.add(p["our_name"]) + polygons.append( + { + "type": "Feature", + "properties": { + "our_name": p["our_name"], + "source": f"manual {p.get('osm_id', '')}", + }, + "geometry": feat["geometry"], + } + ) + + points = [] + no_point = [] + already = [] + for r in ours: + name = r.get("公園名", "") + if not name or name in covered: + continue + # 「緑地(OSM 1234567)」は同じIDのポリゴンが既にある + m = re.match(r"^緑地(OSM (\d+))$", name) + if m and int(m.group(1)) in osm_by_id: + already.append(name) + continue + try: + lat = float(r.get("緯度") or "") + lon = float(r.get("経度") or "") + except ValueError: + no_point.append(name) + continue + # 名前未設定のOSMポリゴンが同じ場所にある場合も、二重描き防止のため除外 + if any(dist_m((lat, lon), c) <= 20 for c, _ in osm_unmatched_centers): + already.append(name) + continue + points.append( + { + "type": "Feature", + "properties": { + "no": len(points) + 1, + "our_name": name, + "address": r.get("住所", ""), + "kind": r.get("種別", ""), + "official_area_m2": r.get("面積㎡", ""), + "drawn": norm(name) in drawn_names, + "lat": lat, + "lon": lon, + }, + "geometry": {"type": "Point", "coordinates": [lon, lat]}, + } + ) + + counts = { + "covered": len(covered), + "missing": len(points) + len(no_point), + "no_point": len(no_point), + "already": len(already), + "drawn": sum(1 for p in points if p["properties"]["drawn"]), + } + + os.makedirs(OUT_DIR, exist_ok=True) + write_geojson(os.path.join(OUT_DIR, "parks-without-polygon.geojson"), points) + write_geojson(os.path.join(OUT_DIR, "existing-parks.geojson"), polygons) + readme = f"""# QGIS で公園ポリゴンを描く + +`scripts/make-park-qgis-kit.py` の生成物です。再生成すると上書きされます。 + +- ポリゴン整備済み: {counts['covered']} 件 +- OSM側に既にポリゴンあり(未リンク): {counts['already']} 件 +- ポリゴン未整備: {counts['missing']} 件(うち手入力済み・未取り込み {counts['drawn']} 件、座標なし {counts['no_point']} 件) + +## 1. QGIS に背景地図を追加 + +「レイヤ > レイヤを追加 > XYZ タイルを追加」で次を登録します。 + +| 用途 | URL | 最大ズーム | +|---|---|---| +| 国土地理院 空中写真(推奨) | `https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{{z}}/{{x}}/{{y}}.jpg` | 18 | +| 国土地理院 淡色地図 | `https://cyberjapandata.gsi.go.jp/xyz/pale/{{z}}/{{x}}/{{y}}.png` | 18 | +| OpenStreetMap | `https://tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png` | 19 | + +## 2. 下地を読み込む + +- `parks-without-polygon.geojson` … まだポリゴンがない公園(点)。`our_name` に公園名が入っています +- `existing-parks.geojson` … 整備済みポリゴン。二重描き防止用 + +## 3. ポリゴン用レイヤを作る + +1. 「レイヤ > レイヤを作成 > 新しい GeoJSON レイヤを作成」 +2. ファイル名は `reference/qgis/manual-parks.geojson`、ジオメトリ型は **Polygon**、CRS は **EPSG:4326** +3. フィールドを1つ追加: 名前 `our_name`、型 **文字列** +4. 編集モードにして「ポリゴンを追加」で公園の輪郭を描く + - 点レイヤのラベルを次の式にすると、公園名と台帳面積が同時に出ます + `"our_name" || '\n' || "official_area_m2" || ' ㎡'` + - スナップを有効にすると描きやすい +5. 属性入力欄に `parks-without-polygon.geojson` の `our_name` をそのまま入力 + (1つの公園が複数ポリゴンに分かれる場合は、同じ `our_name` で複数描く) +6. 編集を保存 + +> `our_name` を空欄にしても、取り込み時に最寄りの公園点から自動で判定します +> (既定 60m 以内・曖昧でない場合のみ)。 + +## 4. 取り込む + +```bash +py -X utf8 scripts/import-manual-parks.py reference/qgis/manual-parks.geojson --dry-run +py -X utf8 scripts/import-manual-parks.py reference/qgis/manual-parks.geojson +``` + +`--dry-run` で公園名・面積・台帳面積との倍率・警告を確認してから本実行してください。 +台帳面積の 0.7〜1.5 倍を外れると警告が出ます。 +取り込み後に `npm run build` で地図と表を確認します。 + +## 進捗の管理 + +`parks-without-polygon.geojson` の `drawn` は、`manual-parks.geojson` に同じ `our_name` のポリゴンがあると `true` になります。 + +- 属性テーブルで `drawn` を確認できます +- スタイルを `drawn = true` で灰色にすると、残りが分かりやすくなります +- 取り込み後に再生成すると、取り込んだ公園はこの一覧から消えます + +> GeoPackage(`.gpkg`)で描いている場合は、`manual-parks.geojson` に書き出してから再生成してください。 + +## 注意 + +- 座標系は EPSG:4326(緯度経度)で保存してください。Web メルカトル(EPSG:3857)のまま書き出すと位置がずれます +- 面積が 20㎡ 未満のポリゴンは、下書きの消し忘れとみなして取り込みを中止します +- `existing-parks.geojson` にある公園は描かないでください(重複します) +""" + with open(os.path.join(OUT_DIR, "README.md"), "w", encoding="utf-8") as f: + f.write(readme) + + print(f"ポリゴン整備済み: {counts['covered']} 件") + print(f"OSM側に既にポリゴンあり(未リンク): {counts['already']} 件") + print(f"ポリゴン未整備: {counts['missing']} 件(座標なし {counts['no_point']} 件)") + if no_point: + print("座標がないため地図に出せない公園(先に kodaira-parks.json の緯度・経度を埋める):") + for n in no_point: + print(f" - {n}") + print(f"出力: {OUT_DIR}") + + if args.list: + print("\nポリゴン未整備の公園:") + for p in points: + props = p["properties"] + mark = " [手入力済み]" if props["drawn"] else "" + print(f" {props['no']:3d}. {props['our_name']}({props['address'] or '住所未登録'}){mark}") + for name in no_point: + print(f" --- {name}(緯度・経度未登録)") + + +if __name__ == "__main__": + main() |
