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.
@@ -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
@@ -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
+ )
@@ -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,3 @@
1
+ from .roundness import compute_roundness as compute_roundness
2
+
3
+ __all__ = ["compute_roundness"]
@@ -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