point-affinity-transformer 0.1.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.
- pat/__init__.py +30 -0
- pat/__main__.py +3 -0
- pat/cli.py +85 -0
- pat/cluster.py +97 -0
- pat/inference.py +212 -0
- pat/io.py +49 -0
- pat/metrics.py +41 -0
- pat/model.py +311 -0
- pat/preprocess.py +68 -0
- pat/presets.py +25 -0
- pat/pretrained.py +117 -0
- pat/train/__init__.py +19 -0
- pat/train/augment.py +36 -0
- pat/train/checkpoint.py +28 -0
- pat/train/config.py +117 -0
- pat/train/data.py +255 -0
- pat/train/evaluate.py +78 -0
- pat/train/loop.py +174 -0
- point_affinity_transformer-0.1.0.dist-info/METADATA +132 -0
- point_affinity_transformer-0.1.0.dist-info/RECORD +23 -0
- point_affinity_transformer-0.1.0.dist-info/WHEEL +4 -0
- point_affinity_transformer-0.1.0.dist-info/entry_points.txt +2 -0
- point_affinity_transformer-0.1.0.dist-info/licenses/LICENSE +21 -0
pat/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Point Affinity Transformer: global neuron shape reasoning on point clouds."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from . import cluster, metrics
|
|
6
|
+
from .inference import EncodedCloud, Segmentation, encode, segment
|
|
7
|
+
from .model import PATConfig, PointAffinityTransformer, expand_features
|
|
8
|
+
from .presets import PRESETS, create_model
|
|
9
|
+
from .pretrained import load_pretrained, save_pretrained
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("point-affinity-transformer") # defined in pyproject.toml
|
|
13
|
+
except PackageNotFoundError: # running from a source tree without installing
|
|
14
|
+
__version__ = "unknown"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"PATConfig",
|
|
18
|
+
"PointAffinityTransformer",
|
|
19
|
+
"EncodedCloud",
|
|
20
|
+
"Segmentation",
|
|
21
|
+
"encode",
|
|
22
|
+
"segment",
|
|
23
|
+
"create_model",
|
|
24
|
+
"PRESETS",
|
|
25
|
+
"load_pretrained",
|
|
26
|
+
"save_pretrained",
|
|
27
|
+
"expand_features",
|
|
28
|
+
"cluster",
|
|
29
|
+
"metrics",
|
|
30
|
+
]
|
pat/__main__.py
ADDED
pat/cli.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Command line interface: ``pat train | eval | convert``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _train(args):
|
|
9
|
+
from .train import load_config, train
|
|
10
|
+
|
|
11
|
+
train(load_config(args.config, args.set))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _eval(args):
|
|
15
|
+
import json
|
|
16
|
+
|
|
17
|
+
import torch
|
|
18
|
+
|
|
19
|
+
from .pretrained import load_pretrained
|
|
20
|
+
from .train import MergedNeuronDataset, SkeletonStore, TensorDirDataset, evaluate, format_results, load_config
|
|
21
|
+
from .train.data import read_root_ids
|
|
22
|
+
|
|
23
|
+
cfg = load_config(args.config, args.set)
|
|
24
|
+
torch.manual_seed(cfg.seed)
|
|
25
|
+
model = load_pretrained(args.model, device=args.device or cfg.device)
|
|
26
|
+
if args.items:
|
|
27
|
+
dataset = TensorDirDataset(args.items)
|
|
28
|
+
else:
|
|
29
|
+
store = SkeletonStore(cfg.data.swc_dir, cfg.data.units, cfg.data.dust_size, cfg.data.cache_dir,
|
|
30
|
+
features=cfg.data.features)
|
|
31
|
+
ids = read_root_ids(cfg.data.eval_neuron_ids or cfg.data.neuron_ids)
|
|
32
|
+
dataset = MergedNeuronDataset(store, ids, cfg.data, train=False)
|
|
33
|
+
thresholds = [float(t) for t in args.thresholds.split(",")]
|
|
34
|
+
results = evaluate(model, dataset, thresholds, min_cluster_size=args.min_cluster_size,
|
|
35
|
+
symmetrize=args.symmetrize, limit=args.limit)
|
|
36
|
+
print(format_results(results))
|
|
37
|
+
if args.out:
|
|
38
|
+
with open(args.out, "w") as f:
|
|
39
|
+
json.dump(results, f, indent=2)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _convert(args):
|
|
43
|
+
import torch
|
|
44
|
+
|
|
45
|
+
from .pretrained import load_legacy_checkpoint, save_pretrained
|
|
46
|
+
|
|
47
|
+
model = load_legacy_checkpoint(args.checkpoint, preset=args.preset)
|
|
48
|
+
print(save_pretrained(model, args.out, dtype=torch.float16 if args.fp16 else None))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv=None):
|
|
52
|
+
parser = argparse.ArgumentParser(prog="pat", description="Point Affinity Transformer")
|
|
53
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
54
|
+
|
|
55
|
+
p = sub.add_parser("train", help="train or fine-tune a model")
|
|
56
|
+
p.add_argument("config", help="YAML training config")
|
|
57
|
+
p.add_argument("--set", nargs="*", default=[], metavar="KEY=VALUE", help="override config entries")
|
|
58
|
+
p.set_defaults(func=_train)
|
|
59
|
+
|
|
60
|
+
p = sub.add_parser("eval", help="evaluate segmentation on merged clouds")
|
|
61
|
+
p.add_argument("config", nargs="?", help="YAML config describing the data (not needed with --items)")
|
|
62
|
+
p.add_argument("--model", required=True, help="model directory (from pat train or save_pretrained)")
|
|
63
|
+
p.add_argument("--items", help="directory of pre-generated items (pc_*.pt, labels_*.pt, mask_*.pt)")
|
|
64
|
+
p.add_argument("--thresholds", default="0.8")
|
|
65
|
+
p.add_argument("--min-cluster-size", type=int, default=30)
|
|
66
|
+
p.add_argument("--symmetrize", action="store_true", help="symmetrize affinities before clustering")
|
|
67
|
+
p.add_argument("--limit", type=int, help="evaluate only the first N items")
|
|
68
|
+
p.add_argument("--device")
|
|
69
|
+
p.add_argument("--out", help="write results as JSON")
|
|
70
|
+
p.add_argument("--set", nargs="*", default=[], metavar="KEY=VALUE")
|
|
71
|
+
p.set_defaults(func=_eval)
|
|
72
|
+
|
|
73
|
+
p = sub.add_parser("convert", help="convert a research .pth checkpoint into a model directory")
|
|
74
|
+
p.add_argument("checkpoint")
|
|
75
|
+
p.add_argument("out")
|
|
76
|
+
p.add_argument("--preset", help="model preset; overrides the one stored in the checkpoint")
|
|
77
|
+
p.add_argument("--fp16", action="store_true", help="store weights in float16")
|
|
78
|
+
p.set_defaults(func=_convert)
|
|
79
|
+
|
|
80
|
+
args = parser.parse_args(argv)
|
|
81
|
+
args.func(args)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|
pat/cluster.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Clustering of affinity matrices into neuron labels.
|
|
2
|
+
|
|
3
|
+
The label ``-1`` marks noise: DBSCAN outliers and, after
|
|
4
|
+
[`remove_small_clusters`][pat.cluster.remove_small_clusters], points of clusters
|
|
5
|
+
smaller than ``min_cluster_size``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import torch
|
|
12
|
+
from sklearn import cluster as _skcluster
|
|
13
|
+
|
|
14
|
+
NOISE = -1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def to_numpy(x) -> np.ndarray:
|
|
18
|
+
"""Convert a tensor or array-like to a NumPy array."""
|
|
19
|
+
return x.detach().cpu().numpy() if torch.is_tensor(x) else np.asarray(x)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def num_clusters(labels) -> int:
|
|
23
|
+
"""Number of clusters in ``labels`` numbered ``0..K-1``, ignoring noise (``-1``)."""
|
|
24
|
+
labels = torch.as_tensor(labels)
|
|
25
|
+
return int(labels.max().item()) + 1 if (labels >= 0).any() else 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def agglomerative(distance, threshold: float = 0.8, linkage: str = "average") -> torch.Tensor:
|
|
29
|
+
"""Agglomerative clustering of a precomputed ``(N, N)`` distance matrix.
|
|
30
|
+
|
|
31
|
+
Clusters are merged while their linkage distance is below ``threshold``.
|
|
32
|
+
"""
|
|
33
|
+
d = to_numpy(distance)
|
|
34
|
+
model = _skcluster.AgglomerativeClustering(
|
|
35
|
+
n_clusters=None, metric="precomputed", distance_threshold=threshold, linkage=linkage
|
|
36
|
+
)
|
|
37
|
+
return torch.as_tensor(model.fit_predict(d), dtype=torch.int64)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def dbscan(distance, eps: float = 0.2, min_samples: int = 5) -> torch.Tensor:
|
|
41
|
+
"""DBSCAN on a precomputed distance matrix. Noise points get label ``-1``."""
|
|
42
|
+
d = to_numpy(distance)
|
|
43
|
+
labels = _skcluster.DBSCAN(eps=eps, min_samples=min_samples, metric="precomputed").fit_predict(d)
|
|
44
|
+
return torch.as_tensor(labels, dtype=torch.int64)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def remove_small_clusters(labels: torch.Tensor, min_size: int) -> torch.Tensor:
|
|
48
|
+
"""Mark clusters with fewer than ``min_size`` points as noise and relabel.
|
|
49
|
+
|
|
50
|
+
The remaining clusters are numbered ``0..K-1``, largest first. Existing
|
|
51
|
+
``-1`` labels stay noise.
|
|
52
|
+
"""
|
|
53
|
+
labels = torch.as_tensor(labels, dtype=torch.int64)
|
|
54
|
+
out = torch.full_like(labels, NOISE)
|
|
55
|
+
valid = labels >= 0
|
|
56
|
+
if not valid.any():
|
|
57
|
+
return out
|
|
58
|
+
ids, inverse, counts = torch.unique(labels[valid], return_inverse=True, return_counts=True)
|
|
59
|
+
keep = counts >= min_size
|
|
60
|
+
order = torch.argsort(counts.masked_fill(~keep, -1), descending=True, stable=True)
|
|
61
|
+
new_id = torch.full_like(ids, NOISE)
|
|
62
|
+
new_id[order[: int(keep.sum())]] = torch.arange(int(keep.sum()))
|
|
63
|
+
out[valid] = new_id[inverse]
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cluster_affinity(
|
|
68
|
+
affinity,
|
|
69
|
+
threshold: float = 0.8,
|
|
70
|
+
min_cluster_size: int = 30,
|
|
71
|
+
method: str = "agglomerative",
|
|
72
|
+
symmetrize: bool = True,
|
|
73
|
+
) -> torch.Tensor:
|
|
74
|
+
"""Turn an ``(N, N)`` affinity matrix into one label per point.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
affinity: Pairwise same-neuron probabilities.
|
|
78
|
+
threshold: Distance threshold on ``1 - affinity``. For average linkage,
|
|
79
|
+
two groups merge while their mean affinity exceeds ``1 - threshold``.
|
|
80
|
+
For DBSCAN this is ``eps``.
|
|
81
|
+
min_cluster_size: Smaller clusters become noise (``-1``). Use ``0`` to keep all.
|
|
82
|
+
method: ``"agglomerative"`` or ``"dbscan"``.
|
|
83
|
+
symmetrize: Average ``A`` and ``A.T`` first. The model is not exactly
|
|
84
|
+
symmetric in its two inputs; the paper evaluation used ``False``.
|
|
85
|
+
"""
|
|
86
|
+
a = to_numpy(affinity).astype(np.float32)
|
|
87
|
+
if symmetrize:
|
|
88
|
+
a = 0.5 * (a + a.T)
|
|
89
|
+
dist = np.clip(1.0 - a, 0.0, None)
|
|
90
|
+
np.fill_diagonal(dist, 0.0)
|
|
91
|
+
if method == "agglomerative":
|
|
92
|
+
labels = agglomerative(dist, threshold=threshold)
|
|
93
|
+
elif method == "dbscan":
|
|
94
|
+
labels = dbscan(dist, eps=threshold)
|
|
95
|
+
else:
|
|
96
|
+
raise ValueError(f"Unknown method {method!r}")
|
|
97
|
+
return remove_small_clusters(labels, min_cluster_size) if min_cluster_size > 0 else labels
|
pat/inference.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""High-level inference: encode a cloud once, then query affinities anywhere.
|
|
2
|
+
|
|
3
|
+
Coordinates are used exactly as given. Normalize them yourself, the same way
|
|
4
|
+
for training and inference.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import torch
|
|
12
|
+
|
|
13
|
+
from .cluster import NOISE, cluster_affinity, num_clusters
|
|
14
|
+
from .model import PointAffinityTransformer
|
|
15
|
+
from .preprocess import as_points, subsample
|
|
16
|
+
|
|
17
|
+
DEFAULT_CHUNK_SIZE = 16384
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class EncodedCloud:
|
|
22
|
+
"""A point cloud encoded by PAT. Query it with arbitrary points.
|
|
23
|
+
|
|
24
|
+
Create it with [`pat.encode`][pat.encode]. Queries are given like the encoded
|
|
25
|
+
cloud: ``(Q, 3 + F)``, coordinates in the same frame followed by the same
|
|
26
|
+
``F`` feature columns.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
model: The model that produced the latents.
|
|
30
|
+
latents: Latent vectors, shape ``(1, num_latents, dim)``.
|
|
31
|
+
points: The (possibly subsampled) points that were encoded, including
|
|
32
|
+
feature columns.
|
|
33
|
+
index: Indices of `points` in the cloud passed to `encode`.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
model: PointAffinityTransformer
|
|
37
|
+
latents: torch.Tensor
|
|
38
|
+
points: torch.Tensor
|
|
39
|
+
index: torch.Tensor
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def device(self) -> torch.device:
|
|
43
|
+
return self.latents.device
|
|
44
|
+
|
|
45
|
+
def _embed(self, queries) -> torch.Tensor:
|
|
46
|
+
return self.model.point_embed(as_points(queries, device=self.device, dim=self.model.in_dim))
|
|
47
|
+
|
|
48
|
+
@torch.no_grad()
|
|
49
|
+
def pair_affinity(self, qa, qb=None, *, chunk_size: int = DEFAULT_CHUNK_SIZE) -> torch.Tensor:
|
|
50
|
+
"""Affinity of explicit point pairs.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
qa: First points ``(P, 3 + F)``, or pairs ``(P, 2, 3 + F)`` if ``qb`` is None.
|
|
54
|
+
qb: Second points ``(P, 3 + F)``.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Affinities of shape ``(P,)``.
|
|
58
|
+
"""
|
|
59
|
+
if qb is None:
|
|
60
|
+
pairs = torch.as_tensor(qa, dtype=torch.float32)
|
|
61
|
+
qa, qb = pairs[:, 0], pairs[:, 1]
|
|
62
|
+
ea, eb = self._embed(qa), self._embed(qb)
|
|
63
|
+
if ea.shape != eb.shape:
|
|
64
|
+
raise ValueError("qa and qb must contain the same number of points")
|
|
65
|
+
out = [
|
|
66
|
+
self.model.decode(self.latents, a[None], b[None], embedded=True)[0]
|
|
67
|
+
for a, b in zip(ea.split(chunk_size), eb.split(chunk_size))
|
|
68
|
+
]
|
|
69
|
+
return torch.cat(out)
|
|
70
|
+
|
|
71
|
+
@torch.no_grad()
|
|
72
|
+
def affinity(self, queries=None, others=None, *, chunk_size: int = DEFAULT_CHUNK_SIZE) -> torch.Tensor:
|
|
73
|
+
"""Affinity matrix between two sets of query points.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
queries: Points ``(Q, 3 + F)``. Defaults to the encoded points.
|
|
77
|
+
others: Points ``(K, 3 + F)``. Defaults to ``queries``.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
``A[i, j]`` is the affinity of ``(queries[i], others[j])``, shape ``(Q, K)``.
|
|
81
|
+
"""
|
|
82
|
+
queries = self.points if queries is None else queries
|
|
83
|
+
ea = self._embed(queries)
|
|
84
|
+
eb = ea if others is None else self._embed(others)
|
|
85
|
+
Q, K = ea.shape[0], eb.shape[0]
|
|
86
|
+
|
|
87
|
+
out = torch.empty(Q * K, device=self.device)
|
|
88
|
+
for start in range(0, Q * K, chunk_size):
|
|
89
|
+
flat = torch.arange(start, min(start + chunk_size, Q * K), device=self.device)
|
|
90
|
+
i, j = flat // K, flat % K
|
|
91
|
+
out[start : start + flat.numel()] = self.model.decode(
|
|
92
|
+
self.latents, ea[i][None], eb[j][None], embedded=True
|
|
93
|
+
)[0]
|
|
94
|
+
return out.view(Q, K)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@torch.no_grad()
|
|
98
|
+
def encode(
|
|
99
|
+
model: PointAffinityTransformer,
|
|
100
|
+
points,
|
|
101
|
+
*,
|
|
102
|
+
max_points: int | None = None,
|
|
103
|
+
generator: torch.Generator | None = None,
|
|
104
|
+
) -> EncodedCloud:
|
|
105
|
+
"""Encode one point cloud.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
model: A PAT model, e.g. from [`load_pretrained`][pat.load_pretrained].
|
|
109
|
+
points: Cloud ``(N, 3 + F)``: coordinates in the same frame as the training
|
|
110
|
+
data, then the model's ``F = model.config.feature_dim`` feature columns.
|
|
111
|
+
max_points: Clouds larger than this are randomly subsampled before
|
|
112
|
+
encoding. Defaults to ``model.config.num_points``.
|
|
113
|
+
generator: Random generator for subsampling.
|
|
114
|
+
"""
|
|
115
|
+
device = next(model.parameters()).device
|
|
116
|
+
points = as_points(points, dim=model.in_dim)
|
|
117
|
+
max_points = max_points or model.config.num_points
|
|
118
|
+
encoded, index = subsample(points, max_points, generator=generator)
|
|
119
|
+
|
|
120
|
+
encoded = encoded.to(device)
|
|
121
|
+
latents = model.encode(encoded[None])
|
|
122
|
+
return EncodedCloud(model, latents, encoded, index.to(device))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class Segmentation:
|
|
127
|
+
"""Result of [`pat.segment`][pat.segment].
|
|
128
|
+
|
|
129
|
+
Attributes:
|
|
130
|
+
labels: Neuron label per query point, ``-1`` for noise. Shape ``(Q,)``.
|
|
131
|
+
queries: The query points, including feature columns.
|
|
132
|
+
anchors: Indices of the queries that were clustered directly.
|
|
133
|
+
affinity: Affinity matrix between the anchors.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
labels: torch.Tensor
|
|
137
|
+
queries: torch.Tensor
|
|
138
|
+
anchors: torch.Tensor
|
|
139
|
+
affinity: torch.Tensor
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def num_neurons(self) -> int:
|
|
143
|
+
return num_clusters(self.labels)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@torch.no_grad()
|
|
147
|
+
def segment(
|
|
148
|
+
model: PointAffinityTransformer,
|
|
149
|
+
points,
|
|
150
|
+
queries=None,
|
|
151
|
+
*,
|
|
152
|
+
threshold: float = 0.8,
|
|
153
|
+
min_cluster_size: int = 30,
|
|
154
|
+
method: str = "agglomerative",
|
|
155
|
+
max_anchors: int = 4096,
|
|
156
|
+
symmetrize: bool = True,
|
|
157
|
+
max_points: int | None = None,
|
|
158
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
159
|
+
generator: torch.Generator | None = None,
|
|
160
|
+
) -> Segmentation:
|
|
161
|
+
"""Split a point cloud into neurons.
|
|
162
|
+
|
|
163
|
+
The cloud ``points`` is encoded once. Labels are computed for ``queries``,
|
|
164
|
+
which can be any points (default: ``points`` itself).
|
|
165
|
+
|
|
166
|
+
If there are more than ``max_anchors`` queries, a random subset (the anchors)
|
|
167
|
+
is clustered on its full affinity matrix, and each remaining query joins
|
|
168
|
+
the cluster with the highest mean affinity to it, provided that mean is
|
|
169
|
+
above ``1 - threshold``. Otherwise it becomes noise. This keeps the cost
|
|
170
|
+
linear in the number of queries.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
model: A PAT model.
|
|
174
|
+
points: Cloud to encode, ``(N, 3 + F)``: coordinates, then features.
|
|
175
|
+
queries: Points to label, ``(Q, 3 + F)``. Defaults to ``points``.
|
|
176
|
+
threshold: Distance threshold, see [`cluster_affinity`][pat.cluster.cluster_affinity].
|
|
177
|
+
min_cluster_size: Minimum anchors per neuron.
|
|
178
|
+
method: Clustering method for the anchors.
|
|
179
|
+
max_anchors: Maximum number of queries clustered directly.
|
|
180
|
+
symmetrize: Symmetrize the anchor affinity matrix.
|
|
181
|
+
max_points: Subsample ``points`` to at most this many before encoding,
|
|
182
|
+
see [`encode`][pat.encode].
|
|
183
|
+
chunk_size: Pairs per decoder pass; lower it if you run out of memory.
|
|
184
|
+
generator: Random generator for subsampling the cloud and the anchors.
|
|
185
|
+
"""
|
|
186
|
+
enc = encode(model, points, max_points=max_points, generator=generator)
|
|
187
|
+
queries = as_points(points if queries is None else queries, device=enc.device, dim=model.in_dim)
|
|
188
|
+
Q = queries.shape[0]
|
|
189
|
+
|
|
190
|
+
_, anchors = subsample(queries, max_anchors, generator=generator)
|
|
191
|
+
A = enc.affinity(queries[anchors], chunk_size=chunk_size)
|
|
192
|
+
anchor_labels = cluster_affinity(
|
|
193
|
+
A, threshold=threshold, min_cluster_size=min_cluster_size, method=method, symmetrize=symmetrize
|
|
194
|
+
).to(enc.device)
|
|
195
|
+
|
|
196
|
+
labels = torch.full((Q,), NOISE, dtype=torch.int64, device=enc.device)
|
|
197
|
+
labels[anchors] = anchor_labels
|
|
198
|
+
rest = torch.ones(Q, dtype=torch.bool, device=enc.device)
|
|
199
|
+
rest[anchors] = False
|
|
200
|
+
n_clusters = num_clusters(anchor_labels)
|
|
201
|
+
|
|
202
|
+
if rest.any() and n_clusters > 0:
|
|
203
|
+
valid = anchor_labels >= 0
|
|
204
|
+
onehot = torch.nn.functional.one_hot(anchor_labels[valid], n_clusters).float()
|
|
205
|
+
rest_idx = rest.nonzero().squeeze(1)
|
|
206
|
+
for chunk in rest_idx.split(max(1, chunk_size // max(1, anchors.numel()))):
|
|
207
|
+
a = enc.affinity(queries[chunk], queries[anchors][valid], chunk_size=chunk_size)
|
|
208
|
+
mean_aff = (a @ onehot) / onehot.sum(0)
|
|
209
|
+
best, arg = mean_aff.max(dim=1)
|
|
210
|
+
labels[chunk] = torch.where(best > 1.0 - threshold, arg, torch.full_like(arg, NOISE))
|
|
211
|
+
|
|
212
|
+
return Segmentation(labels=labels.cpu(), queries=queries.cpu(), anchors=anchors.cpu(), affinity=A.cpu())
|
pat/io.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Reading neuron skeletons."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import navis
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_neuron(path, units: str = "um"):
|
|
10
|
+
"""Read an SWC file as a ``navis.TreeNeuron``.
|
|
11
|
+
|
|
12
|
+
Coordinates are converted to ``units`` if the file declares its units
|
|
13
|
+
(as navis-written files do); otherwise they are used as they are.
|
|
14
|
+
"""
|
|
15
|
+
neuron = navis.read_swc(str(path))
|
|
16
|
+
if neuron.units is not None and not neuron.units.dimensionless:
|
|
17
|
+
neuron = neuron.convert_units(units)
|
|
18
|
+
return neuron
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def node_array(neuron, features=()) -> np.ndarray:
|
|
22
|
+
"""Node table of a ``navis.TreeNeuron`` as ``(N, 3 + F)``: xyz, then the ``features`` columns."""
|
|
23
|
+
return neuron.nodes[["x", "y", "z", *features]].to_numpy(dtype=np.float32)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_swc(path, units: str = "um", features=()) -> np.ndarray:
|
|
27
|
+
"""Read the nodes of an SWC file (see `load_neuron`).
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
path: SWC file.
|
|
31
|
+
units: Coordinate units to convert to.
|
|
32
|
+
features: Node-table columns appended after xyz, e.g. ``["radius"]``.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Array of shape ``(N, 3 + len(features))``.
|
|
36
|
+
"""
|
|
37
|
+
return node_array(load_neuron(path, units), features)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_swcs(paths, units: str = "um", features=()) -> tuple[np.ndarray, np.ndarray]:
|
|
41
|
+
"""Read several SWC files into one cloud.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
``(points, labels)`` where ``labels[i]`` is the index of the file that
|
|
45
|
+
point ``i`` came from.
|
|
46
|
+
"""
|
|
47
|
+
clouds = [read_swc(p, units, features) for p in paths]
|
|
48
|
+
labels = np.concatenate([np.full(len(c), i) for i, c in enumerate(clouds)])
|
|
49
|
+
return np.concatenate(clouds), labels
|
pat/metrics.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Segmentation metrics comparing predicted to ground-truth labels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from sklearn.metrics import adjusted_rand_score as _ari
|
|
7
|
+
from sklearn.metrics.cluster import contingency_matrix
|
|
8
|
+
|
|
9
|
+
from .cluster import to_numpy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _labels(x) -> np.ndarray:
|
|
13
|
+
return to_numpy(x).astype(np.int64).ravel()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _entropy(counts: np.ndarray, n: int) -> float:
|
|
17
|
+
p = counts[counts > 0] / n
|
|
18
|
+
return float(-(p * np.log(p)).sum())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def voi(pred, gt) -> tuple[float, float, float]:
|
|
22
|
+
"""Variation of information (natural log).
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
``(voi, split, merge)`` with ``split = H(pred | gt)`` (over-segmentation)
|
|
26
|
+
and ``merge = H(gt | pred)`` (under-segmentation).
|
|
27
|
+
Noise (``-1``) is treated as one regular label.
|
|
28
|
+
"""
|
|
29
|
+
pred, gt = _labels(pred), _labels(gt)
|
|
30
|
+
n = pred.size
|
|
31
|
+
joint = contingency_matrix(gt, pred, sparse=True).data
|
|
32
|
+
h_joint = _entropy(joint, n)
|
|
33
|
+
h_pred = _entropy(np.unique(pred, return_counts=True)[1], n)
|
|
34
|
+
h_gt = _entropy(np.unique(gt, return_counts=True)[1], n)
|
|
35
|
+
split, merge = h_joint - h_gt, h_joint - h_pred
|
|
36
|
+
return split + merge, split, merge
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def adjusted_rand_score(pred, gt) -> float:
|
|
40
|
+
"""Adjusted Rand index (1 = identical partitions)."""
|
|
41
|
+
return float(_ari(_labels(gt), _labels(pred)))
|