grainmorph 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.
- grainmorph/__init__.py +5 -0
- grainmorph/analyzer.py +49 -0
- grainmorph/fitting.py +79 -0
- grainmorph/mask_ops.py +128 -0
- grainmorph/metrics.py +112 -0
- grainmorph/schema.py +50 -0
- grainmorph/utils/__init__.py +29 -0
- grainmorph/utils/create_mask.py +115 -0
- grainmorph/utils/geometry.py +23 -0
- grainmorph/utils/parametric_mask.py +199 -0
- grainmorph/utils/plot.py +99 -0
- grainmorph/validation.py +44 -0
- grainmorph/wadell/__init__.py +3 -0
- grainmorph/wadell/boundary.py +14 -0
- grainmorph/wadell/corner.py +163 -0
- grainmorph/wadell/discretize.py +66 -0
- grainmorph/wadell/roundness.py +61 -0
- grainmorph/wadell/smoothing.py +41 -0
- grainmorph-0.2.0.dist-info/METADATA +208 -0
- grainmorph-0.2.0.dist-info/RECORD +23 -0
- grainmorph-0.2.0.dist-info/WHEEL +5 -0
- grainmorph-0.2.0.dist-info/licenses/LICENSE +21 -0
- grainmorph-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from ..metrics import compute_roundness
|
|
4
|
+
from .geometry import create_poly_mask, polar_vertices
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def create_particle_mask(
|
|
8
|
+
shape: tuple[int, int],
|
|
9
|
+
center: tuple[float, float],
|
|
10
|
+
radius: float,
|
|
11
|
+
sphericity: float,
|
|
12
|
+
roundness: float,
|
|
13
|
+
*,
|
|
14
|
+
num_angles: int = 256,
|
|
15
|
+
frequencies: list[int] | np.ndarray | None = None,
|
|
16
|
+
decay: float = 1.0,
|
|
17
|
+
seed: int | None = None,
|
|
18
|
+
max_iter: int = 12,
|
|
19
|
+
amp_max: float = 0.45,
|
|
20
|
+
metric_tol: float = 0.001,
|
|
21
|
+
report_tol: float = 0.1,
|
|
22
|
+
return_info: bool = False,
|
|
23
|
+
) -> np.ndarray | tuple[np.ndarray, dict]:
|
|
24
|
+
"""Generates a synthetic particle mask with target morphological features.
|
|
25
|
+
|
|
26
|
+
Uses a Fourier-based approach to generate a closed polygon-based mask that
|
|
27
|
+
matches specified sphericity and roundness values through iterative optimization
|
|
28
|
+
of the roughness amplitude.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
shape: Dimensions of the output mask (height, width).
|
|
32
|
+
center: Center coordinates of the particle (y, x).
|
|
33
|
+
radius: Base radius of the particle.
|
|
34
|
+
sphericity: Target sphericity (0 to 1).
|
|
35
|
+
roundness: Target roundness (0 to 1).
|
|
36
|
+
num_angles: Number of vertices in the generated polygon.
|
|
37
|
+
frequencies: List of frequencies used to generate surface roughness.
|
|
38
|
+
decay: Frequency decay rate of the roughness signal.
|
|
39
|
+
seed: Random seed for phase generation.
|
|
40
|
+
max_iter: Max iterations for roundness calibration.
|
|
41
|
+
amp_max: Maximum allowed amplitude for the roughness signal.
|
|
42
|
+
metric_tol: Internal precision for reaching the target roundness.
|
|
43
|
+
report_tol: Error threshold for the 'target_met' report flag.
|
|
44
|
+
return_info: If True, returns a metadata dictionary along with the mask.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The binary mask as a numpy array, or a tuple of (mask, info_dict).
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
radius = float(radius)
|
|
51
|
+
sphericity = float(sphericity)
|
|
52
|
+
roundness = float(roundness)
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"radius, sphericity, and roundness must be finite numbers."
|
|
56
|
+
) from None
|
|
57
|
+
|
|
58
|
+
if not np.isfinite(radius) or radius < 0:
|
|
59
|
+
raise ValueError("radius must be finite and >= 0.")
|
|
60
|
+
if not np.isfinite(sphericity) or not np.isfinite(roundness):
|
|
61
|
+
raise ValueError("sphericity and roundness must be finite.")
|
|
62
|
+
|
|
63
|
+
sphericity = float(np.clip(sphericity, 0.001, 1.0))
|
|
64
|
+
roundness = float(np.clip(roundness, 0.0, 1.0))
|
|
65
|
+
if num_angles < 3:
|
|
66
|
+
raise ValueError("num_angles must be >= 3.")
|
|
67
|
+
|
|
68
|
+
frequencies_arr = np.asarray(
|
|
69
|
+
frequencies if frequencies is not None else np.arange(2, 9), dtype=int
|
|
70
|
+
)
|
|
71
|
+
phases = np.random.default_rng(seed).uniform(
|
|
72
|
+
0.0, 2.0 * np.pi, size=len(frequencies_arr)
|
|
73
|
+
)
|
|
74
|
+
cy, cx = center
|
|
75
|
+
amplitude = 0.0
|
|
76
|
+
|
|
77
|
+
if radius == 0.0:
|
|
78
|
+
mask = np.zeros(shape, dtype=bool)
|
|
79
|
+
iy, ix = round(cy), round(cx)
|
|
80
|
+
if 0 <= iy < shape[0] and 0 <= ix < shape[1]:
|
|
81
|
+
mask[iy, ix] = True
|
|
82
|
+
else:
|
|
83
|
+
amplitude = _calibrate_amplitude(
|
|
84
|
+
radius=radius,
|
|
85
|
+
sphericity=sphericity,
|
|
86
|
+
roundness=roundness,
|
|
87
|
+
frequencies=frequencies_arr,
|
|
88
|
+
phases=phases,
|
|
89
|
+
decay=decay,
|
|
90
|
+
max_iter=max_iter,
|
|
91
|
+
amp_max=amp_max,
|
|
92
|
+
metric_tol=metric_tol,
|
|
93
|
+
)
|
|
94
|
+
thetas = np.linspace(0.0, 2.0 * np.pi, num_angles, endpoint=False)
|
|
95
|
+
base_radii, roughness_radii = _radial_components(
|
|
96
|
+
radius, sphericity, thetas, frequencies_arr, phases, decay
|
|
97
|
+
)
|
|
98
|
+
radii = np.maximum(base_radii + amplitude * roughness_radii, 0.5)
|
|
99
|
+
vertices = polar_vertices((cy, cx), radii, thetas)
|
|
100
|
+
mask = create_poly_mask(shape, vertices).astype(bool)
|
|
101
|
+
|
|
102
|
+
if not return_info:
|
|
103
|
+
return mask
|
|
104
|
+
|
|
105
|
+
measured = compute_roundness(mask.astype(np.uint8, copy=False)) if radius else None
|
|
106
|
+
achieved = float(measured["val"]) if measured else 0.0
|
|
107
|
+
abs_error = float(abs(roundness - achieved))
|
|
108
|
+
info = {
|
|
109
|
+
"sphericity_target": sphericity,
|
|
110
|
+
"roundness_target": roundness,
|
|
111
|
+
"roundness_achieved": achieved,
|
|
112
|
+
"abs_error": abs_error,
|
|
113
|
+
"report_tol": float(report_tol),
|
|
114
|
+
"target_met": bool(abs_error <= report_tol),
|
|
115
|
+
"amplitude": float(amplitude),
|
|
116
|
+
"frequencies": frequencies_arr.tolist(),
|
|
117
|
+
"phases": phases.tolist(),
|
|
118
|
+
}
|
|
119
|
+
return mask, info
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _calibrate_amplitude(
|
|
123
|
+
*,
|
|
124
|
+
radius: float,
|
|
125
|
+
sphericity: float,
|
|
126
|
+
roundness: float,
|
|
127
|
+
frequencies: np.ndarray,
|
|
128
|
+
phases: np.ndarray,
|
|
129
|
+
decay: float,
|
|
130
|
+
max_iter: int,
|
|
131
|
+
amp_max: float,
|
|
132
|
+
metric_tol: float,
|
|
133
|
+
) -> float:
|
|
134
|
+
thetas = np.linspace(0.0, 2.0 * np.pi, 128, endpoint=False)
|
|
135
|
+
base_radii, roughness_radii = _radial_components(
|
|
136
|
+
radius, sphericity, thetas, frequencies, phases, decay
|
|
137
|
+
)
|
|
138
|
+
scale = min(1.0, 128.0 / radius)
|
|
139
|
+
size = int(np.ceil((radius * scale * (1.0 + amp_max) + 2) * 2))
|
|
140
|
+
shape = (size, size)
|
|
141
|
+
center = size / 2.0
|
|
142
|
+
|
|
143
|
+
def measure(amplitude: float) -> float:
|
|
144
|
+
radii = (base_radii + amplitude * roughness_radii) * scale
|
|
145
|
+
vertices = polar_vertices((center, center), np.maximum(radii, 0.5), thetas)
|
|
146
|
+
result = compute_roundness(create_poly_mask(shape, vertices))
|
|
147
|
+
return float(result["val"]) if result else 0.0
|
|
148
|
+
|
|
149
|
+
achieved = measure(0.0)
|
|
150
|
+
amplitude = 0.0
|
|
151
|
+
if achieved > roundness + metric_tol:
|
|
152
|
+
low, high = 0.0, float(amp_max)
|
|
153
|
+
for _ in range(max_iter):
|
|
154
|
+
mid = (low + high) / 2
|
|
155
|
+
measured = measure(mid)
|
|
156
|
+
if abs(measured - roundness) < abs(achieved - roundness):
|
|
157
|
+
achieved, amplitude = measured, mid
|
|
158
|
+
if abs(measured - roundness) <= metric_tol:
|
|
159
|
+
break
|
|
160
|
+
if measured > roundness:
|
|
161
|
+
low = mid
|
|
162
|
+
else:
|
|
163
|
+
high = mid
|
|
164
|
+
|
|
165
|
+
return amplitude
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _radial_components(
|
|
169
|
+
radius: float,
|
|
170
|
+
sphericity: float,
|
|
171
|
+
thetas: np.ndarray,
|
|
172
|
+
frequencies: np.ndarray,
|
|
173
|
+
phases: np.ndarray,
|
|
174
|
+
decay: float,
|
|
175
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
176
|
+
base_radii = ellipse_radius(
|
|
177
|
+
radius, radius * sphericity, np.cos(thetas), np.sin(thetas)
|
|
178
|
+
)
|
|
179
|
+
roughness_radii = roughness_signal(thetas, frequencies, phases, decay) * radius
|
|
180
|
+
return base_radii, roughness_radii
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def ellipse_radius(
|
|
184
|
+
a: float, b: float, cos_t: np.ndarray, sin_t: np.ndarray
|
|
185
|
+
) -> np.ndarray:
|
|
186
|
+
denom = np.sqrt((b * cos_t) ** 2 + (a * sin_t) ** 2)
|
|
187
|
+
return a * b / np.maximum(denom, 1e-12)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def roughness_signal(
|
|
191
|
+
thetas: np.ndarray, frequencies: np.ndarray, phases: np.ndarray, decay: float
|
|
192
|
+
) -> np.ndarray:
|
|
193
|
+
weights = 1.0 / np.maximum(frequencies.astype(float), 1.0) ** decay
|
|
194
|
+
components = weights[:, None] * np.cos(
|
|
195
|
+
frequencies[:, None] * thetas[None, :] + phases[:, None]
|
|
196
|
+
)
|
|
197
|
+
signal = np.sum(components, axis=0)
|
|
198
|
+
max_abs = np.max(np.abs(signal))
|
|
199
|
+
return signal / max_abs if max_abs > 1e-12 else signal
|
grainmorph/utils/plot.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
import numpy as np
|
|
6
|
+
from matplotlib.patches import Circle, Ellipse, Polygon
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def plot_analysis_results(
|
|
10
|
+
mask: np.ndarray,
|
|
11
|
+
results: Mapping[str, Any],
|
|
12
|
+
figsize: tuple[int, int] = (8, 8),
|
|
13
|
+
title: str = "Analysis Results",
|
|
14
|
+
show: bool = True,
|
|
15
|
+
) -> None:
|
|
16
|
+
|
|
17
|
+
_, ax = plt.subplots(figsize=figsize)
|
|
18
|
+
ax.imshow(mask, cmap="gray", alpha=0.3)
|
|
19
|
+
|
|
20
|
+
sphericity_data = results.get("sphericity")
|
|
21
|
+
if sphericity_data:
|
|
22
|
+
_add_circle_overlay(
|
|
23
|
+
ax,
|
|
24
|
+
sphericity_data["inscribed"],
|
|
25
|
+
color="blue",
|
|
26
|
+
label="Inscribed",
|
|
27
|
+
marker="+",
|
|
28
|
+
)
|
|
29
|
+
_add_circle_overlay(
|
|
30
|
+
ax,
|
|
31
|
+
sphericity_data["enclosing"],
|
|
32
|
+
color="red",
|
|
33
|
+
label="Enclosing",
|
|
34
|
+
marker="x",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
aspect_ratio_data = results.get("aspect_ratio")
|
|
38
|
+
if aspect_ratio_data:
|
|
39
|
+
ellipse = aspect_ratio_data.get("ellipse")
|
|
40
|
+
if ellipse:
|
|
41
|
+
_add_ellipse_overlay(ax, ellipse)
|
|
42
|
+
|
|
43
|
+
metric_labels = {
|
|
44
|
+
"roundness": "Roundness",
|
|
45
|
+
"circularity": "Circularity",
|
|
46
|
+
"sphericity": "Sphericity",
|
|
47
|
+
"aspect_ratio": "Aspect Ratio",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
stats = []
|
|
51
|
+
for key, label in metric_labels.items():
|
|
52
|
+
data = results.get(key)
|
|
53
|
+
if data and "val" in data:
|
|
54
|
+
stats.append(f"{label}: {data['val']:.4f}")
|
|
55
|
+
|
|
56
|
+
ax.set_title(f"{title}\n{', '.join(stats)}")
|
|
57
|
+
handles, labels = ax.get_legend_handles_labels()
|
|
58
|
+
if handles:
|
|
59
|
+
by_label = dict(zip(labels, handles))
|
|
60
|
+
ax.legend(by_label.values(), by_label.keys(), loc="upper right")
|
|
61
|
+
|
|
62
|
+
ax.grid(True, linestyle="--", alpha=0.5)
|
|
63
|
+
plt.tight_layout()
|
|
64
|
+
if show:
|
|
65
|
+
plt.show()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _add_circle_overlay(
|
|
69
|
+
ax, circle_data: Mapping[str, float], *, color: str, label: str, marker: str
|
|
70
|
+
) -> None:
|
|
71
|
+
x = circle_data["x"]
|
|
72
|
+
y = circle_data["y"]
|
|
73
|
+
r = circle_data["r"]
|
|
74
|
+
ax.add_patch(Circle((x, y), r, color=color, fill=False, label=label))
|
|
75
|
+
ax.scatter(x, y, color=color, marker=marker)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _add_ellipse_overlay(ax, ellipse: Mapping[str, Any]) -> None:
|
|
79
|
+
ax.add_patch(
|
|
80
|
+
Ellipse(
|
|
81
|
+
(ellipse["x"], ellipse["y"]),
|
|
82
|
+
ellipse["w"],
|
|
83
|
+
ellipse["h"],
|
|
84
|
+
angle=ellipse["angle"],
|
|
85
|
+
color="yellow",
|
|
86
|
+
fill=False,
|
|
87
|
+
label="Fitted Ellipse",
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
if ellipse.get("bbox"):
|
|
91
|
+
ax.add_patch(
|
|
92
|
+
Polygon(
|
|
93
|
+
ellipse["bbox"],
|
|
94
|
+
closed=True,
|
|
95
|
+
color="green",
|
|
96
|
+
fill=False,
|
|
97
|
+
label="Bounding Box",
|
|
98
|
+
)
|
|
99
|
+
)
|
grainmorph/validation.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from numbers import Integral, Real
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from .schema import Mask
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_analysis_params(eps: float, target_dim: int) -> tuple[float, int]:
|
|
9
|
+
if isinstance(eps, (bool, np.bool_)) or not isinstance(eps, Real):
|
|
10
|
+
raise TypeError("eps must be a real number.")
|
|
11
|
+
if not np.isfinite(eps) or eps <= 0:
|
|
12
|
+
raise ValueError("eps must be > 0 and finite.")
|
|
13
|
+
|
|
14
|
+
if isinstance(target_dim, (bool, np.bool_)) or not isinstance(target_dim, Integral):
|
|
15
|
+
raise TypeError("target_dim must be an integer.")
|
|
16
|
+
if target_dim <= 0:
|
|
17
|
+
raise ValueError("target_dim must be > 0.")
|
|
18
|
+
|
|
19
|
+
return (float(eps), int(target_dim))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def to_binary(mask: np.ndarray) -> Mask:
|
|
23
|
+
if not isinstance(mask, np.ndarray):
|
|
24
|
+
raise TypeError("mask must be a numpy.ndarray.")
|
|
25
|
+
|
|
26
|
+
if mask.ndim != 2:
|
|
27
|
+
raise ValueError(f"mask must be 2D, got {mask.ndim}D.")
|
|
28
|
+
|
|
29
|
+
if mask.dtype == np.bool_:
|
|
30
|
+
return mask.astype(np.uint8)
|
|
31
|
+
|
|
32
|
+
if np.issubdtype(mask.dtype, np.floating):
|
|
33
|
+
if not np.isfinite(mask).all():
|
|
34
|
+
raise ValueError("mask must not contain NaN or inf.")
|
|
35
|
+
if not np.all((mask == 0) | (mask == 1)):
|
|
36
|
+
raise ValueError("mask must be binary with values in {0, 1}.")
|
|
37
|
+
return mask.astype(np.uint8)
|
|
38
|
+
|
|
39
|
+
if np.issubdtype(mask.dtype, np.integer):
|
|
40
|
+
if mask.size > 0 and (mask.min() < 0 or mask.max() > 1):
|
|
41
|
+
raise ValueError("mask must be binary with values in {0, 1}.")
|
|
42
|
+
return mask.astype(np.uint8)
|
|
43
|
+
|
|
44
|
+
raise ValueError("mask must be binary with values in {0, 1}.")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def extract_boundary(mask: np.ndarray) -> np.ndarray:
|
|
6
|
+
if mask.dtype != np.uint8:
|
|
7
|
+
mask = mask.astype(np.uint8)
|
|
8
|
+
|
|
9
|
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
|
|
10
|
+
if not contours:
|
|
11
|
+
return np.empty((0, 2), dtype=np.float64)
|
|
12
|
+
|
|
13
|
+
contour = max(contours, key=cv2.contourArea)
|
|
14
|
+
return contour.reshape(-1, 2).astype(np.float64)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def compute_corner_radii(
|
|
5
|
+
keypoints: np.ndarray,
|
|
6
|
+
r_max: float,
|
|
7
|
+
r_max_pos: np.ndarray,
|
|
8
|
+
circle_fit_thresh: float,
|
|
9
|
+
) -> np.ndarray:
|
|
10
|
+
convex_points = _select_convex_points(keypoints).astype(np.float32)
|
|
11
|
+
keypoints = keypoints.astype(np.float32)
|
|
12
|
+
num_points = len(convex_points)
|
|
13
|
+
|
|
14
|
+
dist_boundary = np.linalg.norm(convex_points - r_max_pos, axis=1)
|
|
15
|
+
dist_cumsum = np.concatenate([[0.0], np.cumsum(dist_boundary)])
|
|
16
|
+
|
|
17
|
+
radii: list[float] = []
|
|
18
|
+
start = 0
|
|
19
|
+
|
|
20
|
+
while True:
|
|
21
|
+
ends = np.arange(start + 3, num_points + 1)
|
|
22
|
+
if len(ends) == 0:
|
|
23
|
+
break
|
|
24
|
+
|
|
25
|
+
sizes = ends - start
|
|
26
|
+
radius, center = _fit_circles(convex_points[start:], sizes)
|
|
27
|
+
|
|
28
|
+
dist_center = np.linalg.norm(center - r_max_pos, axis=1)
|
|
29
|
+
dist_mean = (dist_cumsum[ends] - dist_cumsum[start]) / sizes
|
|
30
|
+
|
|
31
|
+
with np.errstate(invalid="ignore"):
|
|
32
|
+
valid = (
|
|
33
|
+
np.isfinite(radius)
|
|
34
|
+
& (radius > 1e-06)
|
|
35
|
+
& (dist_center < dist_mean)
|
|
36
|
+
& (radius < r_max)
|
|
37
|
+
)
|
|
38
|
+
idx = np.flatnonzero(valid)
|
|
39
|
+
|
|
40
|
+
if len(idx) > 0:
|
|
41
|
+
dist_all = np.linalg.norm(keypoints[None] - center[idx, None], axis=2)
|
|
42
|
+
fits = np.all(dist_all / radius[idx, None] > circle_fit_thresh, axis=1)
|
|
43
|
+
hits = idx[fits]
|
|
44
|
+
|
|
45
|
+
if len(hits) > 0:
|
|
46
|
+
best = hits[-1]
|
|
47
|
+
radii.append(float(radius[best]))
|
|
48
|
+
start = int(ends[best])
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
start += 1
|
|
52
|
+
|
|
53
|
+
return np.array(radii)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _select_convex_points(keypoints: np.ndarray) -> np.ndarray:
|
|
57
|
+
wrapped = np.vstack([keypoints[-1], keypoints, keypoints[0]])
|
|
58
|
+
vec1 = wrapped[1:-1] - wrapped[:-2]
|
|
59
|
+
vec2 = wrapped[2:] - wrapped[1:-1]
|
|
60
|
+
|
|
61
|
+
angle = np.arctan2(
|
|
62
|
+
vec1[:, 0] * vec2[:, 1] - vec1[:, 1] * vec2[:, 0],
|
|
63
|
+
vec1[:, 0] * vec2[:, 0] + vec1[:, 1] * vec2[:, 1],
|
|
64
|
+
)
|
|
65
|
+
orientation = 1.0 if _signed_area(keypoints) >= 0.0 else -1.0
|
|
66
|
+
oriented = angle * orientation
|
|
67
|
+
|
|
68
|
+
concave_locs = np.where(oriented < 0.0)[0]
|
|
69
|
+
idx = concave_locs[0] if len(concave_locs) > 0 else int(np.argmax(oriented))
|
|
70
|
+
keypoints = np.roll(keypoints, -idx, axis=0)
|
|
71
|
+
oriented = np.roll(oriented, -idx)
|
|
72
|
+
return keypoints[oriented > 0.0]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _signed_area(points: np.ndarray) -> float:
|
|
76
|
+
x, y = points.T
|
|
77
|
+
area = np.dot(x[:-1], y[1:]) + x[-1] * y[0] - np.dot(y[:-1], x[1:]) - y[-1] * x[0]
|
|
78
|
+
return 0.5 * float(area)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _fit_circles(
|
|
82
|
+
points: np.ndarray, sizes: np.ndarray
|
|
83
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
84
|
+
# Moments are taken relative to the first point so the centered sums stay
|
|
85
|
+
# accurate for short prefixes.
|
|
86
|
+
origin = points[0].astype(np.float64)
|
|
87
|
+
x, y = (points.astype(np.float64) - origin).T
|
|
88
|
+
sums = np.cumsum(
|
|
89
|
+
np.stack([x, y, x * x, x * y, y * y, x**3, x * x * y, x * y * y, y**3]),
|
|
90
|
+
axis=1,
|
|
91
|
+
)[:, sizes - 1]
|
|
92
|
+
sx, sy, sxx, sxy, syy, sxxx, sxxy, sxyy, syyy = sums
|
|
93
|
+
|
|
94
|
+
n = sizes.astype(np.float64)
|
|
95
|
+
mx = sx / n
|
|
96
|
+
my = sy / n
|
|
97
|
+
|
|
98
|
+
suu = sxx - n * mx * mx
|
|
99
|
+
suv = sxy - n * mx * my
|
|
100
|
+
svv = syy - n * my * my
|
|
101
|
+
suuu = sxxx - 3 * mx * sxx + 2 * n * mx**3
|
|
102
|
+
svvv = syyy - 3 * my * syy + 2 * n * my**3
|
|
103
|
+
suuv = sxxy - my * sxx - 2 * mx * sxy + 2 * n * mx * mx * my
|
|
104
|
+
suvv = sxyy - mx * syy - 2 * my * sxy + 2 * n * mx * my * my
|
|
105
|
+
|
|
106
|
+
det = suu * svv - suv * suv
|
|
107
|
+
# Nearly collinear prefixes make the normal equations ill-conditioned.
|
|
108
|
+
singular = det <= 1e-06 * np.square(suu + svv)
|
|
109
|
+
det = np.where(singular, 1.0, det)
|
|
110
|
+
|
|
111
|
+
rhs_u = 0.5 * (suuu + suvv)
|
|
112
|
+
rhs_v = 0.5 * (suuv + svvv)
|
|
113
|
+
uc = (svv * rhs_u - suv * rhs_v) / det
|
|
114
|
+
vc = (suu * rhs_v - suv * rhs_u) / det
|
|
115
|
+
|
|
116
|
+
radius = np.sqrt(uc * uc + vc * vc + (suu + svv) / n)
|
|
117
|
+
center = np.stack([uc + mx, vc + my], axis=1) + origin
|
|
118
|
+
|
|
119
|
+
for i in np.flatnonzero(singular):
|
|
120
|
+
radius[i], center[i] = _fit_circle(points[: sizes[i]])
|
|
121
|
+
|
|
122
|
+
return radius, center
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _fit_circle(points: np.ndarray) -> tuple[float, np.ndarray]:
|
|
126
|
+
points = points.reshape(-1, 2).copy()
|
|
127
|
+
num_pts = points.shape[0]
|
|
128
|
+
|
|
129
|
+
x_min = float(points[:, 0].min())
|
|
130
|
+
x_max = float(points[:, 0].max())
|
|
131
|
+
y_min = float(points[:, 1].min())
|
|
132
|
+
y_max = float(points[:, 1].max())
|
|
133
|
+
|
|
134
|
+
x_span = x_max - x_min
|
|
135
|
+
y_span = y_max - y_min
|
|
136
|
+
scale = 0.5 * max(x_span, y_span)
|
|
137
|
+
|
|
138
|
+
if scale < 1e-12:
|
|
139
|
+
return (0.0, points[0].copy())
|
|
140
|
+
|
|
141
|
+
offset = np.array([(x_max + x_min) * 0.5, (y_max + y_min) * 0.5], dtype=np.float64)
|
|
142
|
+
points_norm = (points - offset) / scale
|
|
143
|
+
|
|
144
|
+
B = np.empty((num_pts, 3), dtype=points_norm.dtype)
|
|
145
|
+
B[:, :2] = points_norm
|
|
146
|
+
B[:, 2] = 1.0
|
|
147
|
+
|
|
148
|
+
d = np.square(points_norm).sum(axis=1)
|
|
149
|
+
y, *_ = np.linalg.lstsq(B, d, rcond=np.finfo(float).eps)
|
|
150
|
+
|
|
151
|
+
norm_center = 0.5 * y[:2]
|
|
152
|
+
norm_radius = float(np.sqrt(y[2] + np.square(norm_center).sum()))
|
|
153
|
+
center = norm_center * scale + offset
|
|
154
|
+
|
|
155
|
+
if not np.all(np.isfinite(center)):
|
|
156
|
+
return (0.0, points[0].copy())
|
|
157
|
+
|
|
158
|
+
radius = norm_radius * scale
|
|
159
|
+
|
|
160
|
+
if not np.isfinite(radius):
|
|
161
|
+
return (0.0, center)
|
|
162
|
+
|
|
163
|
+
return (radius, center)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def discretize_boundary(boundary: np.ndarray, max_dev_thresh: float) -> np.ndarray:
|
|
7
|
+
num_pts = len(boundary)
|
|
8
|
+
|
|
9
|
+
segment_start = 0
|
|
10
|
+
segment_end = num_pts
|
|
11
|
+
keypoints = [boundary[0]]
|
|
12
|
+
|
|
13
|
+
boundary = np.vstack([boundary, boundary[0]])
|
|
14
|
+
total_length = len(boundary)
|
|
15
|
+
|
|
16
|
+
while segment_start < num_pts:
|
|
17
|
+
max_dev, position_index = max_line_deviation(
|
|
18
|
+
boundary[segment_start:segment_end]
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
while max_dev > max_dev_thresh:
|
|
22
|
+
new_end = position_index + segment_start
|
|
23
|
+
|
|
24
|
+
if new_end <= segment_start:
|
|
25
|
+
segment_end = segment_start + 2
|
|
26
|
+
break
|
|
27
|
+
|
|
28
|
+
segment_end = new_end
|
|
29
|
+
max_dev, position_index = max_line_deviation(
|
|
30
|
+
boundary[segment_start:segment_end]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if segment_end != total_length or segment_start == 0:
|
|
34
|
+
idx = min(segment_end - 1, total_length - 1)
|
|
35
|
+
keypoints.append(boundary[idx])
|
|
36
|
+
|
|
37
|
+
segment_start = segment_end
|
|
38
|
+
segment_end = total_length
|
|
39
|
+
|
|
40
|
+
return np.array(keypoints)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def max_line_deviation(points: np.ndarray) -> tuple[float, int]:
|
|
44
|
+
if len(points) <= 1:
|
|
45
|
+
return (0.0, 0)
|
|
46
|
+
|
|
47
|
+
x = points[:, 0]
|
|
48
|
+
y = points[:, 1]
|
|
49
|
+
x0, y0 = points[0].tolist()
|
|
50
|
+
x1, y1 = points[-1].tolist()
|
|
51
|
+
|
|
52
|
+
dx = x1 - x0
|
|
53
|
+
dy = y0 - y1
|
|
54
|
+
dist_end_sq = dx * dx + dy * dy
|
|
55
|
+
|
|
56
|
+
if dist_end_sq < 1e-12:
|
|
57
|
+
dist_sq = np.square(x - x0) + np.square(y - y0)
|
|
58
|
+
idx = int(dist_sq.argmax())
|
|
59
|
+
|
|
60
|
+
return (math.sqrt(float(dist_sq[idx])), idx)
|
|
61
|
+
|
|
62
|
+
c = y1 * x0 - y0 * x1
|
|
63
|
+
dist_num = np.abs(dy * x + dx * y + c)
|
|
64
|
+
idx = int(dist_num.argmax())
|
|
65
|
+
|
|
66
|
+
return (float(dist_num[idx]) / math.sqrt(dist_end_sq), idx)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
from ..fitting import find_inscribed_circle
|
|
5
|
+
from ..mask_ops import crop_mask
|
|
6
|
+
from ..schema import Mask
|
|
7
|
+
from .boundary import extract_boundary
|
|
8
|
+
from .corner import compute_corner_radii
|
|
9
|
+
from .discretize import discretize_boundary
|
|
10
|
+
from .smoothing import smooth_boundary
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def compute_roundness(
|
|
14
|
+
mask: Mask,
|
|
15
|
+
max_dev_thresh: float = 0.3,
|
|
16
|
+
circle_fit_thresh: float = 0.98,
|
|
17
|
+
alpha_ratio: float = 0.05,
|
|
18
|
+
beta_ratio: float = 0.001,
|
|
19
|
+
) -> float | None:
|
|
20
|
+
mask_cropped, _, _ = crop_mask(mask, pad=1)
|
|
21
|
+
if mask_cropped.size == 0:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
inscribed = find_inscribed_circle(mask_cropped)
|
|
25
|
+
if inscribed is None or inscribed["r"] < 1e-6:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
max_radius = inscribed["r"]
|
|
29
|
+
max_radius_pos = np.array([inscribed["x"], inscribed["y"]])
|
|
30
|
+
|
|
31
|
+
boundary = extract_boundary(mask_cropped)
|
|
32
|
+
if len(boundary) < 3:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
perimeter = float(
|
|
36
|
+
cv2.arcLength(boundary.astype(np.float32).reshape(-1, 1, 2), closed=True)
|
|
37
|
+
)
|
|
38
|
+
if perimeter < 1e-6:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
smoothed_boundary = smooth_boundary(
|
|
42
|
+
boundary,
|
|
43
|
+
perimeter=perimeter,
|
|
44
|
+
alpha_ratio=alpha_ratio,
|
|
45
|
+
beta_ratio=beta_ratio,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
keypoints = discretize_boundary(smoothed_boundary, max_dev_thresh)
|
|
49
|
+
if len(keypoints) < 3:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
radii = compute_corner_radii(
|
|
53
|
+
keypoints,
|
|
54
|
+
max_radius,
|
|
55
|
+
max_radius_pos,
|
|
56
|
+
circle_fit_thresh,
|
|
57
|
+
)
|
|
58
|
+
if len(radii) == 0:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
return float(np.mean(radii) / max_radius)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def smooth_boundary(
|
|
5
|
+
points: np.ndarray,
|
|
6
|
+
perimeter: float,
|
|
7
|
+
alpha_ratio: float = 0.05,
|
|
8
|
+
beta_ratio: float = 0.001,
|
|
9
|
+
) -> np.ndarray:
|
|
10
|
+
num_points = len(points)
|
|
11
|
+
|
|
12
|
+
if not np.isfinite(perimeter) or perimeter <= 0:
|
|
13
|
+
raise ValueError("perimeter must be > 0 and finite.")
|
|
14
|
+
if not np.isfinite(alpha_ratio) or not np.isfinite(beta_ratio):
|
|
15
|
+
raise ValueError("smoothing ratios must be finite.")
|
|
16
|
+
|
|
17
|
+
alpha = alpha_ratio * perimeter
|
|
18
|
+
beta = beta_ratio * perimeter
|
|
19
|
+
kernel = _regularization_kernel(num_points, alpha, beta)
|
|
20
|
+
|
|
21
|
+
eigenvalues = np.fft.fft(kernel)
|
|
22
|
+
transformed = np.fft.fft(points, axis=0)
|
|
23
|
+
tolerance = np.finfo(float).eps * num_points * float(np.max(np.abs(eigenvalues)))
|
|
24
|
+
usable = np.abs(eigenvalues) > tolerance
|
|
25
|
+
solution = np.zeros_like(transformed)
|
|
26
|
+
np.divide(
|
|
27
|
+
transformed,
|
|
28
|
+
eigenvalues[:, None],
|
|
29
|
+
out=solution,
|
|
30
|
+
where=usable[:, None],
|
|
31
|
+
)
|
|
32
|
+
return np.fft.ifft(solution, axis=0).real
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _regularization_kernel(num_points: int, alpha: float, beta: float) -> np.ndarray:
|
|
36
|
+
coefficients = alpha * np.array([-2, 1, 0, 0]) + beta * np.array([-6, 4, -1, 0])
|
|
37
|
+
indices = np.arange(num_points)
|
|
38
|
+
distances = np.minimum(indices, num_points - indices)
|
|
39
|
+
kernel = -coefficients[np.minimum(distances, len(coefficients) - 1)]
|
|
40
|
+
kernel[0] += 1.0
|
|
41
|
+
return kernel
|