File size: 5,874 Bytes
5c52fb9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88b423e
5c52fb9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dda24a3
 
 
 
5c52fb9
 
dda24a3
5c52fb9
 
 
 
 
 
 
 
dda24a3
5c52fb9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88b423e
5c52fb9
 
 
 
88b423e
 
 
 
5c52fb9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
157
158
159
160
161
162
"""Fusion of geometry-based and appearance-based segmentation.

RANSAC gives shadow-invariant plane geometry from the DSM.
C-RADIOv4-H gives appearance-based segmentation from RGB.
This module merges them: geometry is primary (ground truth for planes),
appearance refines boundaries and detects visual sub-features.
"""

import numpy as np
from scipy.ndimage import label, distance_transform_edt


def fuse_segmentations(
    ransac_labels: np.ndarray,
    radio_scores: np.ndarray,
    building_mask: np.ndarray,
    num_roof_classes: int = 6,
) -> np.ndarray:
    """Merge RANSAC plane labels with RADIO appearance scores.

    Strategy:
      1. RANSAC labels are the primary source (geometry = ground truth).
      2. Within each RANSAC plane region, check if RADIO shows strong
         internal class boundaries (different roof types within one plane).
      3. If a RANSAC plane contains pixels where RADIO strongly disagrees
         on class, consider sub-dividing — but only if the sub-region is
         large enough to be meaningful.

    Args:
        ransac_labels: (H, W) int32 from RANSAC. 0 = background.
        radio_scores: (H, W, C) float score map from zero_shot_segment.
        building_mask: (H, W) binary mask.
        num_roof_classes: Number of roof-type classes in radio_scores.

    Returns:
        Fused label map (H, W) int32.
    """
    fused = ransac_labels.copy()

    # For each RANSAC plane, check RADIO's opinion
    unique_planes = sorted(set(np.unique(ransac_labels)) - {0})
    next_id = max(unique_planes) + 1 if unique_planes else 1

    # Get RADIO's per-pixel roof class (only among roof classes)
    roof_scores = radio_scores[:, :, :num_roof_classes]
    radio_roof_class = np.argmax(roof_scores, axis=2)

    for plane_id in unique_planes:
        plane_mask = ransac_labels == plane_id
        plane_pixels = plane_mask.sum()

        if plane_pixels < 100:
            continue

        # What does RADIO think about this plane's pixels?
        classes_in_plane = radio_roof_class[plane_mask]
        unique_classes, counts = np.unique(classes_in_plane, return_counts=True)

        if len(unique_classes) <= 1:
            continue

        # Check if there's a significant minority class
        dominant_class = unique_classes[np.argmax(counts)]
        for cls, count in zip(unique_classes, counts):
            if cls == dominant_class:
                continue

            fraction = count / plane_pixels
            if fraction < 0.30:
                # Must represent >30% of the plane to justify a split.
                # The RADIO roof-type prompts are similar ("flat/pitched/hip/gable
                # roof plane"), so smaller fractions are noise, not real sub-features.
                continue

            # This class represents >30% of the plane — check spatial coherence
            sub_mask = plane_mask & (radio_roof_class == cls)
            labeled_sub, n_components = label(sub_mask)

            for comp_id in range(1, n_components + 1):
                comp_mask = labeled_sub == comp_id
                comp_size = comp_mask.sum()

                # Only split if the sub-region is spatially coherent and large enough
                if comp_size > plane_pixels * 0.20 and comp_size > 500:
                    fused[comp_mask] = next_id
                    next_id += 1

    return fused


def split_disconnected_regions(label_map: np.ndarray) -> np.ndarray:
    """Assign new IDs to spatially disconnected components of the same label.

    Handles the case where two parallel roof faces have the same pitch
    (RANSAC assigns them the same plane) but are physically separate.
    """
    result = np.zeros_like(label_map)
    next_id = 1

    for old_id in sorted(set(np.unique(label_map)) - {0}):
        mask = label_map == old_id
        labeled, n = label(mask)
        for comp in range(1, n + 1):
            result[labeled == comp] = next_id
            next_id += 1

    return result


def merge_small_fragments(
    label_map: np.ndarray,
    building_mask: np.ndarray,
    min_fraction: float = 0.02,
) -> np.ndarray:
    """Merge fragments smaller than min_fraction of building area.

    Small fragments get absorbed into their nearest neighboring plane.

    Default 0.02 (2%) preserves real hip caps and small wings on complex roofs.
    Higher values (0.05) merged legitimate small planes into their neighbors —
    see 8121 Golden Vista Way for a failure case at 0.05.
    """
    building_area = (building_mask > 0).sum()
    min_pixels = int(building_area * min_fraction)

    merged = label_map.copy()
    unique_labels = sorted(set(np.unique(merged)) - {0})

    for lbl in unique_labels:
        mask = merged == lbl
        if mask.sum() >= min_pixels:
            continue

        # Find nearest neighbor label
        dilated = mask.copy()
        for _ in range(5):
            kernel = np.ones((3, 3), dtype=np.uint8)
            dilated = np.maximum(
                dilated,
                (np.pad(dilated, 1, mode="constant")[:-2, 1:-1] |
                 np.pad(dilated, 1, mode="constant")[2:, 1:-1] |
                 np.pad(dilated, 1, mode="constant")[1:-1, :-2] |
                 np.pad(dilated, 1, mode="constant")[1:-1, 2:])
            )

        # Find labels in the dilated region that aren't the current one
        neighbor_labels = merged[dilated & ~mask & (merged > 0)]
        if len(neighbor_labels) == 0:
            continue

        # Merge into the most common neighbor
        vals, cnts = np.unique(neighbor_labels, return_counts=True)
        best_neighbor = vals[np.argmax(cnts)]
        merged[mask] = best_neighbor

    # Relabel sequentially
    result = np.zeros_like(merged)
    for new_id, old_id in enumerate(sorted(set(np.unique(merged)) - {0}), start=1):
        result[merged == old_id] = new_id

    return result