File size: 5,356 Bytes
a66cb4c
 
 
 
23e0ed2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a66cb4c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
"""Post-processing functions for segment predictions."""
import numpy as np


# ── Helpers ─────────────────────────────────────────────────────────────────

def _compact(pv, pe):
    """Remove unused vertices, remap edge indices."""
    if len(pe) == 0:
        return pv[:0], pe
    used, inv = np.unique(pe.reshape(-1), return_inverse=True)
    return pv[used], inv.reshape(-1, 2).astype(pe.dtype)


# ── New postprocessing (adapted from LundUni approach) ───────────────────────

def filter_short_edges(pv, pe, min_len=0.10):
    """Remove edges shorter than min_len metres (likely noise / merged artefacts)."""
    if len(pe) == 0:
        return pv, pe
    lens = np.linalg.norm(pv[pe[:, 1]] - pv[pe[:, 0]], axis=1)
    pe = pe[lens >= min_len]
    return _compact(pv, pe)


def remove_solitary_edges(pv, pe):
    """Remove edges where BOTH endpoints have degree == 1 (isolated noise edges)."""
    if len(pe) == 0:
        return pv, pe
    deg = np.bincount(pe.reshape(-1), minlength=len(pv))
    # keep edge if at least one endpoint is shared with another edge
    keep = np.any(deg[pe] > 1, axis=1)
    pe = pe[keep]
    return _compact(pv, pe)


def remove_near_duplicate_edges(pv, pe, dist_thresh=0.30):
    """Remove edges whose midpoint + direction are very close to a longer edge.

    Processes edges longest-first (like LundUni); removes shorter duplicates.
    """
    if len(pe) < 2:
        return pv, pe

    lengths = np.linalg.norm(pv[pe[:, 1]] - pv[pe[:, 0]], axis=1)
    order = np.argsort(-lengths)           # longest first
    pe_s = pe[order]
    lens_s = lengths[order]

    mids = 0.5 * (pv[pe_s[:, 0]] + pv[pe_s[:, 1]])
    dirs = pv[pe_s[:, 1]] - pv[pe_s[:, 0]]
    dirs = dirs / (lens_s[:, None] + 1e-8)

    keep = np.ones(len(pe_s), dtype=bool)
    for i in range(len(pe_s)):
        if not keep[i]:
            continue
        # Distance from later edges' midpoints to this edge's infinite line
        dp = mids[i + 1:] - mids[i]
        proj = (dp * dirs[i]).sum(axis=1, keepdims=True) * dirs[i]
        perp = np.linalg.norm(dp - proj, axis=1)
        # Also check directional similarity
        cos_sim = np.abs((dirs[i + 1:] * dirs[i]).sum(axis=1))
        suppress = (perp < dist_thresh) & (cos_sim > 0.90)
        keep[i + 1:][suppress] = False

    pe_out = pe_s[keep]
    # restore original index order (not strictly needed but cleaner)
    return _compact(pv, pe_out)


def reposition_to_line_intersections(pv, pe, max_move=0.50):
    """Move each vertex to the least-squares intersection of its incident lines.

    Replaces centroid-averaging with a proper line-intersection solve.
    Adapted from LundUni wireframe_postprocess.py concept.
    Only moves vertex if solution is within max_move metres of original position.
    """
    if len(pe) == 0:
        return pv
    new_pv = pv.copy()
    deg = np.bincount(pe.reshape(-1), minlength=len(pv))

    for vi in range(len(pv)):
        if deg[vi] < 2:
            continue
        # Collect incident edge directions
        mask = (pe[:, 0] == vi) | (pe[:, 1] == vi)
        inc = pe[mask]
        lines = []
        for a, b in inc:
            other = b if a == vi else a
            d = pv[other] - pv[vi]
            n = float(np.linalg.norm(d))
            if n < 1e-6:
                continue
            lines.append((pv[vi].copy(), d / n))

        if len(lines) < 2:
            continue

        # Build least-squares system: minimise sum ||(I - d d^T)(x - p)||^2
        # Normal equations: (sum (I - d d^T)) x = sum (I - d d^T) p
        A = np.zeros((3, 3), dtype=np.float64)
        b = np.zeros(3, dtype=np.float64)
        for p, d in lines:
            P = np.eye(3) - np.outer(d, d)
            A += P
            b += P @ p

        try:
            x, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
            if np.linalg.norm(x - pv[vi]) < max_move:
                new_pv[vi] = x.astype(np.float32)
        except Exception:
            pass

    return new_pv


def snap_to_point_cloud(vertices, xyz, class_id, snap_radius=0.5,
                         target_classes=None):
    """Snap vertices to nearby point cloud clusters of specific semantic classes."""
    if target_classes is None:
        target_classes = [1, 2]  # apex, eave_end_point

    snapped = vertices.copy()
    mask = np.isin(class_id, target_classes)

    if mask.sum() < 2:
        return snapped

    target_pts = xyz[mask]

    for i, v in enumerate(vertices):
        dists = np.linalg.norm(target_pts - v, axis=-1)
        close = dists < snap_radius
        if close.sum() >= 2:
            snapped[i] = target_pts[close].mean(axis=0)

    return snapped


def snap_horizontal(vertices, edges, max_slope=0.05):
    """Snap near-horizontal edges to be exactly horizontal."""
    verts = vertices.copy()
    for a, b in edges:
        a, b = int(a), int(b)
        dy = abs(verts[a, 1] - verts[b, 1])
        dxz = np.sqrt((verts[a, 0] - verts[b, 0])**2 + (verts[a, 2] - verts[b, 2])**2)
        if dxz > 0.1 and dy / dxz < max_slope:
            avg_y = 0.5 * (verts[a, 1] + verts[b, 1])
            verts[a, 1] = avg_y
            verts[b, 1] = avg_y
    return verts