ct-segmentation-toolkit 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.
- ct_seg/__init__.py +78 -0
- ct_seg/denoise/__init__.py +24 -0
- ct_seg/denoise/data.py +348 -0
- ct_seg/denoise/denoise_slice.py +190 -0
- ct_seg/denoise/denoise_volume.py +164 -0
- ct_seg/denoise/eval.py +94 -0
- ct_seg/denoise/loss.py +78 -0
- ct_seg/denoise/model.py +205 -0
- ct_seg/denoise/tiffs.py +105 -0
- ct_seg/denoise/train.py +385 -0
- ct_seg/denoise/utils.py +68 -0
- ct_seg/labeling.py +145 -0
- ct_seg/model.py +117 -0
- ct_seg/segment.py +461 -0
- ct_seg/som_bands.py +199 -0
- ct_seg/tracking.py +84 -0
- ct_seg/train.py +441 -0
- ct_seg/viewer.py +240 -0
- ct_segmentation_toolkit-0.2.0.dist-info/METADATA +156 -0
- ct_segmentation_toolkit-0.2.0.dist-info/RECORD +24 -0
- ct_segmentation_toolkit-0.2.0.dist-info/WHEEL +5 -0
- ct_segmentation_toolkit-0.2.0.dist-info/licenses/LICENSE +201 -0
- ct_segmentation_toolkit-0.2.0.dist-info/licenses/NOTICE +18 -0
- ct_segmentation_toolkit-0.2.0.dist-info/top_level.txt +1 -0
ct_seg/som_bands.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Label-free spectral-spatial band segmentation via a Self-Organizing Map (SOM).
|
|
3
|
+
|
|
4
|
+
Refactored from the author's proof-of-concept script into importable functions
|
|
5
|
+
(the algorithm and feature set are unchanged). The idea: instead of hand-labeling
|
|
6
|
+
training data, describe every pixel by a multi-scale, spectral-spatial feature
|
|
7
|
+
vector (local density, structure-tensor orientation/coherence, a Gabor filter bank,
|
|
8
|
+
and local variance), train a SOM on a pixel sample, then k-means-cluster the SOM
|
|
9
|
+
nodes into regions. This is unsupervised, modality-agnostic segmentation: the same
|
|
10
|
+
spectral-spatial feature engineering applies directly to hyperspectral cubes.
|
|
11
|
+
|
|
12
|
+
Originally developed to segment enamel "decussation bands" from synchrotron micro-CT
|
|
13
|
+
cross-sections, where different bands differ in local rod density, arrangement
|
|
14
|
+
anisotropy, and periodic texture.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from scipy.ndimage import gaussian_filter, uniform_filter
|
|
21
|
+
from scipy.signal import fftconvolve
|
|
22
|
+
from skimage.feature import structure_tensor, structure_tensor_eigenvalues
|
|
23
|
+
from skimage.filters import gabor_kernel
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_features(
|
|
27
|
+
image,
|
|
28
|
+
density_sigmas=(8, 16, 32),
|
|
29
|
+
st_sigma=8,
|
|
30
|
+
gabor_freqs=(0.05, 0.1, 0.15),
|
|
31
|
+
gabor_thetas=None,
|
|
32
|
+
variance_windows=(16, 32),
|
|
33
|
+
):
|
|
34
|
+
"""Extract a multi-scale, spectral-spatial feature stack from a 2D image.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
image : ndarray, shape (H, W)
|
|
39
|
+
Input field (e.g., a rod-mask or grayscale slice), any real dtype.
|
|
40
|
+
density_sigmas : sequence of float
|
|
41
|
+
Gaussian smoothing scales for local density features.
|
|
42
|
+
st_sigma : float
|
|
43
|
+
Structure-tensor smoothing scale (orientation + coherence).
|
|
44
|
+
gabor_freqs : sequence of float
|
|
45
|
+
Gabor spatial frequencies.
|
|
46
|
+
gabor_thetas : sequence of float or None
|
|
47
|
+
Gabor orientations (radians). Defaults to 6 orientations over [0, pi).
|
|
48
|
+
variance_windows : sequence of int
|
|
49
|
+
Window sizes for local-variance (texture) features.
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
feature_stack : ndarray, shape (H, W, N)
|
|
54
|
+
Per-pixel feature vectors.
|
|
55
|
+
feature_names : list of str
|
|
56
|
+
Human-readable name for each of the N feature channels.
|
|
57
|
+
"""
|
|
58
|
+
image = np.asarray(image, dtype=np.float64)
|
|
59
|
+
if gabor_thetas is None:
|
|
60
|
+
gabor_thetas = np.linspace(0, np.pi, 6, endpoint=False)
|
|
61
|
+
|
|
62
|
+
features = []
|
|
63
|
+
feature_names = []
|
|
64
|
+
|
|
65
|
+
# Multi-scale local density.
|
|
66
|
+
for sigma in density_sigmas:
|
|
67
|
+
features.append(gaussian_filter(image, sigma=sigma))
|
|
68
|
+
feature_names.append(f"density_s{sigma}")
|
|
69
|
+
|
|
70
|
+
# Structure tensor -> coherence (anisotropy) + orientation (sin/cos encoded).
|
|
71
|
+
st_elements = structure_tensor(image, sigma=st_sigma)
|
|
72
|
+
lam1, lam2 = structure_tensor_eigenvalues(st_elements)
|
|
73
|
+
coherence = np.where((lam1 + lam2) > 1e-10, (lam1 - lam2) / (lam1 + lam2), 0.0)
|
|
74
|
+
features.append(coherence)
|
|
75
|
+
feature_names.append("coherence")
|
|
76
|
+
|
|
77
|
+
axx, axy, ayy = st_elements
|
|
78
|
+
orientation = 0.5 * np.arctan2(2 * axy, ayy - axx)
|
|
79
|
+
features.append(np.sin(2 * orientation))
|
|
80
|
+
feature_names.append("orient_sin2")
|
|
81
|
+
features.append(np.cos(2 * orientation))
|
|
82
|
+
feature_names.append("orient_cos2")
|
|
83
|
+
|
|
84
|
+
# Gabor filter bank -> periodic-texture magnitudes at each freq/orientation.
|
|
85
|
+
for freq in gabor_freqs:
|
|
86
|
+
for theta in gabor_thetas:
|
|
87
|
+
kernel = gabor_kernel(freq, theta=theta, sigma_x=3, sigma_y=3)
|
|
88
|
+
resp_real = fftconvolve(image, kernel.real, mode="same")
|
|
89
|
+
resp_imag = fftconvolve(image, kernel.imag, mode="same")
|
|
90
|
+
features.append(np.sqrt(resp_real**2 + resp_imag**2))
|
|
91
|
+
feature_names.append(f"gabor_f{freq:.2f}_t{np.degrees(theta):.0f}")
|
|
92
|
+
|
|
93
|
+
# Local variance (texture roughness) at a couple of window sizes.
|
|
94
|
+
for win in variance_windows:
|
|
95
|
+
local_mean = uniform_filter(image, size=win)
|
|
96
|
+
local_sq_mean = uniform_filter(image**2, size=win)
|
|
97
|
+
local_var = np.clip(local_sq_mean - local_mean**2, 0, None)
|
|
98
|
+
features.append(local_var)
|
|
99
|
+
feature_names.append(f"variance_w{win}")
|
|
100
|
+
|
|
101
|
+
return np.stack(features, axis=-1), feature_names
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _normalize_per_feature(flat_features):
|
|
105
|
+
"""Min-max normalize each feature column to [0, 1]."""
|
|
106
|
+
feat_min = flat_features.min(axis=0)
|
|
107
|
+
feat_max = flat_features.max(axis=0)
|
|
108
|
+
feat_range = np.where(feat_max - feat_min > 1e-10, feat_max - feat_min, 1.0)
|
|
109
|
+
return (flat_features - feat_min) / feat_range
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def som_segment(
|
|
113
|
+
image,
|
|
114
|
+
n_clusters=4,
|
|
115
|
+
som_x=12,
|
|
116
|
+
som_y=12,
|
|
117
|
+
iterations=50_000,
|
|
118
|
+
sample_frac=0.05,
|
|
119
|
+
seed=42,
|
|
120
|
+
feature_kwargs=None,
|
|
121
|
+
):
|
|
122
|
+
"""Label-free segmentation of a 2D image into ``n_clusters`` regions.
|
|
123
|
+
|
|
124
|
+
Extracts spectral-spatial features, trains a SOM on a pixel sample, maps every
|
|
125
|
+
pixel to its best-matching unit, then k-means-clusters the SOM nodes into regions.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
image : ndarray, shape (H, W)
|
|
130
|
+
n_clusters : int
|
|
131
|
+
Number of output regions.
|
|
132
|
+
som_x, som_y : int
|
|
133
|
+
SOM grid dimensions.
|
|
134
|
+
iterations : int
|
|
135
|
+
SOM training iterations.
|
|
136
|
+
sample_frac : float
|
|
137
|
+
Fraction of pixels used to train the SOM.
|
|
138
|
+
seed : int
|
|
139
|
+
Random seed for reproducibility.
|
|
140
|
+
feature_kwargs : dict or None
|
|
141
|
+
Extra keyword arguments forwarded to :func:`extract_features`.
|
|
142
|
+
|
|
143
|
+
Returns
|
|
144
|
+
-------
|
|
145
|
+
cluster_map : ndarray, shape (H, W), dtype int
|
|
146
|
+
Per-pixel region label in ``[0, n_clusters)``.
|
|
147
|
+
"""
|
|
148
|
+
# Imported here so the module stays import-light if SOM is unused.
|
|
149
|
+
from minisom import MiniSom
|
|
150
|
+
from sklearn.cluster import KMeans
|
|
151
|
+
|
|
152
|
+
rng = np.random.default_rng(seed)
|
|
153
|
+
h, w = image.shape[:2]
|
|
154
|
+
|
|
155
|
+
feature_stack, _ = extract_features(image, **(feature_kwargs or {}))
|
|
156
|
+
n_feat = feature_stack.shape[-1]
|
|
157
|
+
flat_norm = _normalize_per_feature(feature_stack.reshape(-1, n_feat))
|
|
158
|
+
|
|
159
|
+
# Train the SOM on a random pixel sample.
|
|
160
|
+
n_sample = max(1, int(flat_norm.shape[0] * sample_frac))
|
|
161
|
+
sample_idx = rng.choice(flat_norm.shape[0], size=n_sample, replace=False)
|
|
162
|
+
som = MiniSom(
|
|
163
|
+
som_x,
|
|
164
|
+
som_y,
|
|
165
|
+
n_feat,
|
|
166
|
+
sigma=2.0,
|
|
167
|
+
learning_rate=0.5,
|
|
168
|
+
neighborhood_function="gaussian",
|
|
169
|
+
random_seed=seed,
|
|
170
|
+
)
|
|
171
|
+
som.pca_weights_init(flat_norm[sample_idx])
|
|
172
|
+
som.train(flat_norm[sample_idx], iterations, verbose=False)
|
|
173
|
+
|
|
174
|
+
# Map each pixel to its best-matching unit (flattened node index).
|
|
175
|
+
#
|
|
176
|
+
# Vectorized on purpose: the naive `[som.winner(x) for x in flat_norm]`
|
|
177
|
+
# makes one Python-level MiniSom call per pixel — 262,144 calls for a
|
|
178
|
+
# 512x512 slice, measured at ~59% of som_segment's total runtime
|
|
179
|
+
# (cProfile, 2026-07-15). Computing all pixel-to-node distances as
|
|
180
|
+
# chunked BLAS matrix products gives the SAME argmin: squared Euclidean
|
|
181
|
+
# distance via (a-b)^2 = a^2 - 2ab + b^2 preserves the ordering (sqrt is
|
|
182
|
+
# monotonic), and row-major argmin over the flattened (som_x*som_y) axis
|
|
183
|
+
# matches MiniSom.winner's unravel_index tie-breaking, so node index
|
|
184
|
+
# x*som_y + y is reproduced exactly.
|
|
185
|
+
weights_flat = som.get_weights().reshape(-1, n_feat)
|
|
186
|
+
w_sq = (weights_flat**2).sum(axis=1)[None, :]
|
|
187
|
+
bmu_flat = np.empty(flat_norm.shape[0], dtype=np.int64)
|
|
188
|
+
chunk = 65536 # ~64k x 144 float64 distance block ~= 75 MB, RAM-friendly
|
|
189
|
+
for i in range(0, flat_norm.shape[0], chunk):
|
|
190
|
+
block = flat_norm[i : i + chunk]
|
|
191
|
+
d2 = (block**2).sum(axis=1)[:, None] - 2.0 * (block @ weights_flat.T) + w_sq
|
|
192
|
+
bmu_flat[i : i + chunk] = np.argmin(d2, axis=1)
|
|
193
|
+
|
|
194
|
+
# Cluster the SOM nodes by their weight vectors (weights_flat, computed
|
|
195
|
+
# above for the BMU mapping), then map back to the image.
|
|
196
|
+
node_labels = KMeans(n_clusters=n_clusters, random_state=seed, n_init=10).fit_predict(
|
|
197
|
+
weights_flat
|
|
198
|
+
)
|
|
199
|
+
return node_labels[bmu_flat].reshape(h, w)
|
ct_seg/tracking.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Optional MLflow experiment tracking with a graceful no-op fallback.
|
|
3
|
+
|
|
4
|
+
MLflow is imported lazily (only when tracking is actually used and enabled), so simply
|
|
5
|
+
importing ``ct_seg`` never pays MLflow's import cost and the package has no hard MLflow
|
|
6
|
+
dependency. If MLflow is installed (``pip install -e ".[mlops]"``) and tracking is
|
|
7
|
+
enabled, the training script logs run parameters, per-epoch metrics, and the best
|
|
8
|
+
checkpoint. Otherwise every function here is a harmless no-op.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
_UNSET = object()
|
|
16
|
+
_mlflow_mod = _UNSET # cache: _UNSET (untried) -> module or None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _mlflow():
|
|
20
|
+
"""Return the mlflow module if importable (lazily, cached), else None."""
|
|
21
|
+
global _mlflow_mod
|
|
22
|
+
if _mlflow_mod is _UNSET:
|
|
23
|
+
try:
|
|
24
|
+
import mlflow
|
|
25
|
+
|
|
26
|
+
_mlflow_mod = mlflow
|
|
27
|
+
except ImportError: # pragma: no cover - only when mlflow is not installed
|
|
28
|
+
_mlflow_mod = None
|
|
29
|
+
return _mlflow_mod
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_available() -> bool:
|
|
33
|
+
"""Return True if MLflow is importable."""
|
|
34
|
+
return _mlflow() is not None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def start(run_name=None, enabled=True) -> bool:
|
|
38
|
+
"""Begin an MLflow run. No-op if disabled or MLflow is absent.
|
|
39
|
+
|
|
40
|
+
Returns True if a real MLflow run was started, else False.
|
|
41
|
+
"""
|
|
42
|
+
if not enabled:
|
|
43
|
+
return False
|
|
44
|
+
m = _mlflow()
|
|
45
|
+
if m is not None:
|
|
46
|
+
m.start_run(run_name=run_name)
|
|
47
|
+
return True
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def end(enabled=True) -> None:
|
|
52
|
+
"""End the active MLflow run, if any."""
|
|
53
|
+
if not enabled:
|
|
54
|
+
return
|
|
55
|
+
m = _mlflow()
|
|
56
|
+
if m is not None and m.active_run() is not None:
|
|
57
|
+
m.end_run()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def log_params(params, enabled=True) -> None:
|
|
61
|
+
"""Log a dict of run parameters."""
|
|
62
|
+
if not enabled:
|
|
63
|
+
return
|
|
64
|
+
m = _mlflow()
|
|
65
|
+
if m is not None:
|
|
66
|
+
m.log_params(params)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def log_metrics(metrics, step=None, enabled=True) -> None:
|
|
70
|
+
"""Log a dict of metrics, optionally at a given step (e.g., epoch)."""
|
|
71
|
+
if not enabled:
|
|
72
|
+
return
|
|
73
|
+
m = _mlflow()
|
|
74
|
+
if m is not None:
|
|
75
|
+
m.log_metrics(metrics, step=step)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def log_artifact(path, enabled=True) -> None:
|
|
79
|
+
"""Log a file artifact (e.g., a checkpoint) if it exists."""
|
|
80
|
+
if not enabled:
|
|
81
|
+
return
|
|
82
|
+
m = _mlflow()
|
|
83
|
+
if m is not None and path and os.path.exists(path):
|
|
84
|
+
m.log_artifact(path)
|