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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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)))