spsheaf 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spsheaf/__init__.py +16 -0
- spsheaf/boundary_metrics.py +142 -0
- spsheaf/clustering.py +332 -0
- spsheaf/dimensionality.py +424 -0
- spsheaf/edge_scores.py +115 -0
- spsheaf/evaluation.py +609 -0
- spsheaf/fibers.py +326 -0
- spsheaf/graph.py +53 -0
- spsheaf/io.py +169 -0
- spsheaf/paths.py +105 -0
- spsheaf/pipeline.py +622 -0
- spsheaf/sheaf.py +345 -0
- spsheaf/viz/__init__.py +30 -0
- spsheaf/viz/benchmark.py +132 -0
- spsheaf/viz/boundaries.py +119 -0
- spsheaf/viz/dimensionality.py +424 -0
- spsheaf-0.2.0.dist-info/METADATA +202 -0
- spsheaf-0.2.0.dist-info/RECORD +21 -0
- spsheaf-0.2.0.dist-info/WHEEL +5 -0
- spsheaf-0.2.0.dist-info/licenses/LICENSE +21 -0
- spsheaf-0.2.0.dist-info/top_level.txt +1 -0
spsheaf/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""spSheaf: sheaf-theoretic spatial domain inference from raw molecules.
|
|
2
|
+
|
|
3
|
+
The method is eight function calls (see ``examples/quickstart.py``):
|
|
4
|
+
``fibers.build_kernel_fibers`` and ``fibers.normalize_fibers`` put
|
|
5
|
+
kernel-smoothed expression fibers on a grid; ``graph.build_spatial_graph``
|
|
6
|
+
links the grid; ``sheaf.compute_stalks`` fits a local principal subspace at
|
|
7
|
+
every vertex; ``sheaf.compute_restriction_maps`` aligns neighbouring stalks;
|
|
8
|
+
``sheaf.compute_boundary_scores`` scores every edge by the residual
|
|
9
|
+
disagreement of the aligned coordinates and smooths it into a field;
|
|
10
|
+
``clustering.boundary_aware_clustering`` partitions the grid with that field
|
|
11
|
+
attenuating the transitions; ``clustering.spatial_cleanup`` removes
|
|
12
|
+
fragments. ``pipeline.run_single_slice`` wraps the eight steps and the
|
|
13
|
+
evaluation against an annotation.
|
|
14
|
+
"""
|
|
15
|
+
__version__ = "0.2.0"
|
|
16
|
+
__all__ = ["fibers", "graph", "sheaf", "clustering", "evaluation", "pipeline", "io", "paths"]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Boundary-detection metrics against a ground-truth partition on a graph/grid.
|
|
2
|
+
|
|
3
|
+
Answers reviewer memo point 3: the manuscript is framed around a
|
|
4
|
+
boundary-detection principle but evaluates almost entirely through downstream
|
|
5
|
+
clustering ARI/NMI, which does not test that principle directly.
|
|
6
|
+
|
|
7
|
+
Ground-truth boundary points are grid vertices with at least one graph
|
|
8
|
+
neighbour in a different annotated domain. A candidate scalar field is then
|
|
9
|
+
scored as a soft boundary detector against that set, BSDS-style: a spatial
|
|
10
|
+
tolerance is allowed on both sides of the match, because a boundary predicted
|
|
11
|
+
one grid step away from the annotation is not a false positive in any
|
|
12
|
+
meaningful sense.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from scipy.spatial import cKDTree
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ground_truth_boundary(gt_int, edges, valid=None):
|
|
21
|
+
"""Vertices with a graph neighbour in a different domain."""
|
|
22
|
+
gt_int = np.asarray(gt_int)
|
|
23
|
+
n = len(gt_int)
|
|
24
|
+
if valid is None:
|
|
25
|
+
valid = np.ones(n, bool)
|
|
26
|
+
valid = np.asarray(valid, bool)
|
|
27
|
+
b = np.zeros(n, bool)
|
|
28
|
+
for u, v in edges:
|
|
29
|
+
if valid[u] and valid[v] and gt_int[u] != gt_int[v]:
|
|
30
|
+
b[u] = b[v] = True
|
|
31
|
+
return b
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _match(pred_xy, gt_xy, tol):
|
|
35
|
+
"""(precision_hits, recall_hits) under a symmetric distance tolerance."""
|
|
36
|
+
if len(pred_xy) == 0 or len(gt_xy) == 0:
|
|
37
|
+
return 0, 0
|
|
38
|
+
d_p = cKDTree(gt_xy).query(pred_xy)[0] # each prediction -> nearest GT
|
|
39
|
+
d_g = cKDTree(pred_xy).query(gt_xy)[0] # each GT -> nearest prediction
|
|
40
|
+
return int((d_p <= tol).sum()), int((d_g <= tol).sum())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def pr_curve(score, coords, gt_bnd, valid=None, tol=0.0, n_thresh=60):
|
|
44
|
+
"""Precision/recall of `score` as a boundary detector, at tolerance `tol`.
|
|
45
|
+
|
|
46
|
+
The sweep is over the *fraction of vertices selected*, not over score
|
|
47
|
+
quantiles. This matters: the candidate fields being compared (sheaf
|
|
48
|
+
disagreement, expression gradient, neighbour distance, principal angle)
|
|
49
|
+
have very differently shaped score distributions, and a fixed grid of
|
|
50
|
+
score quantiles would sample the precision-recall trade-off unevenly
|
|
51
|
+
between them -- penalising the more skewed field for its shape rather
|
|
52
|
+
than its accuracy.
|
|
53
|
+
"""
|
|
54
|
+
score = np.asarray(score, float)
|
|
55
|
+
coords = np.asarray(coords, float)
|
|
56
|
+
if valid is None:
|
|
57
|
+
valid = np.ones(len(score), bool)
|
|
58
|
+
valid = np.asarray(valid, bool)
|
|
59
|
+
|
|
60
|
+
s, xy, g = score[valid], coords[valid], np.asarray(gt_bnd, bool)[valid]
|
|
61
|
+
gt_xy = xy[g]
|
|
62
|
+
if g.sum() == 0:
|
|
63
|
+
raise ValueError("no ground-truth boundary vertices")
|
|
64
|
+
|
|
65
|
+
order = np.argsort(-s)
|
|
66
|
+
n = len(s)
|
|
67
|
+
fracs = np.unique(np.geomspace(max(1.0 / n, 1e-4), 1.0, n_thresh))
|
|
68
|
+
prec, rec = [], []
|
|
69
|
+
for f in fracs:
|
|
70
|
+
k = max(int(round(f * n)), 1)
|
|
71
|
+
sel = order[:k]
|
|
72
|
+
tp_p, tp_g = _match(xy[sel], gt_xy, tol)
|
|
73
|
+
prec.append(tp_p / k)
|
|
74
|
+
rec.append(tp_g / len(gt_xy))
|
|
75
|
+
return np.array(prec), np.array(rec)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def average_precision(prec, rec):
|
|
79
|
+
"""Average precision: sum of precision weighted by the recall increment.
|
|
80
|
+
|
|
81
|
+
Standard AP, not a trapezoid normalised by the observed recall range --
|
|
82
|
+
that normalisation degenerates for smooth fields, whose recall barely
|
|
83
|
+
varies across thresholds.
|
|
84
|
+
"""
|
|
85
|
+
o = np.argsort(rec)
|
|
86
|
+
r, p = rec[o], prec[o]
|
|
87
|
+
if len(r) == 0:
|
|
88
|
+
return float("nan")
|
|
89
|
+
r = np.concatenate([[0.0], r])
|
|
90
|
+
p = np.concatenate([[p[0]], p])
|
|
91
|
+
return float(np.sum(np.diff(r) * p[1:]))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def best_f1(prec, rec):
|
|
95
|
+
f = 2 * prec * rec / np.maximum(prec + rec, 1e-12)
|
|
96
|
+
i = int(np.argmax(f))
|
|
97
|
+
return float(f[i]), float(prec[i]), float(rec[i])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def boundary_distances(score, coords, gt_bnd, valid=None, frac=None):
|
|
101
|
+
"""Symmetric distances between the predicted and true boundary sets.
|
|
102
|
+
|
|
103
|
+
The predicted set is the top `frac` of vertices by score; `frac` defaults
|
|
104
|
+
to the true boundary prevalence, so every field is compared at matched
|
|
105
|
+
budget rather than at a threshold that happens to favour one of them.
|
|
106
|
+
"""
|
|
107
|
+
score = np.asarray(score, float)
|
|
108
|
+
coords = np.asarray(coords, float)
|
|
109
|
+
if valid is None:
|
|
110
|
+
valid = np.ones(len(score), bool)
|
|
111
|
+
valid = np.asarray(valid, bool)
|
|
112
|
+
s, xy, g = score[valid], coords[valid], np.asarray(gt_bnd, bool)[valid]
|
|
113
|
+
if frac is None:
|
|
114
|
+
frac = g.mean()
|
|
115
|
+
k = max(int(round(frac * len(s))), 1)
|
|
116
|
+
sel = np.zeros(len(s), bool)
|
|
117
|
+
sel[np.argsort(-s)[:k]] = True
|
|
118
|
+
|
|
119
|
+
gt_xy, pr_xy = xy[g], xy[sel]
|
|
120
|
+
d_p = cKDTree(gt_xy).query(pr_xy)[0]
|
|
121
|
+
d_g = cKDTree(pr_xy).query(gt_xy)[0]
|
|
122
|
+
return {
|
|
123
|
+
"mean_pred_to_gt": float(d_p.mean()),
|
|
124
|
+
"mean_gt_to_pred": float(d_g.mean()),
|
|
125
|
+
"hausdorff_95": float(max(np.percentile(d_p, 95), np.percentile(d_g, 95))),
|
|
126
|
+
"hausdorff": float(max(d_p.max(), d_g.max())),
|
|
127
|
+
"frac_used": float(frac),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def evaluate_field(score, coords, gt_bnd, valid=None, tols=(0.0, 15.0, 30.0)):
|
|
132
|
+
"""Full report for one candidate boundary field."""
|
|
133
|
+
out = {}
|
|
134
|
+
for tol in tols:
|
|
135
|
+
p, r = pr_curve(score, coords, gt_bnd, valid, tol=tol)
|
|
136
|
+
f1, pi, ri = best_f1(p, r)
|
|
137
|
+
out[f"AP@{tol:g}"] = average_precision(p, r)
|
|
138
|
+
out[f"F1@{tol:g}"] = f1
|
|
139
|
+
out[f"P@{tol:g}"] = pi
|
|
140
|
+
out[f"R@{tol:g}"] = ri
|
|
141
|
+
out.update(boundary_distances(score, coords, gt_bnd, valid))
|
|
142
|
+
return out
|
spsheaf/clustering.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Clustering methods for spatial domain inference.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
|
|
6
|
+
1. **Boundary-aware clustering** — The sheaf boundary field is used
|
|
7
|
+
to attenuate ambiguous boundary points via exponential weighting.
|
|
8
|
+
Supports an over-cluster-then-merge strategy to preserve small
|
|
9
|
+
domains (e.g. PVT) that standard k-means tends to absorb into
|
|
10
|
+
larger neighbors.
|
|
11
|
+
|
|
12
|
+
2. **Over-cluster-then-merge** — Run k-means with more clusters than
|
|
13
|
+
needed (e.g. 2×), then merge the most expression-similar clusters
|
|
14
|
+
via agglomerative clustering on their expression centroids. This
|
|
15
|
+
gives small but expression-distinct domains their own initial
|
|
16
|
+
cluster, which survives the merge because its centroid is far
|
|
17
|
+
from all others.
|
|
18
|
+
|
|
19
|
+
3. **Spatial connectivity cleanup** — After clustering, small
|
|
20
|
+
disconnected fragments are detected via BFS on the spatial graph
|
|
21
|
+
and reassigned to the label of their nearest large-component
|
|
22
|
+
neighbor.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
from collections import deque
|
|
27
|
+
from scipy.spatial import cKDTree
|
|
28
|
+
from sklearn.decomposition import PCA
|
|
29
|
+
from sklearn.preprocessing import StandardScaler
|
|
30
|
+
from sklearn.cluster import KMeans, AgglomerativeClustering
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def boundary_aware_clustering(fibers_norm, grid_coords, boundary_smooth,
|
|
34
|
+
edges, n_clusters=8, spatial_weight=0.5,
|
|
35
|
+
boundary_alpha=0.5, n_pca=10, n_init=20,
|
|
36
|
+
overcluster_factor=1.0,
|
|
37
|
+
random_state=42, verbose=True):
|
|
38
|
+
"""
|
|
39
|
+
Cluster with boundary-aware exponential weighting.
|
|
40
|
+
|
|
41
|
+
When ``overcluster_factor > 1``, k-means is run with
|
|
42
|
+
``n_clusters * overcluster_factor`` clusters, then fine clusters
|
|
43
|
+
are merged back to ``n_clusters`` using adjacency-constrained
|
|
44
|
+
agglomerative clustering on expression centroids. Only spatially
|
|
45
|
+
neighboring clusters can merge, so sub-clusters of the same domain
|
|
46
|
+
reunify first, while small expression-distinct domains survive.
|
|
47
|
+
|
|
48
|
+
Steps:
|
|
49
|
+
1. PCA embedding of normalized fibers
|
|
50
|
+
2. Exponential interior weight: w = exp(-α · b / median(b))
|
|
51
|
+
3. Weight expression features row-wise
|
|
52
|
+
4. Concatenate with scaled spatial coordinates
|
|
53
|
+
5. K-means with k_fine = n_clusters * overcluster_factor
|
|
54
|
+
6. Adjacency-constrained merge → n_clusters
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
fibers_norm : ndarray, shape (N, G)
|
|
59
|
+
Normalized expression fibers.
|
|
60
|
+
grid_coords : ndarray, shape (N, 2)
|
|
61
|
+
Spatial positions.
|
|
62
|
+
boundary_smooth : ndarray, shape (N,)
|
|
63
|
+
Smoothed boundary scores.
|
|
64
|
+
edges : list of (int, int)
|
|
65
|
+
Spatial graph edges (used for adjacency-constrained merge).
|
|
66
|
+
n_clusters : int
|
|
67
|
+
Final number of output domains.
|
|
68
|
+
spatial_weight : float
|
|
69
|
+
Weight for spatial coordinates in clustering input.
|
|
70
|
+
boundary_alpha : float
|
|
71
|
+
Exponential weighting strength. At median boundary score,
|
|
72
|
+
w ≈ 0.607; at twice median, w ≈ 0.368.
|
|
73
|
+
n_pca : int
|
|
74
|
+
Number of PCA components.
|
|
75
|
+
n_init : int
|
|
76
|
+
Number of k-means random initializations.
|
|
77
|
+
overcluster_factor : float
|
|
78
|
+
Over-clustering factor. Set to 1.0 to disable. Default 2.0
|
|
79
|
+
runs k-means with 2× clusters then merges back.
|
|
80
|
+
random_state : int
|
|
81
|
+
Random seed for reproducibility.
|
|
82
|
+
verbose : bool
|
|
83
|
+
Print cluster sizes.
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
labels : ndarray, shape (N,)
|
|
88
|
+
Predicted domain labels (0-indexed).
|
|
89
|
+
"""
|
|
90
|
+
# PCA embedding
|
|
91
|
+
pca = PCA(n_components=n_pca).fit(fibers_norm)
|
|
92
|
+
features = pca.transform(fibers_norm)
|
|
93
|
+
|
|
94
|
+
# Exponential boundary weighting
|
|
95
|
+
med_b = np.median(boundary_smooth) + 1e-10
|
|
96
|
+
interior_weight = np.exp(-boundary_alpha * boundary_smooth / med_b)
|
|
97
|
+
|
|
98
|
+
# Weight and scale
|
|
99
|
+
features_scaled = StandardScaler().fit_transform(features)
|
|
100
|
+
features_weighted = features_scaled * interior_weight[:, None]
|
|
101
|
+
|
|
102
|
+
spatial_scaled = StandardScaler().fit_transform(grid_coords)
|
|
103
|
+
cluster_input = np.hstack([
|
|
104
|
+
features_weighted,
|
|
105
|
+
spatial_scaled * spatial_weight,
|
|
106
|
+
])
|
|
107
|
+
|
|
108
|
+
# K-means (possibly over-clustered)
|
|
109
|
+
k_fine = int(round(n_clusters * max(overcluster_factor, 1.0)))
|
|
110
|
+
km = KMeans(
|
|
111
|
+
n_clusters=k_fine, n_init=n_init, random_state=random_state,
|
|
112
|
+
)
|
|
113
|
+
labels_fine = km.fit_predict(cluster_input)
|
|
114
|
+
|
|
115
|
+
# Merge if over-clustered
|
|
116
|
+
if k_fine > n_clusters:
|
|
117
|
+
labels = merge_fine_clusters(
|
|
118
|
+
labels_fine, features, edges, n_clusters, verbose=verbose,
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
labels = labels_fine
|
|
122
|
+
|
|
123
|
+
if verbose:
|
|
124
|
+
sizes = sorted(
|
|
125
|
+
[int((labels == c).sum()) for c in range(n_clusters)],
|
|
126
|
+
reverse=True,
|
|
127
|
+
)
|
|
128
|
+
print(f" Clusters ({n_clusters}): {sizes}")
|
|
129
|
+
|
|
130
|
+
return labels
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def merge_fine_clusters(labels_fine, features, edges, n_clusters,
|
|
134
|
+
verbose=True):
|
|
135
|
+
"""
|
|
136
|
+
Merge fine clusters down to n_clusters using adjacency-constrained
|
|
137
|
+
agglomerative clustering on expression centroids.
|
|
138
|
+
|
|
139
|
+
Only spatially neighboring clusters (sharing at least one edge in
|
|
140
|
+
the spatial graph) can be merged. This ensures that sub-clusters
|
|
141
|
+
of the same domain reunify first, while preventing merges between
|
|
142
|
+
expression-similar but spatially distant clusters.
|
|
143
|
+
|
|
144
|
+
Small expression-distinct domains (e.g. PVT) survive because their
|
|
145
|
+
centroids are far from their spatial neighbors.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
labels_fine : ndarray, shape (N,)
|
|
150
|
+
Fine cluster labels from over-clustered k-means.
|
|
151
|
+
features : ndarray, shape (N, d)
|
|
152
|
+
PCA expression features (unweighted — the raw expression
|
|
153
|
+
signal is used for merging decisions).
|
|
154
|
+
edges : list of (int, int)
|
|
155
|
+
Spatial graph edges.
|
|
156
|
+
n_clusters : int
|
|
157
|
+
Target number of merged clusters.
|
|
158
|
+
verbose : bool
|
|
159
|
+
Print merge summary.
|
|
160
|
+
|
|
161
|
+
Returns
|
|
162
|
+
-------
|
|
163
|
+
labels_merged : ndarray, shape (N,)
|
|
164
|
+
Merged cluster labels (0-indexed, contiguous).
|
|
165
|
+
"""
|
|
166
|
+
from scipy.sparse import lil_matrix
|
|
167
|
+
|
|
168
|
+
k_fine = len(set(labels_fine))
|
|
169
|
+
|
|
170
|
+
# Compute expression centroid for each fine cluster
|
|
171
|
+
centroids = np.zeros((k_fine, features.shape[1]))
|
|
172
|
+
for c in range(k_fine):
|
|
173
|
+
mask = labels_fine == c
|
|
174
|
+
if mask.sum() > 0:
|
|
175
|
+
centroids[c] = features[mask].mean(axis=0)
|
|
176
|
+
|
|
177
|
+
# Build cluster-level adjacency: two fine clusters are adjacent
|
|
178
|
+
# if any spatial edge connects a point in one to a point in the other
|
|
179
|
+
adjacency = lil_matrix((k_fine, k_fine), dtype=np.int8)
|
|
180
|
+
for u, v in edges:
|
|
181
|
+
lu, lv = labels_fine[u], labels_fine[v]
|
|
182
|
+
if lu != lv:
|
|
183
|
+
adjacency[lu, lv] = 1
|
|
184
|
+
adjacency[lv, lu] = 1
|
|
185
|
+
connectivity = adjacency.tocsr()
|
|
186
|
+
|
|
187
|
+
# Adjacency-constrained agglomerative clustering on centroids
|
|
188
|
+
agg = AgglomerativeClustering(
|
|
189
|
+
n_clusters=n_clusters, linkage="ward",
|
|
190
|
+
connectivity=connectivity,
|
|
191
|
+
)
|
|
192
|
+
merge_labels = agg.fit_predict(centroids)
|
|
193
|
+
|
|
194
|
+
# Map fine → merged
|
|
195
|
+
fine_to_merged = {c: merge_labels[c] for c in range(k_fine)}
|
|
196
|
+
labels_merged = np.array([fine_to_merged[l] for l in labels_fine])
|
|
197
|
+
|
|
198
|
+
if verbose:
|
|
199
|
+
print(
|
|
200
|
+
f" Merged {k_fine} fine clusters → {n_clusters} domains "
|
|
201
|
+
f"(adjacency-constrained)"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return labels_merged
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def spatial_cleanup(labels, edges, grid_coords=None,
|
|
208
|
+
min_fragment_frac=0.01, max_iterations=5, verbose=True):
|
|
209
|
+
"""
|
|
210
|
+
Remove small disconnected fragments from cluster assignments.
|
|
211
|
+
|
|
212
|
+
After k-means, some clusters may have spatially disconnected fragments
|
|
213
|
+
(small groups of points assigned to a cluster far from its main body).
|
|
214
|
+
This function finds connected components of each cluster on the spatial
|
|
215
|
+
graph and reassigns small fragments to the label of their nearest
|
|
216
|
+
large-component neighbor.
|
|
217
|
+
|
|
218
|
+
Parameters
|
|
219
|
+
----------
|
|
220
|
+
labels : ndarray, shape (N,)
|
|
221
|
+
Cluster assignments from k-means.
|
|
222
|
+
edges : list of (int, int)
|
|
223
|
+
Spatial graph edges.
|
|
224
|
+
grid_coords : ndarray, shape (N, 2) or None
|
|
225
|
+
Spatial positions (used as fallback for unresolved points).
|
|
226
|
+
min_fragment_frac : float
|
|
227
|
+
Components smaller than this fraction of total points are
|
|
228
|
+
considered fragments. Default: 1%.
|
|
229
|
+
max_iterations : int
|
|
230
|
+
Maximum number of cleanup passes.
|
|
231
|
+
verbose : bool
|
|
232
|
+
Print reassignment statistics.
|
|
233
|
+
|
|
234
|
+
Returns
|
|
235
|
+
-------
|
|
236
|
+
labels_clean : ndarray, shape (N,)
|
|
237
|
+
Cleaned cluster assignments.
|
|
238
|
+
n_reassigned : int
|
|
239
|
+
Total number of points reassigned.
|
|
240
|
+
"""
|
|
241
|
+
N = len(labels)
|
|
242
|
+
min_size = max(3, int(N * min_fragment_frac))
|
|
243
|
+
labels_clean = labels.copy()
|
|
244
|
+
|
|
245
|
+
# Build adjacency list from edges
|
|
246
|
+
adj = [[] for _ in range(N)]
|
|
247
|
+
for u, v in edges:
|
|
248
|
+
adj[u].append(v)
|
|
249
|
+
adj[v].append(u)
|
|
250
|
+
|
|
251
|
+
total_reassigned = 0
|
|
252
|
+
|
|
253
|
+
for iteration in range(max_iterations):
|
|
254
|
+
n_reassigned = 0
|
|
255
|
+
unique_labels = sorted(set(labels_clean))
|
|
256
|
+
|
|
257
|
+
# Find connected components for each cluster
|
|
258
|
+
fragment_points = []
|
|
259
|
+
|
|
260
|
+
for lab in unique_labels:
|
|
261
|
+
members = set(np.where(labels_clean == lab)[0])
|
|
262
|
+
if len(members) == 0:
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
# BFS to find connected components within this cluster
|
|
266
|
+
visited = set()
|
|
267
|
+
components = []
|
|
268
|
+
|
|
269
|
+
for start in members:
|
|
270
|
+
if start in visited:
|
|
271
|
+
continue
|
|
272
|
+
component = []
|
|
273
|
+
queue = deque([start])
|
|
274
|
+
visited.add(start)
|
|
275
|
+
while queue:
|
|
276
|
+
node = queue.popleft()
|
|
277
|
+
component.append(node)
|
|
278
|
+
for nb in adj[node]:
|
|
279
|
+
if nb not in visited and nb in members:
|
|
280
|
+
visited.add(nb)
|
|
281
|
+
queue.append(nb)
|
|
282
|
+
components.append(component)
|
|
283
|
+
|
|
284
|
+
if len(components) <= 1:
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
# Keep the largest component, mark others as fragments
|
|
288
|
+
components.sort(key=len, reverse=True)
|
|
289
|
+
for comp in components[1:]:
|
|
290
|
+
if len(comp) < min_size:
|
|
291
|
+
fragment_points.extend(comp)
|
|
292
|
+
|
|
293
|
+
if not fragment_points:
|
|
294
|
+
break
|
|
295
|
+
|
|
296
|
+
# Reassign fragment points to nearest non-fragment neighbor
|
|
297
|
+
fragment_set = set(fragment_points)
|
|
298
|
+
unresolved = []
|
|
299
|
+
|
|
300
|
+
for pt in fragment_points:
|
|
301
|
+
reassigned = False
|
|
302
|
+
for nb in adj[pt]:
|
|
303
|
+
if nb not in fragment_set:
|
|
304
|
+
labels_clean[pt] = labels_clean[nb]
|
|
305
|
+
n_reassigned += 1
|
|
306
|
+
reassigned = True
|
|
307
|
+
break
|
|
308
|
+
if not reassigned:
|
|
309
|
+
unresolved.append(pt)
|
|
310
|
+
|
|
311
|
+
# Fallback: nearest non-fragment point by spatial distance
|
|
312
|
+
if unresolved and grid_coords is not None:
|
|
313
|
+
non_frag = np.array([i for i in range(N) if i not in fragment_set])
|
|
314
|
+
if len(non_frag) > 0:
|
|
315
|
+
tree = cKDTree(grid_coords[non_frag])
|
|
316
|
+
for pt in unresolved:
|
|
317
|
+
_, nearest = tree.query(grid_coords[pt])
|
|
318
|
+
labels_clean[pt] = labels_clean[non_frag[nearest]]
|
|
319
|
+
n_reassigned += 1
|
|
320
|
+
|
|
321
|
+
total_reassigned += n_reassigned
|
|
322
|
+
|
|
323
|
+
if n_reassigned == 0:
|
|
324
|
+
break
|
|
325
|
+
|
|
326
|
+
if verbose and total_reassigned > 0:
|
|
327
|
+
print(
|
|
328
|
+
f" Cleanup: {total_reassigned} points reassigned "
|
|
329
|
+
f"({total_reassigned / N:.1%})"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return labels_clean, total_reassigned
|