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 ADDED
@@ -0,0 +1,5 @@
1
+ from . import utils
2
+ from .analyzer import analyze_mask
3
+
4
+ __version__ = "0.2.0"
5
+ __all__ = ["analyze_mask", "utils"]
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
+ )