"""OSM XML snapshots -> attributed footprint GeoJSON, with no fabricated source heights.""" import collections import argparse import hashlib import json from pathlib import Path import xml.etree.ElementTree as ET root = Path(__file__).resolve().parents[1] items = {} sources = [] boxes = ['126.975,37.410,126.995,37.435','126.995,37.410,127.015,37.435','126.975,37.435,126.995,37.460','126.995,37.435,127.015,37.460'] for i, bbox in enumerate(boxes): path = root / f'data/raw/gwacheon/tile-{i}.osm' tree = ET.parse(path).getroot() sources.append({'file':path.name,'url':'https://www.openstreetmap.org/api/0.6/map?bbox='+bbox,'sha256':hashlib.sha256(path.read_bytes()).hexdigest()}) for element in tree: items[(element.tag,element.get('id'))] = element nodes = {key[1]:[float(e.get('lon')),float(e.get('lat'))] for key,e in items.items() if key[0]=='node'} def tags(e): return {t.get('k'):t.get('v') for t in e.findall('tag')} def ring(e): refs = [n.get('ref') for n in e.findall('nd')] if len(refs)<4 or refs[0]!=refs[-1] or any(r not in nodes for r in refs): return None return [nodes[r] for r in refs] features=[] skipped=[] consumed=set() for (kind,identity),e in items.items(): p=tags(e) if kind!='relation' or 'building' not in p: continue outer=[]; inner=[]; members=[]; valid=True for m in e.findall('member'): way=items.get(('way',m.get('ref'))) r=ring(way) if way is not None else None if m.get('type')!='way' or r is None: valid=False; break (inner if m.get('role')=='inner' else outer).append(r) members.append(m.get('ref')) if valid and len(outer)==1: features.append({'type':'Feature','id':'relation/'+identity,'properties':p,'geometry':{'type':'Polygon','coordinates':outer+inner}}) consumed.update(members) else: skipped.append({'id':'relation/'+identity,'reason':'multipart or incomplete relation; not silently filled'}) for (kind,identity),e in items.items(): p=tags(e) if kind!='way' or 'building' not in p or identity in consumed: continue if p['building'] in ['no','construction','ruins'] or p.get('location')=='underground': skipped.append({'id':'way/'+identity,'reason':'not an existing above-ground building'}); continue r=ring(e) if r is None: skipped.append({'id':'way/'+identity,'reason':'open or incomplete footprint'}); continue features.append({'type':'Feature','id':'way/'+identity,'properties':p,'geometry':{'type':'Polygon','coordinates':[r]}}) # API returns ways crossing the requested bounds: retain complete footprints only if center is in AOI. features=[f for f in features if 126.975 <= sum(c[0] for c in f['geometry']['coordinates'][0][:-1])/len(f['geometry']['coordinates'][0][:-1]) <= 127.015 and 37.410 <= sum(c[1] for c in f['geometry']['coordinates'][0][:-1])/len(f['geometry']['coordinates'][0][:-1]) <= 37.460] data={'type':'FeatureCollection','attribution':'© OpenStreetMap contributors','license':'ODbL-1.0','features':features} parser = argparse.ArgumentParser() parser.add_argument('--output-dir', type=Path, default=root/'public/buildings') out=parser.parse_args().output_dir out.mkdir(exist_ok=True,parents=True) (out/'gwacheon.geojson').write_text(json.dumps(data,ensure_ascii=False,separators=(',',':')),encoding='utf-8') report={'downloadedAt':'2026-10-29','bbox':[126.975,37.410,127.015,37.460],'count':len(features),'types':dict(collections.Counter(f['properties']['building'] for f in features)),'heightTags':sum('height' in f['properties'] for f in features),'levelTags':sum('building:levels' in f['properties'] for f in features),'sources':sources,'skipped':skipped,'license':'https://www.openstreetmap.org/copyright'} (out/'metadata.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8') print(json.dumps(report,ensure_ascii=False))