"""Prepare buildings exclusively from the user-supplied VWorld SHP archive. python scripts/prepare-vworld-buildings.py PATH_TO_ZIP Requires the existing .tools/terrain environment plus building-requirements.txt. """ import argparse import collections import copy import hashlib import json from pathlib import Path import struct import sys import zipfile ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / '.tools/terrain')) import shapefile from pyproj import CRS, Transformer from shapely.geometry import shape, box from shapely.ops import transform from shapely.strtree import STRtree CONFIG = json.loads((ROOT / 'scripts/constants/gwacheon-buildings.json').read_text(encoding='utf-8')) TO_LOCAL = Transformer.from_crs(4326, CONFIG['sourceCrs'], always_xy=True).transform TO_GEO = Transformer.from_crs(CONFIG['sourceCrs'], 4326, always_xy=True).transform def positive(value, maximum): try: number = float(value) return number if 0 < number <= maximum else None except (ValueError, TypeError): return None def extract_archive(path): """Read SHPs sequentially and only decompress DBF up to selected records. pyshp.record() rewinds its DBF on every call; doing that on a compressed 1.8 GB member repeatedly is prohibitive. Read the selected fixed-width rows in ascending order instead. Source files remain in the supplied ZIP. """ bounds = transform(TO_LOCAL, box(*CONFIG['bbox'])).buffer(20).bounds features = [] with zipfile.ZipFile(path) as archive: for name in archive.namelist(): if not name.lower().endswith('.shp'): continue stem = name[:-4] source_crs = CRS.from_wkt(archive.read(stem + '.prj').decode()) expected_crs = CRS(CONFIG['sourceCrs']) # ESRI WKT uses a different datum spelling; verify the actual TM parameters. if (source_crs.coordinate_operation.method_name != expected_crs.coordinate_operation.method_name or [p.value for p in source_crs.coordinate_operation.params] != [p.value for p in expected_crs.coordinate_operation.params] or source_crs.ellipsoid.semi_major_metre != expected_crs.ellipsoid.semi_major_metre or source_crs.ellipsoid.inverse_flattening != expected_crs.ellipsoid.inverse_flattening or any(axis.unit_conversion_factor != 1 for axis in source_crs.axis_info)): raise ValueError('Unexpected source CRS: ' + stem) with archive.open(name) as shp, archive.open(stem + '.shx') as shx, archive.open(stem + '.dbf') as dbf: reader = shapefile.Reader(shp=shp, shx=shx) selected = list(reader.iterShapes(bbox=bounds)) header = dbf.read(32) header_size, row_size = struct.unpack_from(' CONFIG['overlapAreaMeters2'] for i in additions.query(polygon)): report['officialOverlapsSkipped'].append(feature['id']) continue output.append(copy.deepcopy(feature)) accepted.add(index) report['officialAdded'] += 1 return output, report def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('archive', type=Path) args = parser.parse_args() with args.archive.open('rb') as source_file: digest = hashlib.file_digest(source_file, 'sha256').hexdigest() cache = ROOT / 'data/raw/gwacheon/vworld-extract.geojson' cache_hash = cache.with_suffix('.sha256') if cache.exists() and cache_hash.exists() and cache_hash.read_text() == digest: raw = json.loads(cache.read_text(encoding='utf-8'))['features'] else: raw = extract_archive(args.archive) cache.write_text(json.dumps({'type': 'FeatureCollection', 'features': raw}, ensure_ascii=False), encoding='utf-8') cache_hash.write_text(digest) data, report = select_buildings(official_features(raw)) counts = collections.Counter('height' if positive(f['properties'].get('height'), 400) else 'levels' if positive(f['properties'].get('building:levels'), 100) else 'fallback' for f in data) manifest = {'bbox': CONFIG['bbox'], 'count': len(data), 'types': dict(collections.Counter(f['properties']['building'] for f in data)), **CONFIG['licensing'], 'heightTags': sum('height' in f['properties'] for f in data), 'levelTags': sum('building:levels' in f['properties'] for f in data), 'heightSources': dict(counts), 'selection': report, 'official': {'archive': args.archive.name, 'sha256': digest, 'crs': CONFIG['sourceCrs'], 'url': CONFIG['sourceUrl'], 'license': CONFIG['licensing']['licenses']['VWorld'], 'rawRegionalFeatures': len(raw), 'fieldDefinitions': 'data/references/gwacheon/vworld-columns.xlsx'}, 'limitations': ['VWorld-only footprints and attributes. Snapshot can contain older footprints.', 'Overlaps >0.5m² between VWorld records are excluded, preferring measured height or levels.', 'Missing heights remain explicitly estimated; terrain is still Copernicus DSM, not surveyed ground.']} out = ROOT / 'public/buildings' (out / 'gwacheon.geojson').write_text(json.dumps({'type': 'FeatureCollection', 'attribution': '국토교통부 GIS건물통합정보 (VWorld)', **CONFIG['licensing'], 'features': data}, ensure_ascii=False, separators=(',', ':')), encoding='utf-8') (out / 'metadata.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding='utf-8') print(json.dumps({'count': len(data), 'heightSources': dict(counts), 'selection': {k: len(v) if isinstance(v, list) else v for k,v in report.items()}}, indent=2)) if __name__ == '__main__': sys.stdout.reconfigure(encoding='utf-8') main()