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/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)