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
grainmorph/__init__.py
ADDED
grainmorph/analyzer.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from .mask_ops import prepare_mask
|
|
4
|
+
from .metrics import (
|
|
5
|
+
compute_aspect_ratio,
|
|
6
|
+
compute_circularity,
|
|
7
|
+
compute_roundness,
|
|
8
|
+
compute_sphericity,
|
|
9
|
+
)
|
|
10
|
+
from .schema import AnalysisResult
|
|
11
|
+
from .validation import to_binary, validate_analysis_params
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def analyze_mask(
|
|
15
|
+
mask: np.ndarray,
|
|
16
|
+
*,
|
|
17
|
+
use_aspect_ratio: bool = True,
|
|
18
|
+
use_roundness: bool = True,
|
|
19
|
+
use_circularity: bool = True,
|
|
20
|
+
use_sphericity: bool = True,
|
|
21
|
+
roundness_params: dict[str, float] | None = None,
|
|
22
|
+
eps: float = 0.001,
|
|
23
|
+
target_dim: int = 384,
|
|
24
|
+
) -> AnalysisResult | None:
|
|
25
|
+
eps, target_dim = validate_analysis_params(eps, target_dim)
|
|
26
|
+
|
|
27
|
+
mask_binary = to_binary(mask)
|
|
28
|
+
if not np.any(mask_binary):
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
if not (use_aspect_ratio or use_roundness or use_circularity or use_sphericity):
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
prepared = prepare_mask(mask_binary, target_dim=target_dim)
|
|
35
|
+
final_mask = prepared.mask
|
|
36
|
+
|
|
37
|
+
results: AnalysisResult = {}
|
|
38
|
+
if use_aspect_ratio:
|
|
39
|
+
results["aspect_ratio"] = compute_aspect_ratio(final_mask, eps=eps)
|
|
40
|
+
if use_roundness:
|
|
41
|
+
results["roundness"] = compute_roundness(final_mask, **(roundness_params or {}))
|
|
42
|
+
if use_circularity:
|
|
43
|
+
results["circularity"] = compute_circularity(final_mask, eps=eps)
|
|
44
|
+
if use_sphericity:
|
|
45
|
+
results["sphericity"] = compute_sphericity(final_mask, eps=eps)
|
|
46
|
+
|
|
47
|
+
prepared.restore_results(results)
|
|
48
|
+
|
|
49
|
+
return results
|
grainmorph/fitting.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
from .mask_ops import crop_mask, get_contours
|
|
5
|
+
from .schema import Bbox, CircleData, EllipseData, Mask, Points
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def find_inscribed_circle(mask: Mask) -> CircleData | None:
|
|
9
|
+
cropped_mask, pad_x0, pad_y0 = crop_mask(mask, pad=1)
|
|
10
|
+
|
|
11
|
+
if cropped_mask.size == 0:
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
distance_transform = cv2.distanceTransform(
|
|
15
|
+
cropped_mask.astype(np.uint8, copy=False), cv2.DIST_L2, cv2.DIST_MASK_PRECISE
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
max_idx = np.argmax(distance_transform)
|
|
19
|
+
crop_center_y, crop_center_x = np.unravel_index(max_idx, distance_transform.shape)
|
|
20
|
+
|
|
21
|
+
center_x = float(int(crop_center_x) + pad_x0)
|
|
22
|
+
center_y = float(int(crop_center_y) + pad_y0)
|
|
23
|
+
radius = float(distance_transform[crop_center_y, crop_center_x])
|
|
24
|
+
|
|
25
|
+
return {"x": center_x, "y": center_y, "r": radius}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_enclosing_circle(mask: Mask) -> CircleData | None:
|
|
29
|
+
contours = get_contours(mask)
|
|
30
|
+
if not contours:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
points = np.concatenate(contours)
|
|
34
|
+
hull = cv2.convexHull(points)
|
|
35
|
+
|
|
36
|
+
(center_x, center_y), radius = cv2.minEnclosingCircle(hull)
|
|
37
|
+
|
|
38
|
+
return {"x": float(center_x), "y": float(center_y), "r": float(radius)}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fit_ellipse(mask: Mask) -> EllipseData | None:
|
|
42
|
+
contours = get_contours(mask)
|
|
43
|
+
if not contours:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
points: Points = np.concatenate(contours).reshape(-1, 2).astype(np.float32)
|
|
47
|
+
if len(points) < 5:
|
|
48
|
+
rect = cv2.minAreaRect(points)
|
|
49
|
+
(center_x, center_y), (width, height), angle = rect
|
|
50
|
+
|
|
51
|
+
if width <= 1e-06 or height <= 1e-06:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
bbox = cv2.boxPoints(rect).tolist()
|
|
55
|
+
else:
|
|
56
|
+
(center_x, center_y), (width, height), angle = cv2.fitEllipse(points)
|
|
57
|
+
bbox = _oriented_bbox_from_angle(points, angle)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
"major": float(max(width, height)),
|
|
61
|
+
"minor": float(min(width, height)),
|
|
62
|
+
"x": float(center_x),
|
|
63
|
+
"y": float(center_y),
|
|
64
|
+
"angle": float(angle),
|
|
65
|
+
"w": float(width),
|
|
66
|
+
"h": float(height),
|
|
67
|
+
"bbox": bbox,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _oriented_bbox_from_angle(points: Points, angle: float) -> Bbox:
|
|
72
|
+
radians = np.deg2rad(angle)
|
|
73
|
+
cos_a, sin_a = np.cos(radians), np.sin(radians)
|
|
74
|
+
axes = np.array([[cos_a, -sin_a], [sin_a, cos_a]])
|
|
75
|
+
projected = points @ axes
|
|
76
|
+
min_x, min_y = projected.min(axis=0)
|
|
77
|
+
max_x, max_y = projected.max(axis=0)
|
|
78
|
+
corners = np.array([[min_x, min_y], [max_x, min_y], [max_x, max_y], [min_x, max_y]])
|
|
79
|
+
return (corners @ axes.T).tolist()
|
grainmorph/mask_ops.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from .schema import AnalysisResult, Mask
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class PreparedMask:
|
|
11
|
+
mask: Mask
|
|
12
|
+
offset_x: float
|
|
13
|
+
offset_y: float
|
|
14
|
+
scale: float
|
|
15
|
+
|
|
16
|
+
def restore_results(self, results: AnalysisResult) -> None:
|
|
17
|
+
sphericity = results.get("sphericity")
|
|
18
|
+
if sphericity:
|
|
19
|
+
for key in ("inscribed", "enclosing"):
|
|
20
|
+
circle = sphericity[key]
|
|
21
|
+
circle["x"] = float(circle["x"] * self.scale + self.offset_x)
|
|
22
|
+
circle["y"] = float(circle["y"] * self.scale + self.offset_y)
|
|
23
|
+
circle["r"] = float(circle["r"] * self.scale)
|
|
24
|
+
|
|
25
|
+
aspect_ratio = results.get("aspect_ratio")
|
|
26
|
+
if aspect_ratio:
|
|
27
|
+
ellipse = aspect_ratio["ellipse"]
|
|
28
|
+
ellipse["x"] = float(ellipse["x"] * self.scale + self.offset_x)
|
|
29
|
+
ellipse["y"] = float(ellipse["y"] * self.scale + self.offset_y)
|
|
30
|
+
for key in ("major", "minor", "w", "h"):
|
|
31
|
+
ellipse[key] = float(ellipse[key] * self.scale)
|
|
32
|
+
ellipse["bbox"] = [
|
|
33
|
+
[
|
|
34
|
+
float(x * self.scale + self.offset_x),
|
|
35
|
+
float(y * self.scale + self.offset_y),
|
|
36
|
+
]
|
|
37
|
+
for x, y in ellipse["bbox"]
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def prepare_mask(mask: Mask, target_dim: int) -> PreparedMask:
|
|
42
|
+
cropped, crop_x, crop_y = crop_mask(mask, pad=0)
|
|
43
|
+
|
|
44
|
+
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
|
|
45
|
+
cropped, connectivity=4
|
|
46
|
+
)
|
|
47
|
+
if num_labels > 2:
|
|
48
|
+
largest = 1 + np.argmax(stats[1:, cv2.CC_STAT_AREA])
|
|
49
|
+
cropped = (labels == largest).astype(np.uint8)
|
|
50
|
+
|
|
51
|
+
# Background 4-connected to the padded border stays empty; the rest are holes.
|
|
52
|
+
flooded = np.pad(cropped, 1)
|
|
53
|
+
cv2.floodFill(flooded, None, (0, 0), 2, flags=4)
|
|
54
|
+
filled_mask = (flooded[1:-1, 1:-1] != 2).astype(np.uint8)
|
|
55
|
+
component, component_x, component_y = crop_mask(filled_mask, pad=0)
|
|
56
|
+
|
|
57
|
+
source_height, source_width = component.shape
|
|
58
|
+
scale = 1.0
|
|
59
|
+
if max(source_height, source_width) > target_dim:
|
|
60
|
+
resize_scale = target_dim / max(source_height, source_width)
|
|
61
|
+
target_width = max(1, round(source_width * resize_scale))
|
|
62
|
+
target_height = max(1, round(source_height * resize_scale))
|
|
63
|
+
if target_width > 1 and target_height > 1:
|
|
64
|
+
translation = (resize_scale - 1.0) / 2.0
|
|
65
|
+
transform = np.array(
|
|
66
|
+
[
|
|
67
|
+
[resize_scale, 0.0, translation],
|
|
68
|
+
[0.0, resize_scale, translation],
|
|
69
|
+
],
|
|
70
|
+
dtype=np.float64,
|
|
71
|
+
)
|
|
72
|
+
component = cv2.warpAffine(
|
|
73
|
+
component,
|
|
74
|
+
transform,
|
|
75
|
+
(target_width, target_height),
|
|
76
|
+
flags=cv2.INTER_NEAREST,
|
|
77
|
+
)
|
|
78
|
+
scale = 1.0 / resize_scale
|
|
79
|
+
|
|
80
|
+
# OpenCV maps pixel centers, so the half-pixel term is part of the inverse map.
|
|
81
|
+
offset_x = crop_x + component_x + (scale - 1.0) / 2.0
|
|
82
|
+
offset_y = crop_y + component_y + (scale - 1.0) / 2.0
|
|
83
|
+
|
|
84
|
+
return PreparedMask(
|
|
85
|
+
mask=component,
|
|
86
|
+
offset_x=float(offset_x),
|
|
87
|
+
offset_y=float(offset_y),
|
|
88
|
+
scale=float(scale),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_contours(mask: np.ndarray) -> tuple[np.ndarray, ...]:
|
|
93
|
+
cropped_mask, offset_x, offset_y = crop_mask(mask, pad=1)
|
|
94
|
+
|
|
95
|
+
if cropped_mask.size == 0:
|
|
96
|
+
return ()
|
|
97
|
+
|
|
98
|
+
if cropped_mask.dtype != np.uint8:
|
|
99
|
+
cropped_mask = cropped_mask.astype(np.uint8)
|
|
100
|
+
|
|
101
|
+
contours, _ = cv2.findContours(
|
|
102
|
+
cropped_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
offset = np.array([[[offset_x, offset_y]]], dtype=np.int32)
|
|
106
|
+
return tuple(contour + offset for contour in contours)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def crop_mask(mask: np.ndarray, pad: int = 1) -> tuple[np.ndarray, int, int]:
|
|
110
|
+
if mask.dtype != np.uint8:
|
|
111
|
+
binary_mask_uint8 = mask.astype(np.uint8)
|
|
112
|
+
else:
|
|
113
|
+
binary_mask_uint8 = mask
|
|
114
|
+
|
|
115
|
+
x_min, y_min, width, height = cv2.boundingRect(binary_mask_uint8)
|
|
116
|
+
|
|
117
|
+
if width == 0 or height == 0:
|
|
118
|
+
return (np.zeros((0, 0), dtype=mask.dtype), 0, 0)
|
|
119
|
+
|
|
120
|
+
cropped = mask[y_min : y_min + height, x_min : x_min + width]
|
|
121
|
+
|
|
122
|
+
if pad > 0:
|
|
123
|
+
cropped = np.pad(cropped, pad_width=pad, mode="constant", constant_values=0)
|
|
124
|
+
|
|
125
|
+
offset_y = y_min - pad
|
|
126
|
+
offset_x = x_min - pad
|
|
127
|
+
|
|
128
|
+
return (cropped, offset_x, offset_y)
|
grainmorph/metrics.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from .fitting import (
|
|
4
|
+
find_enclosing_circle,
|
|
5
|
+
find_inscribed_circle,
|
|
6
|
+
fit_ellipse,
|
|
7
|
+
)
|
|
8
|
+
from .mask_ops import crop_mask
|
|
9
|
+
from .schema import (
|
|
10
|
+
AspectRatioResult,
|
|
11
|
+
CircularityResult,
|
|
12
|
+
Mask,
|
|
13
|
+
RoundnessResult,
|
|
14
|
+
SphericityResult,
|
|
15
|
+
)
|
|
16
|
+
from .wadell import compute_roundness as compute_wadell_roundness
|
|
17
|
+
|
|
18
|
+
# Four-direction Crofton weights indexed by 2x2 pixel pattern, matching
|
|
19
|
+
# skimage.measure.perimeter_crofton(image, directions=4).
|
|
20
|
+
_CROFTON_WEIGHTS = np.array(
|
|
21
|
+
[
|
|
22
|
+
0,
|
|
23
|
+
np.pi / 4 * (1 + 1 / np.sqrt(2)),
|
|
24
|
+
np.pi / (4 * np.sqrt(2)),
|
|
25
|
+
np.pi / (2 * np.sqrt(2)),
|
|
26
|
+
0,
|
|
27
|
+
np.pi / 4 * (1 + 1 / np.sqrt(2)),
|
|
28
|
+
0,
|
|
29
|
+
np.pi / (4 * np.sqrt(2)),
|
|
30
|
+
np.pi / 4,
|
|
31
|
+
np.pi / 2,
|
|
32
|
+
np.pi / (4 * np.sqrt(2)),
|
|
33
|
+
np.pi / (4 * np.sqrt(2)),
|
|
34
|
+
np.pi / 4,
|
|
35
|
+
np.pi / 2,
|
|
36
|
+
0,
|
|
37
|
+
0,
|
|
38
|
+
]
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compute_roundness(mask: Mask, **params: float) -> RoundnessResult | None:
|
|
43
|
+
value = compute_wadell_roundness(mask, **params)
|
|
44
|
+
|
|
45
|
+
if value is None:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
value = float(np.clip(value, 0.0, 1.0))
|
|
49
|
+
|
|
50
|
+
return {"val": value}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def compute_circularity(mask: Mask, *, eps: float = 0.001) -> CircularityResult | None:
|
|
54
|
+
cropped_mask, _, _ = crop_mask(mask, pad=1)
|
|
55
|
+
|
|
56
|
+
if cropped_mask.size == 0:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
perimeter = _crofton_perimeter(cropped_mask)
|
|
60
|
+
|
|
61
|
+
if perimeter < eps:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
area = float(np.count_nonzero(mask))
|
|
65
|
+
value = 4.0 * np.pi * area / perimeter**2
|
|
66
|
+
value = float(np.clip(value, 0.0, 1.0))
|
|
67
|
+
|
|
68
|
+
return {"val": value}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _crofton_perimeter(mask: Mask) -> float:
|
|
72
|
+
padded = np.pad((mask > 0).astype(np.uint8), ((2, 1), (2, 1)))
|
|
73
|
+
patterns = (
|
|
74
|
+
padded[1:, 1:]
|
|
75
|
+
+ 2 * padded[:-1, 1:]
|
|
76
|
+
+ 4 * padded[1:, :-1]
|
|
77
|
+
+ 8 * padded[:-1, :-1]
|
|
78
|
+
)
|
|
79
|
+
counts = np.bincount(patterns.ravel(), minlength=16)
|
|
80
|
+
return float(_CROFTON_WEIGHTS @ counts)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compute_sphericity(mask: Mask, *, eps: float = 0.001) -> SphericityResult | None:
|
|
84
|
+
inscribed = find_inscribed_circle(mask)
|
|
85
|
+
enclosing = find_enclosing_circle(mask)
|
|
86
|
+
|
|
87
|
+
if inscribed is None or enclosing is None:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
inscribed_radius = inscribed["r"]
|
|
91
|
+
enclosing_radius = enclosing["r"]
|
|
92
|
+
|
|
93
|
+
if enclosing_radius < eps:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
value = float(np.clip(inscribed_radius / enclosing_radius, 0.0, 1.0))
|
|
97
|
+
|
|
98
|
+
return {"val": value, "inscribed": inscribed, "enclosing": enclosing}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def compute_aspect_ratio(mask: Mask, *, eps: float = 0.001) -> AspectRatioResult | None:
|
|
102
|
+
ellipse_data = fit_ellipse(mask)
|
|
103
|
+
|
|
104
|
+
if ellipse_data is None:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
if ellipse_data["minor"] < eps:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
value = ellipse_data["major"] / ellipse_data["minor"]
|
|
111
|
+
|
|
112
|
+
return {"val": value, "ellipse": ellipse_data}
|
grainmorph/schema.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from typing import Annotated, TypeAlias, TypedDict
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
Bbox: TypeAlias = list[list[float]]
|
|
6
|
+
Mask: TypeAlias = Annotated[np.ndarray, "shape=(H, W), dtype=uint8"]
|
|
7
|
+
Points: TypeAlias = Annotated[np.ndarray, "shape=(N, 2), dtype=float32"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CircleData(TypedDict):
|
|
11
|
+
x: float
|
|
12
|
+
y: float
|
|
13
|
+
r: float
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EllipseData(TypedDict):
|
|
17
|
+
major: float
|
|
18
|
+
minor: float
|
|
19
|
+
x: float
|
|
20
|
+
y: float
|
|
21
|
+
angle: float
|
|
22
|
+
w: float
|
|
23
|
+
h: float
|
|
24
|
+
bbox: Bbox
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RoundnessResult(TypedDict):
|
|
28
|
+
val: float
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CircularityResult(TypedDict):
|
|
32
|
+
val: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SphericityResult(TypedDict):
|
|
36
|
+
val: float
|
|
37
|
+
inscribed: CircleData
|
|
38
|
+
enclosing: CircleData
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AspectRatioResult(TypedDict):
|
|
42
|
+
val: float
|
|
43
|
+
ellipse: EllipseData
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AnalysisResult(TypedDict, total=False):
|
|
47
|
+
roundness: RoundnessResult | None
|
|
48
|
+
circularity: CircularityResult | None
|
|
49
|
+
sphericity: SphericityResult | None
|
|
50
|
+
aspect_ratio: AspectRatioResult | None
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from .create_mask import (
|
|
2
|
+
create_circle_mask,
|
|
3
|
+
create_ellipse_mask,
|
|
4
|
+
create_pentagon_mask,
|
|
5
|
+
create_rectangle_mask,
|
|
6
|
+
create_square_mask,
|
|
7
|
+
create_star_mask,
|
|
8
|
+
create_triangle_mask,
|
|
9
|
+
)
|
|
10
|
+
from .parametric_mask import create_particle_mask
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def plot_analysis_results(*args, **kwargs):
|
|
14
|
+
from .plot import plot_analysis_results as _plot_analysis_results
|
|
15
|
+
|
|
16
|
+
return _plot_analysis_results(*args, **kwargs)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"create_circle_mask",
|
|
21
|
+
"create_ellipse_mask",
|
|
22
|
+
"create_particle_mask",
|
|
23
|
+
"create_pentagon_mask",
|
|
24
|
+
"create_rectangle_mask",
|
|
25
|
+
"create_square_mask",
|
|
26
|
+
"create_star_mask",
|
|
27
|
+
"create_triangle_mask",
|
|
28
|
+
"plot_analysis_results",
|
|
29
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
from .geometry import create_poly_mask, polar_vertices
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _polygon_mask(shape: tuple[int, int], vertices: np.ndarray) -> np.ndarray:
|
|
7
|
+
return create_poly_mask(shape, vertices).astype(bool)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_circle_mask(
|
|
11
|
+
shape: tuple[int, int], center: tuple[int, int], radius: float
|
|
12
|
+
) -> np.ndarray:
|
|
13
|
+
if not np.isfinite(radius) or radius < 0:
|
|
14
|
+
raise ValueError("radius must be finite and >= 0.")
|
|
15
|
+
|
|
16
|
+
h, w = shape
|
|
17
|
+
y, x = np.ogrid[:h, :w]
|
|
18
|
+
cy, cx = center
|
|
19
|
+
|
|
20
|
+
return (x - cx) ** 2 + (y - cy) ** 2 <= radius**2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_ellipse_mask(
|
|
24
|
+
shape: tuple[int, int], center: tuple[int, int], radius_x: float, radius_y: float
|
|
25
|
+
) -> np.ndarray:
|
|
26
|
+
if (
|
|
27
|
+
not np.isfinite(radius_x)
|
|
28
|
+
or not np.isfinite(radius_y)
|
|
29
|
+
or radius_x <= 0
|
|
30
|
+
or radius_y <= 0
|
|
31
|
+
):
|
|
32
|
+
raise ValueError("radius_x and radius_y must be finite and > 0.")
|
|
33
|
+
|
|
34
|
+
h, w = shape
|
|
35
|
+
y, x = np.ogrid[:h, :w]
|
|
36
|
+
cy, cx = center
|
|
37
|
+
|
|
38
|
+
return ((x - cx) / radius_x) ** 2 + ((y - cy) / radius_y) ** 2 <= 1.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_rectangle_mask(
|
|
42
|
+
shape: tuple[int, int], top_left: tuple[int, int], bottom_right: tuple[int, int]
|
|
43
|
+
) -> np.ndarray:
|
|
44
|
+
h, w = shape
|
|
45
|
+
y1, x1 = top_left
|
|
46
|
+
y2, x2 = bottom_right
|
|
47
|
+
|
|
48
|
+
if y1 >= y2 or x1 >= x2:
|
|
49
|
+
raise ValueError("top_left must be strictly above-left of bottom_right.")
|
|
50
|
+
|
|
51
|
+
if y1 < 0 or x1 < 0 or y2 > h or x2 > w:
|
|
52
|
+
raise ValueError("rectangle coordinates must be within mask bounds.")
|
|
53
|
+
|
|
54
|
+
res = np.zeros(shape, dtype=bool)
|
|
55
|
+
res[y1:y2, x1:x2] = True
|
|
56
|
+
return res
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_square_mask(
|
|
60
|
+
shape: tuple[int, int], top_left: tuple[int, int], size: int
|
|
61
|
+
) -> np.ndarray:
|
|
62
|
+
if size <= 0:
|
|
63
|
+
raise ValueError("size must be > 0.")
|
|
64
|
+
|
|
65
|
+
return create_rectangle_mask(
|
|
66
|
+
shape=shape,
|
|
67
|
+
top_left=top_left,
|
|
68
|
+
bottom_right=(top_left[0] + size, top_left[1] + size),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create_triangle_mask(
|
|
73
|
+
shape: tuple[int, int],
|
|
74
|
+
v1: tuple[int, int],
|
|
75
|
+
v2: tuple[int, int],
|
|
76
|
+
v3: tuple[int, int],
|
|
77
|
+
) -> np.ndarray:
|
|
78
|
+
"""Create a triangle from (x, y) vertices in a (height, width) image."""
|
|
79
|
+
vertices = np.array([v1, v2, v3])
|
|
80
|
+
return _polygon_mask(shape, vertices)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def create_pentagon_mask(
|
|
84
|
+
shape: tuple[int, int], center: tuple[int, int], radius: float
|
|
85
|
+
) -> np.ndarray:
|
|
86
|
+
if not np.isfinite(radius) or radius <= 0:
|
|
87
|
+
raise ValueError("radius must be finite and > 0.")
|
|
88
|
+
|
|
89
|
+
angles = np.linspace(0, 2 * np.pi, 5, endpoint=False) - np.pi / 2
|
|
90
|
+
vertices = polar_vertices(center=center, radii=radius, angles=angles)
|
|
91
|
+
return _polygon_mask(shape, vertices)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def create_star_mask(
|
|
95
|
+
shape: tuple[int, int],
|
|
96
|
+
center: tuple[int, int],
|
|
97
|
+
outer_radius: float,
|
|
98
|
+
inner_radius: float,
|
|
99
|
+
num_points: int = 5,
|
|
100
|
+
) -> np.ndarray:
|
|
101
|
+
if not np.isfinite(outer_radius) or outer_radius <= 0:
|
|
102
|
+
raise ValueError("outer_radius must be finite and > 0.")
|
|
103
|
+
if not np.isfinite(inner_radius) or inner_radius <= 0:
|
|
104
|
+
raise ValueError("inner_radius must be finite and > 0.")
|
|
105
|
+
if not isinstance(num_points, (int, np.integer)):
|
|
106
|
+
raise TypeError("num_points must be an integer.")
|
|
107
|
+
if num_points < 2:
|
|
108
|
+
raise ValueError("num_points must be >= 2.")
|
|
109
|
+
|
|
110
|
+
angles = np.linspace(0, 2 * np.pi, 2 * num_points, endpoint=False) - np.pi / 2
|
|
111
|
+
radii = np.ones_like(angles) * outer_radius
|
|
112
|
+
radii[1::2] = inner_radius
|
|
113
|
+
|
|
114
|
+
vertices = polar_vertices(center=center, radii=radii, angles=angles)
|
|
115
|
+
return _polygon_mask(shape, vertices)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def create_poly_mask(shape: tuple[int, int], vertices: np.ndarray) -> np.ndarray:
|
|
6
|
+
mask = np.zeros(shape, dtype=np.uint8)
|
|
7
|
+
pts = np.asarray(vertices, dtype=np.int32).reshape(-1, 1, 2)
|
|
8
|
+
|
|
9
|
+
cv2.fillPoly(mask, [pts], 1)
|
|
10
|
+
return mask
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def polar_vertices(
|
|
14
|
+
center: tuple[float, float],
|
|
15
|
+
radii: float | np.ndarray,
|
|
16
|
+
angles: np.ndarray,
|
|
17
|
+
) -> np.ndarray:
|
|
18
|
+
cy, cx = center
|
|
19
|
+
radii_arr = np.asarray(radii, dtype=float)
|
|
20
|
+
return np.stack(
|
|
21
|
+
[cx + radii_arr * np.cos(angles), cy + radii_arr * np.sin(angles)],
|
|
22
|
+
axis=1,
|
|
23
|
+
)
|