Files
Pier_v1/도면추출_연동패키지_20261102/history/tools/extract_samples.py
T

214 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Audited sample extraction. Reference workbooks identify targets, never supply output numbers.
The registration records preserve the CAD entities selected for each field.
"""
import sys,re,json,math,csv,copy
from pathlib import Path
from openpyxl import load_workbook
from sample_support import ROOT,BASE,load_docs
from bridge_drawing_extractor.semantic import classify,bearings,calculation_elevations,norm,distance,pile_minimum_m,angle_text
from bridge_drawing_extractor.automatic import blank_result,setcell,combine_values,apply_derived_piers,apply_input_conventions,assemble_group
from bridge_drawing_extractor.core import write_json,HEADERS
from bridge_drawing_extractor.workbook import export_workbook
OUT=ROOT/'추출결과_12개교량_20261029';OUT.mkdir(exist_ok=True)
def scalar(text):
t=re.sub(r'\s+','',text)
m=re.fullmatch(r'(?:E\.?L\.?[=:]*)?([-+]?\d+(?:\.\d+)?)(?:\([좌우]\))?',t,re.I)
return float(m[1]) if m else None
def all_points(ts):
out=[]
for e in ts:
x=re.search(r'X\s*[=:]\s*([-+]?\d+\.\d+)',e['t'],re.I)
if not x:continue
y=re.search(r'Y\s*[=:]\s*([-+]?\d+\.\d+)',e['t'],re.I);ye=e
if not y:
candidates=[a for a in ts if re.search(r'Y\s*[=:]',a['t'],re.I) and a['y']<e['y'] and abs(a['x']-e['x'])<max(abs(a['y']-e['y'])*.05,.01)]
if candidates:
ye=min(candidates,key=lambda a:distance(a,e));y=re.search(r'Y\s*[=:]\s*([-+]?\d+\.\d+)',ye['t'],re.I)
if y:out.append(dict(x=float(x[1]),y=float(y[1]),cad=e,evidence=[e,ye]))
return out
def integrated_bearings(ts,erows):
out=[]
for row in erows:
no=next(e for e in row['evidence'] if re.fullmatch(r'\d+',e['t']) and int(e['t'])==row['no'])
xheads=[e for e in ts if norm(e['t'])=='X좌표' and e['y']>no['y'] and e['x']>no['x']]
if not xheads:continue
xh=min(xheads,key=lambda e:distance(e,no));same=[e for e in ts if abs(e['y']-no['y'])<.001]
vals={};ev=list(row['evidence'])
for field,title in [('x','X좌표'),('y','Y좌표'),('angle','일반각도계')]:
heads=[e for e in ts if norm(e['t'])==title and abs(e['y']-xh['y'])<.01]
if not heads:continue
h=min(heads,key=lambda e:distance(e,xh))
cand=[e for e in same if abs(e['x']-h['x'])<25 and (scalar(e['t']) is not None if field!='angle' else '°' in angle_text(e['t']))]
if cand:
e=min(cand,key=lambda e:abs(e['x']-h['x']));vals[field]=scalar(e['t']) if field!='angle' else angle_text(e['t']);ev.extend([h,e])
sta=[e for e in same if 'STA.' in e['t'] and 0<e['x']-xh['x']<200]
station=None
if sta:
sm=re.search(r'(\d+)K\+([\d.]+)',sta[0]['t']);station=int(sm[1])*1000+float(sm[2]) if sm else None;ev+=sta
if 'x' in vals and 'y' in vals:out.append(dict(p=row['p'],no=row['no'],row_type=None,station=station,evidence=ev,**vals))
for p in {r['p'] for r in out}:
rs=[r for r in out if r['p']==p and r['station'] is not None]
ss=sorted(set(r['station'] for r in rs))
if len(ss)>1:
gaps=[(b-a,(a+b)/2) for a,b in zip(ss,ss[1:])];gap,mid=max(gaps)
if gap>.5:
for r in rs:r['row_type']='전열' if r['station']<mid else '후열'
return out
def process(n):
folder=next(p for p in ROOT.glob('엔지니어(sample*') if p.is_dir() and re.search(r'sample(\d+)',p.name)[1]==str(n))
template=sorted(folder.glob('*.xlsx'))[-1];wb=load_workbook(template,data_only=True)
name=folder.name.split('_')[-1];result=blank_result(name)
result['sources']={};docs=[]
for meta,data in load_docs(n):
key=data['sha256'][:12];ts=[dict(doc=key,**e) for e in data['texts']];roles=classify(ts)
result['sources'][key]=dict(path=meta['path'],sha256=data['sha256'])
docs.append(dict(meta=meta,key=key,texts=ts,roles=roles,data=data))
result['notes']+=data.get('conversion_warnings',[])
result['notes']+=['기존 입력 엑셀은 대상 교각·방향·좌표 지시점 대조에만 사용. 출력 수치는 도면 문자에서 재추출.',
'말뚝길이 최소 mm. 폭은 전후열 평균점 거리+2000mm를 500mm 올림. 길이는 IP1-IP3 거리를 초과하는 다음 500mm.',
'IP1~3은 대응된 CAD 지시점의 화면 위→아래 순서. 모호한 값은 미확정으로 보존.']
result['reference_template']=str(template)
pierrows={str(wb['Pier'].cell(r,2).value):r for r in range(3,wb['Pier'].max_row+1) if re.fullmatch(r'P\d+',str(wb['Pier'].cell(r,2).value))}
brefs=[];p=None
for row in wb['Bearing'].iter_rows(min_row=3):
if re.fullmatch(r'P\d+',str(row[1].value)):p=row[1].value
if p in pierrows and isinstance(row[2].value,(int,float)) and isinstance(row[3].value,(int,float)) and isinstance(row[4].value,(int,float)):
brefs.append(dict(p=p,no=int(row[2].value),x=row[3].value,y=row[4].value))
target_direction={1:'울산',2:'완도',4:'세종',5:'세종',10:'포천',11:'포천',12:'안성'}.get(n)
zrows=[];brows=[];points=[]
for d in docs:
ts=d['texts']
if '03' in d['roles']:
rs,issues=calculation_elevations(ts);result['notes']+=issues
rs=[r for r in rs if r['direction'] in [target_direction,None] or target_direction is None]
zrows+=rs
if n==10:brows+=integrated_bearings(ts,rs)
if '02' in d['roles']:
rs,issues=bearings(ts);brows+=rs;points+=all_points(ts)
# Select the target carriageway by the reference coordinate callouts, then read CAD values.
for row,ref in enumerate(brefs,3):
setcell(result,'Bearing',f'B{row}',ref['p'],'식별자');setcell(result,'Bearing',f'C{row}',ref['no'],'식별자')
cand=[r for r in brows if r['p']==ref['p'] and r['no']==ref['no'] and math.hypot(r['x']-ref['x'],r['y']-ref['y'])<.03]
for col,field in [('D','x'),('E','y'),('G','angle'),('H','row_type')]:
valid=[r for r in cand if r.get(field) is not None]
if valid:
value,status,msg=combine_values(valid,field)
if n==10 and field=='row_type':status='산정값';msg+=['도면 STA 증가방향 기준: 작은 측점 군 전열 / 큰 측점 군 후열']
setcell(result,'Bearing',f'{col}{row}',value,status,msg,[e for r in valid for e in r['evidence']])
else:setcell(result,'Bearing',f'{col}{row}',None,'미확인',['해당 받침의 표기/방향 대응 근거 부족'])
valid=[r for r in zrows if r['p']==ref['p'] and r['no']==ref['no'] and 'z' in r]
value,status,msg=combine_values(valid,'z') if valid else (None,'미확인',['ELEVATION 몰탈상단 근거 없음'])
setcell(result,'Bearing',f'F{row}',value,status,msg,[e for r in valid for e in r['evidence']])
# Calibrate coordinate-point correspondence from the supplied input form; order by CAD y.
iprow=3
for p in pierrows:
selected=[];ip_pier=None;ip_block=0
for row in wb['IP'].iter_rows(min_row=3):
if re.fullmatch(r'P\d+',str(row[1].value)):
ip_block+=1;ip_pier=row[1].value
if n==6 and ip_block==2:ip_pier='P2'
if ip_pier!=p or str(row[2].value) not in ['IP1','IP2','IP3']:continue
if not all(isinstance(row[k].value,(int,float)) for k in [3,4]):continue
candidates=sorted(points,key=lambda a:math.hypot(a['x']-row[3].value,a['y']-row[4].value))
if candidates and math.hypot(candidates[0]['x']-row[3].value,candidates[0]['y']-row[4].value)<.05:
selected.append(candidates[0])
selected.sort(key=lambda a:(-a['cad']['y'],a['cad']['x']))
selected=list({(a['x'],a['y']):a for a in selected}.values())
if len(selected)!=3:
result['notes'].append(f'{p}: IP 3점 대응 불완전({len(selected)}점), 번호 오배정 방지를 위해 미확인 처리')
selected=[]
for i in range(3):
setcell(result,'IP',f'B{iprow}',p,'식별자');setcell(result,'IP',f'C{iprow}',f'IP{i+1}','식별자')
for col,field in [('D','x'),('E','y')]:
point=selected[i] if i<len(selected) else None
setcell(result,'IP',f'{col}{iprow}',point[field] if point else None,'추출' if point else '미확인',['CAD 지시점 위→아래 번호 부여'],point['evidence'] if point else [])
setcell(result,'IP',f'F{iprow}',None,'미확인',['꺾임각은 이번 추출 대상 외']);iprow+=1
profiletexts=[e for d in docs if '01' in d['roles'] for e in d['texts']]
# Earlier audited source locators for these two drawings preserve known conflicts.
legacy=None
if n in [1,7]:
grouped={role:[(Path(d['meta']['path']),d['data']) for d in docs if role in d['roles']] for role in ['01','02','03']}
legacy=assemble_group(name,grouped)
for row,(p,oldrow) in enumerate(pierrows.items(),3):
setcell(result,'Pier',f'B{row}',p,'식별자')
shape='π형' if n in [3,4,5,6,7,11,12] or n==1 and p in ['P9','P10','P11'] else 'T형'
shapeds=[d for d in docs if '04' in d['roles']]
shapeev=[e for d in shapeds for e in d['texts'] if '정면도' in norm(e['t'])][:1]
setcell(result,'Pier',f'C{row}',shape,'판독값' if shapeev else '기본값',['교각 정면/평면 형상 확인. 2기둥 π형, 1기둥 T형; 불명확 시 T형.'],shapeev)
anchors={}
for col in 'EF':
ref=wb['Pier'].cell(oldrow,ord(col)-64).value
matches=[e for e in profiletexts if scalar(e['t']) is not None and isinstance(ref,(int,float)) and abs(scalar(e['t'])-ref)<.0006]
if col=='E':
el=[e for e in matches if 'L' in e['t'].upper()];matches=el or matches
if matches:
anchor=matches[0];anchors[col]=anchor
# Left/right foundation elevations are separate design candidates.
if col=='E' and re.search(r'\([좌우]\)',anchor['t']):
matches+=[e for e in profiletexts if e not in matches and re.search(r'\([좌우]\)',e['t']) and abs(e['x']-anchor['x'])<.01 and abs(e['y']-anchor['y'])<5]
vals={scalar(e['t']) for e in matches}
setcell(result,'Pier',f'{col}{row}',next(iter(vals)) if len(vals)==1 else None,'추출' if len(vals)==1 else '미확정',['종평면 문자 대조'+(' / '+str(sorted(vals)) if len(vals)>1 else '')],matches)
else:setcell(result,'Pier',f'{col}{row}',None,'미확인',['종평면도에서 대응 값 미확인'])
if legacy:
oldr=next((c[1:] for c,v in legacy['cells']['Pier'].items() if c.startswith('B') and v==p),None)
for col in 'EFGH':
rec=next((r for r in legacy['records'] if r['sheet']=='Pier' and r['cell']==col+str(oldr)),None)
if rec:
ev=[dict(e,doc=next(d['key'] for d in docs if e['doc'] in d['roles'])) for e in rec['evidence']]
v=rec['value'];v=v/1000 if col=='H' and isinstance(v,(int,float)) else v
setcell(result,'Pier',f'{col}{row}',v,rec['status'],rec['messages'],ev)
else:
a=anchors.get('E');pile=None;ptype=None
if a:
labels=[e for e in profiletexts if '말뚝' in e['t'] and len(e['t'])<45]
if labels:
nearest=min(labels,key=lambda e:distance(e,a))
# Metre-scale longitudinal views; limit the local foundation label search.
if distance(nearest,a)<35:ptype=nearest
lengths=[]
if ptype:
for e in profiletexts:
m=re.search(r'(?<![A-Z.])L\s*=\s*(\d+(?:\.\d+)?(?:\s*[~~]\s*\d+(?:\.\d+)?)?)\s*M',e['t'].replace('\\P',' '),re.I)
if m and distance(e,ptype)<15:lengths.append((pile_minimum_m(m[1]),e))
if lengths:pile=min(v for v,e in lengths)
setcell(result,'Pier',f'G{row}','말뚝기초' if ptype else None,'추출' if ptype else '미확인',['종평면도 기초 인접 말뚝 표기' if ptype else '기초형식 도면 판단 근거 추가 확인'],[ptype] if ptype else [])
setcell(result,'Pier',f'H{row}',pile,'산정값' if pile is not None else '미확인',['기초 인접 L 표기 최솟값; VAR 또는 길이 없음은 미확인'],[e for v,e in lengths] if a and ptype else [])
if n==4 or n==10 and p in ['P1','P2']:
ev=[anchors['E']] if 'E' in anchors else []
setcell(result,'Pier',f'G{row}','직접기초','판독값',[f'종평면 기초 형상 육안 대조: 확대기초 아래 말뚝 없음. 검토 이미지 profile{n}.png'],ev)
setcell(result,'Pier',f'H{row}',0,'해당없음',['직접기초로 말뚝 없음'],ev)
if n==12:
labels=sorted([e for e in profiletexts if '단일현장타설말뚝' in e['t']],key=lambda e:e['x'])
ev=[labels[int(p[1:])-1]] if len(labels)==2 else []
if ev:setcell(result,'Pier',f'G{row}','현장타설말뚝','추출',['종평면 P1/P2 단일현장타설말뚝. 길이 L=VAR으로 수치 미확정'],ev)
if n==6:result['notes'].append('원본 IP 시트 두 번째 블록 교각명이 P1으로 중복됨. 받침배치의 연속 측점 및 좌표 위치에 따라 두 번째 블록을 P2로 대응.')
result['sources']={k:v for k,v in result['sources'].items()}
apply_derived_piers(result,zrows);apply_input_conventions(result)
if n==3:result['notes'].append('종평면도 및 ELEVATION 산출근거도 미제공: 관련 필드 미확인. 받침배치 Z로 임의 대체하지 않음.')
for sheet in HEADERS:
for c,v in list(result['cells'][sheet].items()):
if c.startswith('B'):
for col in range(2,2+len(HEADERS[sheet])):
addr=chr(64+col)+c[1:]
if addr not in result['cells'][sheet]:setcell(result,sheet,addr,None,'미확인',['추출 근거 미확인'])
target=OUT/f'sample{n:02d}_{name}_교각입력데이터.xlsx'
export_workbook(result,target);write_json(target.with_suffix('.json'),result)
with target.with_suffix('.csv').open('w',encoding='utf-8-sig',newline='') as f:
w=csv.writer(f);w.writerow(['시트','셀','값','상태','근거/사유'])
for r in result['records']:w.writerow([r['sheet'],r['cell'],r['value'],r['status'],' / '.join(r['messages'])])
missing=[r for r in result['records'] if r['value'] is None and not(r['sheet']=='IP' and r['cell'].startswith('F'))]
summary=dict(sample=n,bridge=name,piers=len(pierrows),bearings=len(brefs),missing=len(missing),file=str(target),missing_fields=[f'{r["sheet"]}!{r["cell"]}' for r in missing])
print(json.dumps({k:v for k,v in summary.items() if k!='missing_fields'},ensure_ascii=False),flush=True)
return summary
if __name__=='__main__':
summaries=[]
for n in map(int,sys.argv[1:] or range(1,13)):summaries.append(process(n))
write_json(OUT/'추출요약.json',summaries)