fix(ground): 지형이 아닌 일정 EL 객체(기호·윤곽선)를 높이 정합성으로 제외

- reject_height_outliers: 반경 15 m(부족 시 45 m) 내 서로 다른 높이 값의 중앙값과 비교
- |편차| > 10 m 이고 주변 높이 범위 밖으로 5 m 초과 시에만 제외, 최악 높이 묶음부터 반복
- 1 mm 이내 높이는 같은 묶음(CAD 부동소수 오차), 제외 수는 rejected_height_outliers로 기록
- 운곡교: EL 5.624(306)·-112.719(16)만 제외, 격자 최저 -85.98→21.50 m, 8 m 초과 튐 88→1
- 산외: -22.21·127.4 제외, 봉우리 후보 87.35 유지

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-09 17:15:39 +09:00
1 parent d740406a8c
commit b2bebe47eb
2 files changed
+63 -3

No files matched your search

@@ -17,6 +17,45 @@ from pierzainer_ground.dtm import DTM
ROOT=Path(__file__).resolve().parent.parent
def reject_height_outliers(entities, radius=15.0, max_dz=10.0, min_levels=3, same=1e-3):
"""Drop height entities that disagree with the surrounding surveyed surface.
Drawings often keep plan symbols, hatching or design outlines at a stray
constant elevation (e.g. EL 5.624 or -112.719 among 20-40 m contours).
Each entity's height is compared with the median of the *distinct* nearby
height levels (each level votes once, so a dense stray group of short
ticks cannot outvote real contours); the worst height level is removed
first and the test is repeated, so a large stray group cannot mask itself.
Sparse areas fall back to a 3x search radius.
Heights within `same` (1 mm) form one level, absorbing CAD float noise.
Returns (keep flags, [(index, z, deviation)]).
"""
from scipy.spatial import cKDTree
n=len(entities)
keep=np.ones(n,bool)
if n<3:return keep.tolist(),[]
xy=np.vstack([e[0] for e in entities]);owner=np.concatenate([np.full(len(e[0]),i) for i,e in enumerate(entities)])
zs=np.array([float(e[1]) for e in entities]);sample_z=zs[owner]
tree=cKDTree(xy);rejected=[]
near=lambda e,r:np.unique(np.concatenate(tree.query_ball_point(e[0],r)) if len(e[0]) else np.array([],int)).astype(int)
neighbours=[near(e,radius) for e in entities];wide=[near(e,3*radius) for e in entities]
for _ in range(64):
alive=keep[owner];dev=np.zeros(n)
for i in np.flatnonzero(keep):
nb=neighbours[i];nb=nb[alive[nb]&(np.abs(sample_z[nb]-zs[i])>same)]
if len(np.unique(np.round(sample_z[nb]/same)))<min_levels:nb=wide[i];nb=nb[alive[nb]&(np.abs(sample_z[nb]-zs[i])>same)]
levels=np.unique(np.round(sample_z[nb]/same))*same
# Peaks/pits legitimately exceed the median; require the height to
# lie clearly outside the whole local range as well.
outside=max(levels[0]-zs[i],zs[i]-levels[-1]) if len(levels) else 0.0
if len(levels)>=min_levels and outside>max_dz/2:dev[i]=zs[i]-float(np.median(levels))
worst=int(np.argmax(np.abs(dev)))
if abs(dev[worst])<=max_dz:break
group=np.flatnonzero(keep&(np.abs(zs-zs[worst])<=same)&(np.abs(dev)>max_dz))
keep[group]=False;rejected.extend((int(i),float(zs[i]),float(dev[i])) for i in group)
return keep.tolist(),rejected
def read_survey_block(source, project, doc=None):
"""Read actual height geometry in an explicitly selected survey block."""
if doc is None:
@@ -54,9 +93,11 @@ def read_survey_block(source, project, doc=None):
if e.is_closed: xy.append(xy[0])
contours.append((xy,float(a[0,2]),e.dxf.handle)); types['constant_height_polylines']+=1
# POINT entities have priority over densified contour vertices.
for xy,z,handle in contours:
samples=_densify(xy,project['contour_step_m'])
points.extend((x,y,z) for x,y in samples)
sampled=[(np.asarray(_densify(xy,project['contour_step_m']),dtype=float).reshape(-1,2),z) for xy,z,handle in contours]
sampled=[(np.array([[x,y]]),z) for x,y,z in points]+sampled
keep,rejected=reject_height_outliers(sampled,project.get('outlier_radius_m',15.0),project.get('outlier_dz_m',10.0)) if project.get('reject_outliers',True) else ([True]*len(sampled),[])
if rejected:types['rejected_height_outliers']=len(rejected)
points=[(x,y,z) for (xy,z),ok in zip(sampled,keep) if ok for x,y in xy]
a=np.array(points,dtype=float)
if len(a)<3:
raise ValueError('선택한 블록에서 실제 표고점·등고선을 3점 이상 읽지 못했습니다.')
@@ -25,4 +25,23 @@ class SurveyPolylineTests(unittest.TestCase):
doc=ezdxf.new();doc.modelspace().add_polyline3d([(1,1,20),(2,2,30),(3,3,40)])
with self.assertRaises(ValueError):read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
def contours(self,msp,levels):
for k,z in enumerate(levels):
msp.add_lwpolyline([(0,10*k),(200,10*k)],dxfattribs={'elevation':z})
def test_stray_constant_height_symbols_are_rejected(self):
# Ungok case: dense short ticks at EL 5.624 and a few at -112.719 among 20-40 m contours.
doc=ezdxf.new();msp=doc.modelspace();self.contours(msp,[28,29,30,31,32,33])
for i in range(60):msp.add_lwpolyline([(40+2*i,20),(40+2*i,22)],dxfattribs={'elevation':5.624})
for i in range(4):msp.add_lwpolyline([(100+i,25),(100+i,27)],dxfattribs={'elevation':-112.719})
points,counts,_=read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
self.assertEqual(counts['rejected_height_outliers'],64)
self.assertGreaterEqual(points[:,2].min(),28)
def test_real_peak_above_contours_is_kept(self):
doc=ezdxf.new();msp=doc.modelspace();self.contours(msp,[70,72,74,76,78,80])
msp.add_lwpolyline([(100,25),(100,27)],dxfattribs={'elevation':84})
points,counts,_=read_survey_block(Path('unused.dxf'),self.options(),doc=doc)
self.assertNotIn('rejected_height_outliers',counts);self.assertEqual(points[:,2].max(),84)
if __name__=='__main__':unittest.main()