214 lines
15 KiB
Python
214 lines
15 KiB
Python
"""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)
|
||
|