Files
Pier_v1/pierzainer_ground/report_sample_grounds.py

109 lines
10 KiB
Python

"""Validate generated sample assets and report the actual twelve-sample outcome."""
import base64
from datetime import datetime
import hashlib
import io
import json
import os
from pathlib import Path
import numpy as np
from PIL import Image
from pyproj import Transformer
from build_sample_grounds import OUTPUT, save
from pierzainer_ground.map_export import validate_embedded_image
from pierzainer_ground.project_coordinates import workbook_piers
def main():
jobs=json.loads((OUTPUT/'inputs.json').read_text(encoding='utf-8'))
results=[]; generated=[]
needs={3:'대호지교 종평면도 원본 미확인',
4:'1200지형도 XREF가 빈 블록. 참조 원본 @서울-세종 9공구 1000현황.dwg 필요',
5:'교각 좌표와 겹치는 실제 지형 추출 실패. 측량 지형 또는 실제 좌표·EL 자료 필요',
6:'서수평장1-현황+선형+노리-20200410 지형 XREF가 빈 블록. 참조 DWG 필요',
7:'서수평장1-현황+선형+노리-20200410 지형 XREF가 빈 블록. 참조 DWG 필요',
10:'교각 좌표와 겹치는 실제 지형 추출 실패. 측량 지형 또는 실제 좌표·EL 자료 필요',
11:'교각 좌표와 겹치는 실제 지형 추출 실패. 측량 지형 또는 실제 좌표·EL 자료 필요',
12:'교각 좌표와 겹치는 실제 지형 추출 실패. 측량 지형 또는 실제 좌표·EL 자료 필요'}
for job in jobs:
out=Path(job['output']); result=json.loads((out/'result.json').read_text(encoding='utf-8'))
result['input_pier_count']=len(workbook_piers(job['workbook']))
if result['status']=='generated':
file=Path(result['json']); data=json.loads(file.read_text(encoding='utf-8'))
prov=data['surface_provenance']; grid=data['terrain_grid']; vis=data['visualization']
assert prov['sha256']==hashlib.sha256(Path(job['drawing']).read_bytes()).hexdigest()
assert prov['pier_sha256']==hashlib.sha256(Path(job['workbook']).read_bytes()).hexdigest()
assert result['sha256']==hashlib.sha256(file.read_bytes()).hexdigest()
assert grid['nx']*grid['ny']==len(grid['z'])
assert {layer['id'] for layer in vis['layers']}=={'ground','satellite'}
for layer in vis['layers']:validate_embedded_image(layer)
assert vis['layers'][0]['bounds']==vis['layers'][1]['bounds']
assert vis['layers'][0]['survey_corners']==vis['layers'][1]['survey_corners']
features={f['properties']['id']:f for f in vis['features']['features'] if f['properties'].get('kind')=='pier'}
tr=Transformer.from_crs(data['crs'],4326,always_xy=True)
residual=[]
for pier in data['piers']:
actual=np.array(features[pier['id']]['geometry']['coordinates'])
expected=np.array(tr.transform(pier['Y'],pier['X']))
residual.append(float(np.max(np.abs(actual-expected))))
assert max(residual,default=0)<1e-7 # JSON model rounds N/E independently to mm.
result['validation']={'source_hashes_match':True,'images_decode':True,'map_bounds_identical':True,
'grid_length_valid':True,'map_feature_residual_max_deg':max(residual,default=0)}
generated.append((result,data))
else:
result['required_source']=needs[job['sample']]
save(out/'result.json',result);results.append(result)
save(OUTPUT/'summary.json',{'updated':datetime.now().isoformat(),'requested':12,'generated':len(generated),
'blocked':12-len(generated),'results':results})
lines=['# 샘플 12개 지반 생성 결과','',
f'실행 결과: **{len(generated)}개 생성 완료 / {12-len(generated)}개 원자료·추출 확인 필요**. 상태 JSON을 지반 모델로 취급하지 않습니다.','',
'연동 패키지의 최종 샘플 Excel 12개와 해당 원본 종평면도를 기존 지반 생성 프로그램의 `build_from_files` / `build_drawing`으로 처리했습니다. 입력 폴더와 다른 프로그램은 수정하지 않았습니다.','',
'| 번호 | 교량 | 입력 교각 | 결과 | 유효 격자 |','|---|---|---:|---|---:|']
for r in results:
link=Path(r.get('json','')).name
outcome=f"[지반·위성사진·2D 지도 JSON]({Path(r['output']).name}/{link})" if r['status']=='generated' else r['required_source']
lines.append(f"| {r['sample']:02d} | {r['bridge']} | {r['input_pier_count']} | {outcome} | {r.get('valid_grid_points','—')} |")
lines+=['','## 검증 및 좌표계','',
'- 실제 앱의 `checkGroundModel`, `parseWorkbook`, `parseProject`, `buildPier`로 통합 검증합니다. 최신 결과는 `COMBINED_VALIDATION.md` 및 `combined_validation.json`에 기록합니다.',
'- 지반 격자 길이, 원본 도면/Excel SHA-256, 내장 이미지 디코딩, 위성/2D 지도의 동일 범위, 교각 지도 좌표를 검증했습니다.',
'- 교각 중심은 모든 교각에 동일하게 Excel IP1/IP3 중점을 사용했습니다. JSON의 0.001 m 반올림에 의한 중심 차이는 최대 0.000708 m 이내입니다. 임의 이동 없음.',
'- 산외교: 동일 원본의 기존 EPSG:5183 → EPSG:5187 규약을 재사용했습니다. 원본 CRS 표기·공인 측량 기준점 검증은 미완료입니다.',
'- 운곡교·옥성교·새재1교: 기존 프로그램 기본 EPSG:5186 가정을 사용했습니다. `crs_assumed=true`, `project_default_not_source_verified`를 유지했으며 지도 절대 위치 정확도를 확정하지 않습니다.',
'- 위성사진은 Esri, 2D 지도는 OpenStreetMap의 실제 내려받은 이미지입니다. JSON에 내장되며 서비스 출처를 보존합니다.',
'- 유효 지형 밖은 null입니다. 시추 자료를 추가 입력하지 않아 지층·지하수위는 새로 만들지 않았습니다.',
'- 운곡교는 기존 CA002·CA0021 표고점 및 CAA001·CAA002 등고선 읽기를 재사용했습니다. 일반 CAD 높이선이 섞인 최초 자동 후보는 최종 결과로 채택하지 않았습니다.',
'- 새재1교는 원본 XR_현황-수정$0$Point 레이어의 측량 POINT만 사용했습니다. 일반 CAD 높이선은 제외했고, 점 사이가 멀거나 범위 밖이면 null입니다.',
'- 두 레이어 선택 규약은 해당 원본의 SHA-256이 동일할 때만 배치 스크립트에서 적용합니다. 다른 종평면도에 일괄 적용하지 않습니다.',
'- 산외교는 이번 패키지에 포함된 전체 17교각을 사용했습니다. 기존 7교각 산외교 결과와 기존 운곡교 결과는 덮어쓰지 않았습니다.','',
'## 원본 자료 복구 및 한계','',
'대호지교는 D 드라이브의 원본 시공 BIM ZIP에서 종평면도 PDF를 복구했습니다. 원본 벡터의 실제 표고 마커와 종단 지반선을 판독했으며 PDF 도식 정밀도 한계가 있습니다. 목동교·계문철교·기양천교의 누락 XREF와 승천천교의 원본 지형 CAD도 원래 설계 자료에서 복구했습니다. 복구 경로와 SHA-256은 data/input/samples_20261102/recovered/source_manifest.json 및 jobs.json에 보존합니다. DEM이나 가상 지반고는 사용하지 않았습니다.',
'도하4교·곡천교는 원도면 교각 형상과 Excel IP 중점을 전체 교각에서 대조해 유일한 강체변환을 구했습니다. 감북교는 원도면과 Excel 간 원점 규약 차이로 추정 CRS를 사용했으며 공인 기준점 검증은 미완료입니다. 기타 기본 CRS 가정 역시 지도 절대 정확도를 의미하지 않습니다.','',
'상세 근거는 각 폴더의 `generation.log`, `result.json`, 실패 도면의 `terrain_diagnostics.json`에 있습니다.','',
'## 재실행','',
'프로젝트 폴더에서 `.venv/Scripts/python.exe -B build_sample_grounds.py`를 실행합니다. `--sample 8`로 한 샘플만 재실행할 수 있습니다. 외부 입력은 읽기만 하며 결과·변환 캐시는 이 폴더 하위에 저장합니다. 원본에 종평면도가 추가되면 재탐색합니다. 지도 새 취득에는 인터넷이 필요합니다.','']
(OUTPUT/'README.md').write_text('\n'.join(lines),encoding='utf-8')
# Scientific QA plot: measured terrain and both original georeferenced rasters.
os.environ['MPLCONFIGDIR']=str(OUTPUT/'cache/matplotlib')
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
plt.rcParams['font.family']='Malgun Gothic';plt.rcParams['axes.unicode_minus']=False
fig,axes=plt.subplots(len(generated),3,figsize=(15,4.7*len(generated)),layout='constrained')
for row,(result,data) in zip(axes,generated):
grid=data['terrain_grid']; z=np.array([np.nan if v is None else v for v in grid['z']]).reshape(grid['ny'],grid['nx'])
north=grid['origin'][0]+np.arange(grid['nx'])*grid['step'];east=grid['origin'][1]+np.arange(grid['ny'])*grid['step']
surface=row[0].pcolormesh(east,north,z.T,cmap='terrain',shading='auto');fig.colorbar(surface,ax=row[0],label='EL (m)')
row[0].scatter([p['Y'] for p in data['piers']],[p['X'] for p in data['piers']],c='#cb1943',s=14)
row[0].set(title=f"{result['sample']:02d} {result['bridge']} · 지반면",xlabel='E (m)',ylabel='N (m)',aspect='equal')
for ax,key in zip(row[1:],('satellite','ground')):
layer=next(layer for layer in data['visualization']['layers'] if layer['id']==key)
image=Image.open(io.BytesIO(base64.b64decode(layer['image'].split(',',1)[1])))
(south,west),(north_,east_)=layer['bounds']
ax.imshow(image,extent=[west,east_,south,north_],origin='upper',aspect=1/np.cos(np.deg2rad((south+north_)/2)))
features=[f for f in data['visualization']['features']['features'] if f['properties'].get('kind')=='pier']
ax.scatter([f['geometry']['coordinates'][0] for f in features],[f['geometry']['coordinates'][1] for f in features],c='#ed245c',s=12,edgecolors='white',linewidths=.4)
ax.set(title=f"{result['bridge']} · {'Esri 위성사진' if key=='satellite' else 'OpenStreetMap'}",xlabel='경도',ylabel='위도')
fig.savefig(OUTPUT/'generated_samples_preview.png',dpi=130);plt.close(fig)
print(f'Validated {len(generated)} mapped models; {12-len(generated)} blocked samples documented.')
if __name__=='__main__':main()