MEGAINFRA-WEBAPP/scripts/site-model/extract_munyaka_village.py
DEls 8e1cbc8b75 feat(site-map): Munyaka 2 stormwater and village network, Progress section highlight, layer switches
Munyaka model: 27 stormwater structures and 26 pipes from the issued structure table
(MH/KI/outlet by CAD block), water node points, and the information-stage village network
(services, ponds, boundary, lots, hydrants) aligned from the SDP Rev C PDF.

Map 3: selected Progress section is outlined and its row highlighted; Show-on-map gains
ground surface, contours, kerbs, markings, toes, reserve, signs, studs and HOLD switches;
the ground fades when underground services are ticked; pipes and manholes are chunkier.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:13:38 +02:00

136 lines
5.9 KiB
Python

"""Village network of Munyaka (C3175-VIL-SDP-001 Rev C, issued for information) as plan linework.
The SDP is a vector PDF at 1:1000 with no coordinates, so it is aligned to the CAD model by
matching its manhole symbols to the issued SR1-SR12 sewer and SW stormwater structures.
Result is plan position only: no levels, sizes or quantities are read from it.
"""
import itertools
import math
import warnings
import numpy as np
from shapely.geometry import LineString
SCALE = 1000*0.0254/72 # metres per PDF point at 1:1000
# Legend colours of the SDP (see its LEGEND block)
ROUTES = {'stormwater': ((0.0, 0.72, 0.0), 1.08), 'sewer': ((1.0, 0.0, 0.0), 1.68), 'water': ((0.0, 0.25, 1.0), 1.08)}
POND = ((0.0, 1.0, 0.5), 0.72)
BOUNDARY = ((0.0, 0.0, 0.0), 0.72)
LAYOUT = ((0.0, 0.0, 0.0), 0.24)
def _open(pdf_path):
import pymupdf
warnings.filterwarnings('ignore')
page = pymupdf.open(str(pdf_path))[0]
return pymupdf, page, page.rotation_matrix
def _symbols(page, rot, colour, lo, hi):
out = []
for it in page.get_drawings():
r = it['rect']
if (it['fill'] or it['color']) is None or not lo <= round(r.width, 1) <= hi or r.height >= 8:
continue
c = tuple(round(x, 2) for x in (it['fill'] or it['color']))
if c == colour:
q = (r.tl + r.br)*0.5*rot
out.append((q.x*SCALE, -q.y*SCALE))
return np.array(out)
def _rigid(P, Q):
pc, qc = P.mean(0), Q.mean(0)
U, _, Vt = np.linalg.svd((P-pc).T@(Q-qc))
R = Vt.T@U.T
if np.linalg.det(R) < 0:
Vt[-1] *= -1
R = Vt.T@U.T
return R, qc-pc@R.T
def align(page, rot, sewer_mh, storm):
"""Rigid transform (no scale, no mirroring) from PDF metres to local model metres."""
P = _symbols(page, rot, (1.0, 0.0, 0.0), 2.3, 3.4)
Pw = _symbols(page, rot, (0.0, 0.72, 0.0), 2.8, 3.4)
Q = np.array(sewer_mh)
best = (0,)
dP = np.linalg.norm(P[:, None]-P[None], axis=2)
for i, j in itertools.combinations(range(len(Q)), 2):
d = np.linalg.norm(Q[i]-Q[j])
for a, b in zip(*np.where(np.abs(dP-d) < .6)):
vp, vq = P[b]-P[a], Q[j]-Q[i]
th = math.atan2(vq[1], vq[0])-math.atan2(vp[1], vp[0])
R = np.array([[math.cos(th), -math.sin(th)], [math.sin(th), math.cos(th)]])
T = Q[i]-P[a]@R.T
hits = (np.linalg.norm((P@R.T+T)[:, None]-Q[None], axis=2).min(0) < 1.5).sum()
if hits > best[0]:
best = (hits, R, T)
if best[0] < 8:
raise ValueError(f'Village PDF alignment failed: only {best[0]} sewer manholes matched')
_, R, T = best
both = np.vstack([Q, np.array(storm)])
for _ in range(6):
pairs = []
for cand, targets in ((P, Q), (Pw, np.array(storm))):
X = cand@R.T+T
for q in targets:
d = np.linalg.norm(X-q, axis=1)
if d.min() < 3:
pairs.append((cand[d.argmin()], q))
R, T = _rigid(np.array([a for a, _ in pairs]), np.array([b for _, b in pairs]))
res = np.linalg.norm(np.array([a for a, _ in pairs])@R.T+T-np.array([b for _, b in pairs]), axis=1)
if len(pairs) < 15 or np.median(res) > .6:
raise ValueError(f'Village PDF alignment is weak: {len(pairs)} pairs, median residual {np.median(res):.2f} m')
return R, T, {'matchedPoints': len(pairs), 'medianResidualM': round(float(np.median(res)), 3), 'maxResidualM': round(float(res.max()), 3),
'rotationDeg': round(math.degrees(math.atan2(R[1, 0], R[0, 0])), 2), 'of': len(both)}
def village_model(pdf_path, sewer_mh, storm, simplify=.25):
pymupdf, page, rot = _open(pdf_path)
R, T, fit = align(page, rot, sewer_mh, storm)
tf = lambda q: (np.array([(q*rot).x*SCALE, -(q*rot).y*SCALE])@R.T+T)
def points(it):
out = []
for seg in it['items']:
if seg[0] == 'l':
out += [seg[1], seg[2]]
elif seg[0] == 'c':
a, b, c, d = (np.array(v) for v in seg[1:5])
out += [pymupdf.Point(*(((1-t)**3)*a+3*((1-t)**2)*t*b+3*(1-t)*t*t*c+t**3*d)) for t in np.linspace(0, 1, 6)]
return out
buckets = {k: [] for k in ('stormwater', 'sewer', 'water', 'pond', 'boundary', 'layout')}
styles = {**{k: v for k, v in ROUTES.items()}, 'pond': POND, 'boundary': BOUNDARY, 'layout': LAYOUT}
for it in page.get_drawings():
if it['type'] != 's' or not it['color'] or (it['rect']*rot).x0 > 2000: # right-hand strip is the title block/legend
continue
key = (tuple(round(c, 2) for c in it['color']), round(it['width'] or 0, 2))
for name, style in styles.items():
if key == style:
pts = points(it)
if len(pts) >= 2:
line = LineString([tf(q) for q in pts])
if name == 'layout' and line.length < 10:
continue
if name == 'pond' and line.length < 25:
continue
buckets[name].append(line)
def rows(lines):
out = []
for ln in lines:
s = ln.simplify(simplify) if simplify else ln
if s.length > .5:
out.append({'pts': [[round(x, 2), round(y, 2)] for x, y in s.coords], 'length': round(s.length, 2)})
return out
routes = [{**r, 'service': svc} for svc in ('stormwater', 'sewer', 'water') for r in rows(buckets[svc])]
hydrants = []
for w in page.get_text('words'):
if w[4] == 'FH':
q = tf(pymupdf.Point((w[0]+w[2])/2, (w[1]+w[3])/2))
hydrants.append([round(float(q[0]), 2), round(float(q[1]), 2)])
return {'source': 'C3175-VIL-SDP-001 Rev C (22 Oct 2026), issued for information. Plan position only, aligned to the CAD model; no levels, sizes or quantities.',
'alignment': fit, 'routes': routes, 'ponds': rows(buckets['pond']), 'boundary': rows(buckets['boundary']),
'layout': rows(buckets['layout']), 'hydrants': hydrants}