swcgeom 0.19.4__cp311-cp311-macosx_10_9_universal2.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.
Potentially problematic release.
This version of swcgeom might be problematic. Click here for more details.
- swcgeom/__init__.py +21 -0
- swcgeom/analysis/__init__.py +13 -0
- swcgeom/analysis/feature_extractor.py +454 -0
- swcgeom/analysis/features.py +218 -0
- swcgeom/analysis/lmeasure.py +750 -0
- swcgeom/analysis/sholl.py +201 -0
- swcgeom/analysis/trunk.py +183 -0
- swcgeom/analysis/visualization.py +191 -0
- swcgeom/analysis/visualization3d.py +81 -0
- swcgeom/analysis/volume.py +143 -0
- swcgeom/core/__init__.py +19 -0
- swcgeom/core/branch.py +129 -0
- swcgeom/core/branch_tree.py +65 -0
- swcgeom/core/compartment.py +107 -0
- swcgeom/core/node.py +130 -0
- swcgeom/core/path.py +155 -0
- swcgeom/core/population.py +341 -0
- swcgeom/core/swc.py +247 -0
- swcgeom/core/swc_utils/__init__.py +19 -0
- swcgeom/core/swc_utils/assembler.py +35 -0
- swcgeom/core/swc_utils/base.py +180 -0
- swcgeom/core/swc_utils/checker.py +107 -0
- swcgeom/core/swc_utils/io.py +204 -0
- swcgeom/core/swc_utils/normalizer.py +163 -0
- swcgeom/core/swc_utils/subtree.py +70 -0
- swcgeom/core/tree.py +384 -0
- swcgeom/core/tree_utils.py +277 -0
- swcgeom/core/tree_utils_impl.py +58 -0
- swcgeom/images/__init__.py +9 -0
- swcgeom/images/augmentation.py +149 -0
- swcgeom/images/contrast.py +87 -0
- swcgeom/images/folder.py +217 -0
- swcgeom/images/io.py +578 -0
- swcgeom/images/loaders/__init__.py +8 -0
- swcgeom/images/loaders/pbd.cpython-311-darwin.so +0 -0
- swcgeom/images/loaders/pbd.pyx +523 -0
- swcgeom/images/loaders/raw.cpython-311-darwin.so +0 -0
- swcgeom/images/loaders/raw.pyx +183 -0
- swcgeom/transforms/__init__.py +20 -0
- swcgeom/transforms/base.py +136 -0
- swcgeom/transforms/branch.py +223 -0
- swcgeom/transforms/branch_tree.py +74 -0
- swcgeom/transforms/geometry.py +270 -0
- swcgeom/transforms/image_preprocess.py +107 -0
- swcgeom/transforms/image_stack.py +219 -0
- swcgeom/transforms/images.py +206 -0
- swcgeom/transforms/mst.py +183 -0
- swcgeom/transforms/neurolucida_asc.py +498 -0
- swcgeom/transforms/path.py +56 -0
- swcgeom/transforms/population.py +36 -0
- swcgeom/transforms/tree.py +265 -0
- swcgeom/transforms/tree_assembler.py +161 -0
- swcgeom/utils/__init__.py +18 -0
- swcgeom/utils/debug.py +23 -0
- swcgeom/utils/download.py +119 -0
- swcgeom/utils/dsu.py +58 -0
- swcgeom/utils/ellipse.py +131 -0
- swcgeom/utils/file.py +90 -0
- swcgeom/utils/neuromorpho.py +581 -0
- swcgeom/utils/numpy_helper.py +70 -0
- swcgeom/utils/plotter_2d.py +134 -0
- swcgeom/utils/plotter_3d.py +35 -0
- swcgeom/utils/renderer.py +145 -0
- swcgeom/utils/sdf.py +324 -0
- swcgeom/utils/solid_geometry.py +154 -0
- swcgeom/utils/transforms.py +367 -0
- swcgeom/utils/volumetric_object.py +483 -0
- swcgeom-0.19.4.dist-info/METADATA +86 -0
- swcgeom-0.19.4.dist-info/RECORD +72 -0
- swcgeom-0.19.4.dist-info/WHEEL +5 -0
- swcgeom-0.19.4.dist-info/licenses/LICENSE +201 -0
- swcgeom-0.19.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
|
|
2
|
+
# SPDX-FileCopyrightText: 2022 - 2025 Zexin Yuan <pypi@yzx9.xyz>
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
"""SWC geometry operations."""
|
|
7
|
+
|
|
8
|
+
import warnings
|
|
9
|
+
from typing import Generic, Literal, TypeVar
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import numpy.typing as npt
|
|
13
|
+
from typing_extensions import override
|
|
14
|
+
|
|
15
|
+
from swcgeom.core import DictSWC
|
|
16
|
+
from swcgeom.core.swc_utils import SWCNames
|
|
17
|
+
from swcgeom.transforms.base import Transform
|
|
18
|
+
from swcgeom.utils import (
|
|
19
|
+
rotate3d,
|
|
20
|
+
rotate3d_x,
|
|
21
|
+
rotate3d_y,
|
|
22
|
+
rotate3d_z,
|
|
23
|
+
scale3d,
|
|
24
|
+
translate3d,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Normalizer",
|
|
29
|
+
"RadiusReseter",
|
|
30
|
+
"AffineTransform",
|
|
31
|
+
"Translate",
|
|
32
|
+
"TranslateOrigin",
|
|
33
|
+
"Scale",
|
|
34
|
+
"Rotate",
|
|
35
|
+
"RotateX",
|
|
36
|
+
"RotateY",
|
|
37
|
+
"RotateZ",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
T = TypeVar("T", bound=DictSWC)
|
|
41
|
+
Center = Literal["root", "soma", "origin"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# pylint: disable=too-few-public-methods
|
|
45
|
+
class Normalizer(Generic[T], Transform[T, T]):
|
|
46
|
+
"""Noramlize coordinates and radius to 0-1."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, *, names: SWCNames | None = None) -> None:
|
|
49
|
+
super().__init__()
|
|
50
|
+
if names is not None:
|
|
51
|
+
warnings.warn(
|
|
52
|
+
"`name` parameter is no longer needed, now use the "
|
|
53
|
+
"built-in names table, you can directly remove it.",
|
|
54
|
+
DeprecationWarning,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@override
|
|
58
|
+
def __call__(self, x: T) -> T:
|
|
59
|
+
"""Scale the `x`, `y`, `z`, `r` of nodes to 0-1."""
|
|
60
|
+
new_tree = x.copy()
|
|
61
|
+
xyzr = [x.names.x, x.names.y, x.names.z, x.names.r]
|
|
62
|
+
for key in xyzr: # TODO: does r is the same?
|
|
63
|
+
vs = new_tree.ndata[key]
|
|
64
|
+
new_tree.ndata[key] = (vs - np.min(vs)) / np.max(vs)
|
|
65
|
+
|
|
66
|
+
return new_tree
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RadiusReseter(Generic[T], Transform[T, T]):
|
|
70
|
+
"""Reset radius to fixed value."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, r: float) -> None:
|
|
73
|
+
super().__init__()
|
|
74
|
+
self.r = r
|
|
75
|
+
|
|
76
|
+
def __call__(self, x: T) -> T:
|
|
77
|
+
r = np.full_like(x.r(), fill_value=self.r)
|
|
78
|
+
new_tree = x.copy()
|
|
79
|
+
new_tree.ndata[new_tree.names.r] = r
|
|
80
|
+
return new_tree
|
|
81
|
+
|
|
82
|
+
@override
|
|
83
|
+
def extra_repr(self) -> str:
|
|
84
|
+
return f"r={self.r:.4f}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AffineTransform(Generic[T], Transform[T, T]):
|
|
88
|
+
"""Apply affine matrix."""
|
|
89
|
+
|
|
90
|
+
tm: npt.NDArray[np.float32]
|
|
91
|
+
center: Center
|
|
92
|
+
fmt: str
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
tm: npt.NDArray[np.float32],
|
|
97
|
+
center: Center = "origin",
|
|
98
|
+
*,
|
|
99
|
+
fmt: str | None = None,
|
|
100
|
+
names: SWCNames | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
self.tm, self.center = tm, center
|
|
103
|
+
|
|
104
|
+
if fmt is not None:
|
|
105
|
+
warnings.warn(
|
|
106
|
+
"`fmt` parameter is no longer needed, now use the "
|
|
107
|
+
"extra_repr(), you can directly remove it.",
|
|
108
|
+
DeprecationWarning,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if names is not None:
|
|
112
|
+
warnings.warn(
|
|
113
|
+
"`name` parameter is no longer needed, now use the "
|
|
114
|
+
"built-in names table, you can directly remove it.",
|
|
115
|
+
DeprecationWarning,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@override
|
|
119
|
+
def __call__(self, x: T) -> T:
|
|
120
|
+
match self.center:
|
|
121
|
+
case "root" | "soma":
|
|
122
|
+
idx = np.nonzero(x.ndata[x.names.pid] == -1)[0][0].item()
|
|
123
|
+
xyz = x.xyz()[idx]
|
|
124
|
+
tm = (
|
|
125
|
+
translate3d(-xyz[0], -xyz[1], -xyz[2])
|
|
126
|
+
.dot(self.tm)
|
|
127
|
+
.dot(translate3d(xyz[0], xyz[1], xyz[2]))
|
|
128
|
+
)
|
|
129
|
+
case _:
|
|
130
|
+
tm = self.tm
|
|
131
|
+
|
|
132
|
+
return self.apply(x, tm)
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def apply(x: T, tm: npt.NDArray[np.float32]) -> T:
|
|
136
|
+
xyzw = x.xyzw().dot(tm.T).T
|
|
137
|
+
xyzw /= xyzw[3]
|
|
138
|
+
|
|
139
|
+
y = x.copy()
|
|
140
|
+
y.ndata[x.names.x] = xyzw[0]
|
|
141
|
+
y.ndata[x.names.y] = xyzw[1]
|
|
142
|
+
y.ndata[x.names.z] = xyzw[2]
|
|
143
|
+
return y
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Translate(Generic[T], AffineTransform[T]):
|
|
147
|
+
"""Translate SWC."""
|
|
148
|
+
|
|
149
|
+
def __init__(self, tx: float, ty: float, tz: float, **kwargs) -> None:
|
|
150
|
+
super().__init__(translate3d(tx, ty, tz), **kwargs)
|
|
151
|
+
self.tx, self.ty, self.tz = tx, ty, tz
|
|
152
|
+
|
|
153
|
+
@override
|
|
154
|
+
def extra_repr(self) -> str:
|
|
155
|
+
return f"tx={self.tx:.4f}, ty={self.ty:.4f}, tz={self.tz:.4f}"
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def transform(cls, x: T, tx: float, ty: float, tz: float, **kwargs) -> T:
|
|
159
|
+
return cls(tx, ty, tz, **kwargs)(x)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class TranslateOrigin(Generic[T], Transform[T, T]):
|
|
163
|
+
"""Translate root of SWC to origin point."""
|
|
164
|
+
|
|
165
|
+
def __call__(self, x: T) -> T:
|
|
166
|
+
return self.transform(x)
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def transform(cls, x: T) -> T:
|
|
170
|
+
pid = np.nonzero(x.ndata[x.names.pid] == -1)[0][0].item()
|
|
171
|
+
xyzw = x.xyzw()
|
|
172
|
+
tm = translate3d(-xyzw[pid, 0], -xyzw[pid, 1], -xyzw[pid, 2])
|
|
173
|
+
return AffineTransform.apply(x, tm)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class Scale(Generic[T], AffineTransform[T]):
|
|
177
|
+
"""Scale SWC."""
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self, sx: float, sy: float, sz: float, center: Center = "root", **kwargs
|
|
181
|
+
) -> None:
|
|
182
|
+
super().__init__(scale3d(sx, sy, sz), center=center, **kwargs)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def transform( # pylint: disable=too-many-arguments
|
|
186
|
+
cls, x: T, sx: float, sy: float, sz: float, center: Center = "root", **kwargs
|
|
187
|
+
) -> T:
|
|
188
|
+
return cls(sx, sy, sz, center=center, **kwargs)(x)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Rotate(Generic[T], AffineTransform[T]):
|
|
192
|
+
"""Rotate SWC."""
|
|
193
|
+
|
|
194
|
+
def __init__(
|
|
195
|
+
self,
|
|
196
|
+
n: npt.NDArray[np.float32],
|
|
197
|
+
theta: float,
|
|
198
|
+
center: Center = "root",
|
|
199
|
+
**kwargs,
|
|
200
|
+
) -> None:
|
|
201
|
+
fmt = f"Rotate-{n[0]}-{n[1]}-{n[2]}-{theta:.4f}"
|
|
202
|
+
super().__init__(rotate3d(n, theta), center=center, fmt=fmt, **kwargs)
|
|
203
|
+
self.n = n
|
|
204
|
+
self.theta = theta
|
|
205
|
+
self.center = center
|
|
206
|
+
|
|
207
|
+
@override
|
|
208
|
+
def extra_repr(self) -> str:
|
|
209
|
+
return f"n={self.n}, theta={self.theta:.4f}, center={self.center}" # TODO: improve format of n
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def transform(
|
|
213
|
+
cls,
|
|
214
|
+
x: T,
|
|
215
|
+
n: npt.NDArray[np.float32],
|
|
216
|
+
theta: float,
|
|
217
|
+
center: Center = "root",
|
|
218
|
+
**kwargs,
|
|
219
|
+
) -> T:
|
|
220
|
+
return cls(n, theta, center=center, **kwargs)(x)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class RotateX(Generic[T], AffineTransform[T]):
|
|
224
|
+
"""Rotate SWC with x-axis."""
|
|
225
|
+
|
|
226
|
+
def __init__(self, theta: float, center: Center = "root", **kwargs) -> None:
|
|
227
|
+
super().__init__(rotate3d_x(theta), center=center, **kwargs)
|
|
228
|
+
self.theta = theta
|
|
229
|
+
|
|
230
|
+
@override
|
|
231
|
+
def extra_repr(self) -> str:
|
|
232
|
+
return f"center={self.center}, theta={self.theta:.4f}"
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def transform(cls, x: T, theta: float, center: Center = "root", **kwargs) -> T:
|
|
236
|
+
return cls(theta, center=center, **kwargs)(x)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class RotateY(Generic[T], AffineTransform[T]):
|
|
240
|
+
"""Rotate SWC with y-axis."""
|
|
241
|
+
|
|
242
|
+
def __init__(self, theta: float, center: Center = "root", **kwargs) -> None:
|
|
243
|
+
super().__init__(rotate3d_y(theta), center=center, **kwargs)
|
|
244
|
+
self.theta = theta
|
|
245
|
+
self.center = center
|
|
246
|
+
|
|
247
|
+
@override
|
|
248
|
+
def extra_repr(self) -> str:
|
|
249
|
+
return f"theta={self.theta:.4f}, center={self.center}"
|
|
250
|
+
|
|
251
|
+
@classmethod
|
|
252
|
+
def transform(cls, x: T, theta: float, center: Center = "root", **kwargs) -> T:
|
|
253
|
+
return cls(theta, center=center, **kwargs)(x)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class RotateZ(Generic[T], AffineTransform[T]):
|
|
257
|
+
"""Rotate SWC with z-axis."""
|
|
258
|
+
|
|
259
|
+
def __init__(self, theta: float, center: Center = "root", **kwargs) -> None:
|
|
260
|
+
super().__init__(rotate3d_z(theta), center=center, **kwargs)
|
|
261
|
+
self.theta = theta
|
|
262
|
+
self.center = center
|
|
263
|
+
|
|
264
|
+
@override
|
|
265
|
+
def extra_repr(self) -> str:
|
|
266
|
+
return f"theta={self.theta:.4f}, center={self.center}"
|
|
267
|
+
|
|
268
|
+
@classmethod
|
|
269
|
+
def transform(cls, x: T, theta: float, center: Center = "root", **kwargs) -> T:
|
|
270
|
+
return cls(theta, center=center, **kwargs)(x)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
|
|
2
|
+
# SPDX-FileCopyrightText: 2022 - 2025 Zexin Yuan <pypi@yzx9.xyz>
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
"""Image stack pre-processing."""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import numpy.typing as npt
|
|
10
|
+
from scipy.fftpack import fftn, fftshift, ifftn
|
|
11
|
+
from scipy.ndimage import gaussian_filter, minimum_filter
|
|
12
|
+
from typing_extensions import override
|
|
13
|
+
|
|
14
|
+
from swcgeom.transforms.base import Transform
|
|
15
|
+
|
|
16
|
+
__all__ = ["SGuoImPreProcess"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SGuoImPreProcess(Transform[npt.NDArray[np.uint8], npt.NDArray[np.uint8]]):
|
|
20
|
+
"""Single-Neuron Image Enhancement.
|
|
21
|
+
|
|
22
|
+
Implementation of the image enhancement method described in the paper:
|
|
23
|
+
|
|
24
|
+
Shuxia Guo, Xuan Zhao, Shengdian Jiang, Liya Ding, Hanchuan Peng,
|
|
25
|
+
Image enhancement to leverage the 3D morphological reconstruction
|
|
26
|
+
of single-cell neurons, Bioinformatics, Volume 38, Issue 2,
|
|
27
|
+
January 2022, Pages 503–512, https://doi.org/10.1093/bioinformatics/btab638
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
@override
|
|
31
|
+
def __call__(self, x: npt.NDArray[np.uint8]) -> npt.NDArray[np.uint8]:
|
|
32
|
+
# TODO: support np.float32
|
|
33
|
+
assert x.dtype == np.uint8, "Image must be in uint8 format"
|
|
34
|
+
x = self.sigmoid_adjustment(x)
|
|
35
|
+
x = self.subtract_min_along_z(x)
|
|
36
|
+
x = self.bilateral_filter_3d(x)
|
|
37
|
+
x = self.high_pass_fft(x)
|
|
38
|
+
return x
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def sigmoid_adjustment(
|
|
42
|
+
image: npt.NDArray[np.uint8], sigma: float = 3, percentile: float = 25
|
|
43
|
+
) -> npt.NDArray[np.uint8]:
|
|
44
|
+
image_normalized = image / 255.0
|
|
45
|
+
u = np.percentile(image_normalized, percentile)
|
|
46
|
+
adjusted = 1 / (1 + np.exp(-sigma * (image_normalized - u)))
|
|
47
|
+
adjusted_rescaled = (adjusted * 255).astype(np.uint8)
|
|
48
|
+
return adjusted_rescaled
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def subtract_min_along_z(image: npt.NDArray[np.uint8]) -> npt.NDArray[np.uint8]:
|
|
52
|
+
min_along_z = minimum_filter(
|
|
53
|
+
image,
|
|
54
|
+
size=(1, 1, image.shape[2], 1),
|
|
55
|
+
mode="constant",
|
|
56
|
+
cval=np.max(image).item(),
|
|
57
|
+
)
|
|
58
|
+
subtracted = image - min_along_z
|
|
59
|
+
return subtracted
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def bilateral_filter_3d(
|
|
63
|
+
image: npt.NDArray[np.uint8], spatial_sigma=(1, 1, 0.33), range_sigma=35
|
|
64
|
+
) -> npt.NDArray[np.uint8]:
|
|
65
|
+
# initialize the output image
|
|
66
|
+
filtered_image = np.zeros_like(image)
|
|
67
|
+
|
|
68
|
+
spatial_gaussian = gaussian_filter(image, spatial_sigma)
|
|
69
|
+
|
|
70
|
+
# traverse each pixel to perform bilateral filtering
|
|
71
|
+
# TODO: optimization is needed
|
|
72
|
+
for z in range(image.shape[2]):
|
|
73
|
+
for y in range(image.shape[1]):
|
|
74
|
+
for x in range(image.shape[0]):
|
|
75
|
+
value = image[x, y, z]
|
|
76
|
+
range_weight = np.exp(
|
|
77
|
+
-((image - value) ** 2) / (2 * range_sigma**2)
|
|
78
|
+
)
|
|
79
|
+
weights = spatial_gaussian * range_weight
|
|
80
|
+
filtered_image[x, y, z] = np.sum(image * weights) / np.sum(weights)
|
|
81
|
+
|
|
82
|
+
return filtered_image
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def high_pass_fft(image: npt.NDArray[np.uint8]) -> npt.NDArray[np.uint8]:
|
|
86
|
+
# fft
|
|
87
|
+
fft_image = fftn(image)
|
|
88
|
+
fft_shifted = fftshift(fft_image)
|
|
89
|
+
|
|
90
|
+
# create a high-pass filter
|
|
91
|
+
h, w, d, _ = image.shape
|
|
92
|
+
x, y, z = np.ogrid[:h, :w, :d]
|
|
93
|
+
center = (h / 2, w / 2, d / 2)
|
|
94
|
+
distance = np.sqrt(
|
|
95
|
+
(x - center[0]) ** 2 + (y - center[1]) ** 2 + (z - center[2]) ** 2
|
|
96
|
+
)
|
|
97
|
+
# adjust this threshold to control the filtering strength
|
|
98
|
+
high_pass_mask = distance > (d // 4)
|
|
99
|
+
# apply the high-pass filter
|
|
100
|
+
fft_shifted *= high_pass_mask
|
|
101
|
+
|
|
102
|
+
# inverse fft
|
|
103
|
+
fft_unshifted = np.fft.ifftshift(fft_shifted)
|
|
104
|
+
filtered_image = np.real(ifftn(fft_unshifted))
|
|
105
|
+
|
|
106
|
+
filtered_rescaled = np.clip(filtered_image, 0, 255).astype(np.uint8)
|
|
107
|
+
return filtered_rescaled
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
|
|
2
|
+
# SPDX-FileCopyrightText: 2022 - 2025 Zexin Yuan <pypi@yzx9.xyz>
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
"""Create image stack from morphology.
|
|
7
|
+
|
|
8
|
+
NOTE: All denpendencies need to be installed, try:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pip install swcgeom[all]
|
|
12
|
+
```
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
from typing import Sequence
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
import numpy.typing as npt
|
|
23
|
+
import tifffile
|
|
24
|
+
from sdflit import (
|
|
25
|
+
ColoredMaterial,
|
|
26
|
+
ObjectsScene,
|
|
27
|
+
RangeSampler,
|
|
28
|
+
RoundCone,
|
|
29
|
+
Scene,
|
|
30
|
+
SDFObject,
|
|
31
|
+
)
|
|
32
|
+
from tqdm import tqdm
|
|
33
|
+
from typing_extensions import deprecated, override
|
|
34
|
+
|
|
35
|
+
from swcgeom.core import Population, Tree
|
|
36
|
+
from swcgeom.transforms.base import Transform
|
|
37
|
+
|
|
38
|
+
__all__ = ["ToImageStack"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ToImageStack(Transform[Tree, npt.NDArray[np.uint8]]):
|
|
42
|
+
r"""Transform tree to image stack."""
|
|
43
|
+
|
|
44
|
+
resolution: npt.NDArray[np.float32]
|
|
45
|
+
|
|
46
|
+
def __init__(self, resolution: int | float | npt.ArrayLike = 1) -> None:
|
|
47
|
+
"""Transform tree to image stack.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
resolution: Resolution of image stack.
|
|
51
|
+
If a scalar, it will be broadcasted to a vector of 3d.
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(resolution, (int, float, np.integer, np.floating)):
|
|
54
|
+
resolution = [resolution, resolution, resolution] # type: ignore
|
|
55
|
+
|
|
56
|
+
self.resolution = np.array(resolution, dtype=np.float32)
|
|
57
|
+
assert len(self.resolution) == 3, "resolution should be vector of 3d."
|
|
58
|
+
|
|
59
|
+
@override
|
|
60
|
+
def __call__(self, x: Tree) -> npt.NDArray[np.uint8]:
|
|
61
|
+
"""Transform tree to image stack.
|
|
62
|
+
|
|
63
|
+
NOTE: This method loads the entire image stack into memory, so it ONLY works
|
|
64
|
+
for small image stacks, use :meth`transform_and_save` for big image stack.
|
|
65
|
+
"""
|
|
66
|
+
return np.stack(list(self.transform(x, verbose=False)), axis=0)
|
|
67
|
+
|
|
68
|
+
def transform(
|
|
69
|
+
self,
|
|
70
|
+
x: Tree | Sequence[Tree],
|
|
71
|
+
verbose: bool = True,
|
|
72
|
+
*,
|
|
73
|
+
ranges: tuple[npt.ArrayLike, npt.ArrayLike] | None = None,
|
|
74
|
+
) -> Iterable[npt.NDArray[np.uint8]]:
|
|
75
|
+
trees = [x] if isinstance(x, Tree) else x
|
|
76
|
+
if not trees:
|
|
77
|
+
return iter([]) # Return empty iterator if sequence is empty
|
|
78
|
+
|
|
79
|
+
time_start = None
|
|
80
|
+
if verbose:
|
|
81
|
+
sources = ", ".join(t.source for t in trees if t.source)
|
|
82
|
+
print(f"To image stack: {sources if sources else 'unnamed trees'}")
|
|
83
|
+
time_start = time.time()
|
|
84
|
+
|
|
85
|
+
scene = self._get_scene(trees)
|
|
86
|
+
|
|
87
|
+
if ranges is None:
|
|
88
|
+
all_xyz = np.concatenate([t.xyz() for t in trees], axis=0)
|
|
89
|
+
all_r = np.concatenate([t.r() for t in trees], axis=0).reshape(-1, 1)
|
|
90
|
+
if all_xyz.size == 0: # Handle empty trees
|
|
91
|
+
coord_min = np.zeros(3, dtype=np.float32)
|
|
92
|
+
coord_max = np.zeros(3, dtype=np.float32)
|
|
93
|
+
else:
|
|
94
|
+
coord_min = np.floor(np.min(all_xyz - all_r, axis=0))
|
|
95
|
+
coord_max = np.ceil(np.max(all_xyz + all_r, axis=0))
|
|
96
|
+
else:
|
|
97
|
+
assert len(ranges) == 2
|
|
98
|
+
coord_min = np.array(ranges[0])
|
|
99
|
+
coord_max = np.array(ranges[1])
|
|
100
|
+
assert len(coord_min) == len(coord_max) == 3
|
|
101
|
+
|
|
102
|
+
samplers = self._get_samplers(coord_min, coord_max)
|
|
103
|
+
|
|
104
|
+
if verbose and time_start is not None:
|
|
105
|
+
total = (coord_max[2] - coord_min[2]) / self.resolution[2]
|
|
106
|
+
samplers = tqdm(samplers, total=total.astype(np.int64).item())
|
|
107
|
+
|
|
108
|
+
time_end = time.time()
|
|
109
|
+
print("Prepare in: ", time_end - time_start, "s")
|
|
110
|
+
|
|
111
|
+
for sampler in samplers:
|
|
112
|
+
voxel = sampler.sample(scene) # should be shape of (x, y, z, 3) and z = 1
|
|
113
|
+
frame = (255 * voxel[..., 0, 0]).astype(np.uint8)
|
|
114
|
+
yield frame
|
|
115
|
+
|
|
116
|
+
@deprecated("Use transform instead")
|
|
117
|
+
def transfrom(
|
|
118
|
+
self,
|
|
119
|
+
x: Tree,
|
|
120
|
+
verbose: bool = True,
|
|
121
|
+
*,
|
|
122
|
+
ranges: tuple[npt.ArrayLike, npt.ArrayLike] | None = None,
|
|
123
|
+
) -> Iterable[npt.NDArray[np.uint8]]:
|
|
124
|
+
return self.transform(x, verbose, ranges=ranges)
|
|
125
|
+
|
|
126
|
+
def transform_and_save(
|
|
127
|
+
self, fname: str, x: Tree | Sequence[Tree], verbose: bool = True, **kwargs
|
|
128
|
+
) -> None:
|
|
129
|
+
self.save_tif(fname, self.transform(x, verbose=verbose, **kwargs))
|
|
130
|
+
|
|
131
|
+
def transform_population(
|
|
132
|
+
self, population: Population | str, verbose: bool = True
|
|
133
|
+
) -> None:
|
|
134
|
+
trees = (
|
|
135
|
+
Population.from_swc(population)
|
|
136
|
+
if isinstance(population, str)
|
|
137
|
+
else population
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if verbose:
|
|
141
|
+
trees = tqdm(trees)
|
|
142
|
+
|
|
143
|
+
# TODO: multiprocess
|
|
144
|
+
for tree in trees:
|
|
145
|
+
tif = re.sub(r".swc$", ".tif", tree.source)
|
|
146
|
+
if not os.path.isfile(tif):
|
|
147
|
+
self.transform_and_save(tif, tree, verbose=False)
|
|
148
|
+
|
|
149
|
+
@override
|
|
150
|
+
def extra_repr(self) -> str:
|
|
151
|
+
res = ",".join(f"{a:.4f}" for a in self.resolution)
|
|
152
|
+
return f"resolution=({res})"
|
|
153
|
+
|
|
154
|
+
def _get_scene(self, trees: Sequence[Tree]) -> Scene:
|
|
155
|
+
material = ColoredMaterial((1, 0, 0)).into()
|
|
156
|
+
scene = ObjectsScene()
|
|
157
|
+
scene.set_background((0, 0, 0))
|
|
158
|
+
|
|
159
|
+
def leave(n: Tree.Node, children: list[Tree.Node]) -> Tree.Node:
|
|
160
|
+
for c in children:
|
|
161
|
+
sdf = RoundCone(_tp3f(n.xyz()), _tp3f(c.xyz()), n.r, c.r).into()
|
|
162
|
+
scene.add_object(SDFObject(sdf, material).into())
|
|
163
|
+
return n
|
|
164
|
+
|
|
165
|
+
for tree in trees:
|
|
166
|
+
tree.traverse(leave=leave)
|
|
167
|
+
|
|
168
|
+
scene.build_bvh()
|
|
169
|
+
return scene.into()
|
|
170
|
+
|
|
171
|
+
def _get_samplers(
|
|
172
|
+
self,
|
|
173
|
+
coord_min: npt.NDArray,
|
|
174
|
+
coord_max: npt.NDArray,
|
|
175
|
+
offset: npt.NDArray | None = None,
|
|
176
|
+
) -> Iterable[RangeSampler]:
|
|
177
|
+
"""Get Samplers.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
coord_min, coord_max: Coordinates array of shape (3,).
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
eps = 1e-6
|
|
184
|
+
stride = self.resolution
|
|
185
|
+
offset = offset or (stride / 2)
|
|
186
|
+
|
|
187
|
+
xmin, ymin, zmin = _tp3f(coord_min + offset)
|
|
188
|
+
xmax, ymax, zmax = _tp3f(coord_max)
|
|
189
|
+
z = zmin
|
|
190
|
+
while z < zmax:
|
|
191
|
+
yield RangeSampler(
|
|
192
|
+
(xmin, ymin, z), (xmax, ymax, z + stride[2] - eps), _tp3f(stride)
|
|
193
|
+
)
|
|
194
|
+
z += stride[2]
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def save_tif(
|
|
198
|
+
fname: str,
|
|
199
|
+
frames: Iterable[npt.NDArray[np.uint8]],
|
|
200
|
+
resolution: tuple[float, float] = (1, 1),
|
|
201
|
+
) -> None:
|
|
202
|
+
with tifffile.TiffWriter(fname) as tif:
|
|
203
|
+
for frame in frames:
|
|
204
|
+
tif.write(
|
|
205
|
+
frame,
|
|
206
|
+
contiguous=True,
|
|
207
|
+
photometric="minisblack",
|
|
208
|
+
resolution=resolution,
|
|
209
|
+
metadata={
|
|
210
|
+
"unit": "um",
|
|
211
|
+
"axes": "ZXY",
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _tp3f(x: npt.NDArray) -> tuple[float, float, float]:
|
|
217
|
+
"""Convert to tuple of 3 floats."""
|
|
218
|
+
assert len(x) == 3
|
|
219
|
+
return (float(x[0]), float(x[1]), float(x[2]))
|