partimorph 0.1.0__tar.gz

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.
Files changed (29) hide show
  1. partimorph-0.1.0/LICENSE +21 -0
  2. partimorph-0.1.0/PKG-INFO +100 -0
  3. partimorph-0.1.0/README.md +81 -0
  4. partimorph-0.1.0/pyproject.toml +31 -0
  5. partimorph-0.1.0/setup.cfg +4 -0
  6. partimorph-0.1.0/src/partimorph/__init__.py +5 -0
  7. partimorph-0.1.0/src/partimorph/analyzer.py +111 -0
  8. partimorph-0.1.0/src/partimorph/fitting.py +140 -0
  9. partimorph-0.1.0/src/partimorph/metrics.py +105 -0
  10. partimorph-0.1.0/src/partimorph/misc.py +41 -0
  11. partimorph-0.1.0/src/partimorph/schema.py +52 -0
  12. partimorph-0.1.0/src/partimorph/utils/__init__.py +29 -0
  13. partimorph-0.1.0/src/partimorph/utils/create_mask.py +109 -0
  14. partimorph-0.1.0/src/partimorph/utils/geometry.py +27 -0
  15. partimorph-0.1.0/src/partimorph/utils/parametric_mask.py +180 -0
  16. partimorph-0.1.0/src/partimorph/utils/plot.py +97 -0
  17. partimorph-0.1.0/src/partimorph/validation.py +27 -0
  18. partimorph-0.1.0/src/partimorph/wadell/__init__.py +3 -0
  19. partimorph-0.1.0/src/partimorph/wadell/boundary.py +93 -0
  20. partimorph-0.1.0/src/partimorph/wadell/corner.py +84 -0
  21. partimorph-0.1.0/src/partimorph/wadell/discretize.py +101 -0
  22. partimorph-0.1.0/src/partimorph/wadell/roundness.py +76 -0
  23. partimorph-0.1.0/src/partimorph/wadell/smoothing.py +30 -0
  24. partimorph-0.1.0/src/partimorph.egg-info/PKG-INFO +100 -0
  25. partimorph-0.1.0/src/partimorph.egg-info/SOURCES.txt +27 -0
  26. partimorph-0.1.0/src/partimorph.egg-info/dependency_links.txt +1 -0
  27. partimorph-0.1.0/src/partimorph.egg-info/requires.txt +9 -0
  28. partimorph-0.1.0/src/partimorph.egg-info/top_level.txt +1 -0
  29. partimorph-0.1.0/tests/test_partimorph.py +139 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PartiMorph Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: partimorph
3
+ Version: 0.1.0
4
+ Summary: Particle shape analysis and visualization library.
5
+ Author: PartiMorph Contributors
6
+ License: MIT
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: numpy
11
+ Requires-Dist: scipy
12
+ Requires-Dist: opencv-python
13
+ Requires-Dist: scikit-image
14
+ Requires-Dist: matplotlib
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest; extra == "dev"
17
+ Requires-Dist: ruff; extra == "dev"
18
+ Dynamic: license-file
19
+
20
+ # PartiMorph
21
+
22
+ PartiMorph analyzes a 2D binary particle mask and returns:
23
+ - Wadell roundness
24
+ - ISO circularity
25
+ - Riley sphericity
26
+ - Aspect ratio
27
+
28
+ ## Install
29
+
30
+ Python `3.12+`
31
+
32
+ ```bash
33
+ pip install -e .
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ```python
39
+ import partimorph as pm
40
+
41
+ mask = pm.utils.create_circle_mask((256, 256), (128, 128), 60)
42
+ results = pm.analyze_mask(mask)
43
+
44
+ print(results["roundness"]["val"])
45
+ print(results["circularity"]["val"])
46
+ print(results["sphericity"]["val"])
47
+ print(results["aspect_ratio"]["val"])
48
+ ```
49
+
50
+ ## Input Rules
51
+
52
+ - `mask` must be a 2D `numpy.ndarray`
53
+ - Allowed values: `bool` or `{0, 1}`
54
+ - Empty mask returns `None`
55
+
56
+ ## Main API
57
+
58
+ ```python
59
+ pm.analyze_mask(
60
+ mask,
61
+ use_aspect_ratio=True,
62
+ use_roundness=True,
63
+ use_circularity=True,
64
+ use_sphericity=True,
65
+ roundness_params=None,
66
+ eps=0.001,
67
+ target_dim=384,
68
+ )
69
+ ```
70
+
71
+ Notes:
72
+ - Large masks are downscaled automatically (`target_dim`) for speed.
73
+ - `use_* = False` removes that metric from the output keys.
74
+
75
+ ## Result Shape
76
+
77
+ ```python
78
+ {
79
+ "roundness": {"val": float} | None,
80
+ "circularity": {"val": float} | None,
81
+ "sphericity": {
82
+ "val": float,
83
+ "inscribed": {"x": float, "y": float, "r": float},
84
+ "enclosing": {"x": float, "y": float, "r": float},
85
+ } | None,
86
+ "aspect_ratio": {
87
+ "val": float,
88
+ "ellipse": {
89
+ "major": float, "minor": float,
90
+ "x": float, "y": float, "angle": float,
91
+ "w": float, "h": float, "bbox": list[list[float]],
92
+ },
93
+ } | None,
94
+ }
95
+ ```
96
+
97
+ ## Utilities
98
+
99
+ - `pm.utils.create_particle_mask(...)`: synthetic mask generator
100
+ - `pm.utils.plot_analysis_results(mask, results)`: quick visualization
@@ -0,0 +1,81 @@
1
+ # PartiMorph
2
+
3
+ PartiMorph analyzes a 2D binary particle mask and returns:
4
+ - Wadell roundness
5
+ - ISO circularity
6
+ - Riley sphericity
7
+ - Aspect ratio
8
+
9
+ ## Install
10
+
11
+ Python `3.12+`
12
+
13
+ ```bash
14
+ pip install -e .
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```python
20
+ import partimorph as pm
21
+
22
+ mask = pm.utils.create_circle_mask((256, 256), (128, 128), 60)
23
+ results = pm.analyze_mask(mask)
24
+
25
+ print(results["roundness"]["val"])
26
+ print(results["circularity"]["val"])
27
+ print(results["sphericity"]["val"])
28
+ print(results["aspect_ratio"]["val"])
29
+ ```
30
+
31
+ ## Input Rules
32
+
33
+ - `mask` must be a 2D `numpy.ndarray`
34
+ - Allowed values: `bool` or `{0, 1}`
35
+ - Empty mask returns `None`
36
+
37
+ ## Main API
38
+
39
+ ```python
40
+ pm.analyze_mask(
41
+ mask,
42
+ use_aspect_ratio=True,
43
+ use_roundness=True,
44
+ use_circularity=True,
45
+ use_sphericity=True,
46
+ roundness_params=None,
47
+ eps=0.001,
48
+ target_dim=384,
49
+ )
50
+ ```
51
+
52
+ Notes:
53
+ - Large masks are downscaled automatically (`target_dim`) for speed.
54
+ - `use_* = False` removes that metric from the output keys.
55
+
56
+ ## Result Shape
57
+
58
+ ```python
59
+ {
60
+ "roundness": {"val": float} | None,
61
+ "circularity": {"val": float} | None,
62
+ "sphericity": {
63
+ "val": float,
64
+ "inscribed": {"x": float, "y": float, "r": float},
65
+ "enclosing": {"x": float, "y": float, "r": float},
66
+ } | None,
67
+ "aspect_ratio": {
68
+ "val": float,
69
+ "ellipse": {
70
+ "major": float, "minor": float,
71
+ "x": float, "y": float, "angle": float,
72
+ "w": float, "h": float, "bbox": list[list[float]],
73
+ },
74
+ } | None,
75
+ }
76
+ ```
77
+
78
+ ## Utilities
79
+
80
+ - `pm.utils.create_particle_mask(...)`: synthetic mask generator
81
+ - `pm.utils.plot_analysis_results(mask, results)`: quick visualization
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "partimorph"
7
+ version = "0.1.0"
8
+ authors = [
9
+ { name = "PartiMorph Contributors" }
10
+ ]
11
+ description = "Particle shape analysis and visualization library."
12
+ readme = "README.md"
13
+ requires-python = ">=3.12"
14
+ license = { text = "MIT" }
15
+ dependencies = [
16
+ "numpy",
17
+ "scipy",
18
+ "opencv-python",
19
+ "scikit-image",
20
+ "matplotlib",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "pytest",
26
+ "ruff",
27
+ ]
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+ include = ["partimorph*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from .analyzer import analyze_mask as analyze_mask
2
+ from . import utils as utils
3
+
4
+ __version__ = "0.1.0"
5
+ __all__ = ["analyze_mask", "utils"]
@@ -0,0 +1,111 @@
1
+ import cv2
2
+ import numpy as np
3
+ from scipy import ndimage
4
+ from .schema import AnalysisResult, Mask
5
+ from .metrics import (
6
+ compute_aspect_ratio,
7
+ compute_circularity,
8
+ compute_roundness,
9
+ compute_sphericity,
10
+ )
11
+ from .validation import to_binary
12
+
13
+
14
+ def _preprocess_mask(mask: Mask, target_dim: int) -> tuple[Mask, bool]:
15
+ height, width = mask.shape[:2]
16
+ max_dimension = max(height, width)
17
+ is_resized = max_dimension > target_dim
18
+
19
+ if is_resized:
20
+ scale = target_dim / max_dimension
21
+ mask = cv2.resize(
22
+ mask.astype(np.uint8),
23
+ (0, 0),
24
+ fx=scale,
25
+ fy=scale,
26
+ interpolation=cv2.INTER_NEAREST,
27
+ )
28
+ mask_bool = mask.astype(bool)
29
+
30
+ labeled_mask, num_features = ndimage.label(mask_bool)
31
+ if num_features > 1:
32
+ sizes = np.bincount(labeled_mask.ravel())
33
+ sizes[0] = 0
34
+ mask_bool = labeled_mask == np.argmax(sizes)
35
+
36
+ return (ndimage.binary_fill_holes(mask_bool).astype(np.uint8), is_resized)
37
+
38
+
39
+ def _rescale_results(
40
+ results: AnalysisResult,
41
+ *,
42
+ scale_x: float,
43
+ scale_y: float,
44
+ ) -> None:
45
+ mean_scale = (scale_x + scale_y) / 2.0
46
+
47
+ if results.get("sphericity"):
48
+ for key in ["inscribed", "enclosing"]:
49
+ circle_data = results["sphericity"][key]
50
+ circle_data["x"] = float(circle_data["x"] * scale_x)
51
+ circle_data["y"] = float(circle_data["y"] * scale_y)
52
+ circle_data["r"] = float(circle_data["r"] * mean_scale)
53
+
54
+ if results.get("aspect_ratio"):
55
+ ellipse = results["aspect_ratio"]["ellipse"]
56
+ ellipse["x"] = float(ellipse["x"] * scale_x)
57
+ ellipse["y"] = float(ellipse["y"] * scale_y)
58
+ ellipse["major"] = float(ellipse["major"] * mean_scale)
59
+ ellipse["minor"] = float(ellipse["minor"] * mean_scale)
60
+ ellipse["w"] = float(ellipse["w"] * scale_x)
61
+ ellipse["h"] = float(ellipse["h"] * scale_y)
62
+ if "bbox" in ellipse:
63
+ ellipse["bbox"] = [
64
+ [float(pt[0] * scale_x), float(pt[1] * scale_y)]
65
+ for pt in ellipse["bbox"]
66
+ ]
67
+
68
+
69
+ def analyze_mask(
70
+ mask: np.ndarray,
71
+ *,
72
+ use_aspect_ratio: bool = True,
73
+ use_roundness: bool = True,
74
+ use_circularity: bool = True,
75
+ use_sphericity: bool = True,
76
+ roundness_params: dict[str, float] | None = None,
77
+ eps: float = 0.001,
78
+ target_dim: int = 384,
79
+ ) -> AnalysisResult | None:
80
+ if eps <= 0:
81
+ raise ValueError("eps must be > 0.")
82
+ if target_dim <= 0:
83
+ raise ValueError("target_dim must be > 0.")
84
+
85
+ mask_binary = to_binary(mask)
86
+ if not np.any(mask_binary):
87
+ return None
88
+
89
+ original_height, original_width = mask_binary.shape
90
+ final_mask, is_resized = _preprocess_mask(mask_binary, target_dim=target_dim)
91
+
92
+ results: AnalysisResult = {}
93
+ if use_aspect_ratio:
94
+ results["aspect_ratio"] = compute_aspect_ratio(final_mask, eps=eps)
95
+ if use_roundness:
96
+ results["roundness"] = compute_roundness(final_mask, **(roundness_params or {}))
97
+ if use_circularity:
98
+ results["circularity"] = compute_circularity(final_mask, eps=eps)
99
+ if use_sphericity:
100
+ results["sphericity"] = compute_sphericity(final_mask, eps=eps)
101
+
102
+ if is_resized and results:
103
+ scale_x = original_width / final_mask.shape[1]
104
+ scale_y = original_height / final_mask.shape[0]
105
+ _rescale_results(
106
+ results,
107
+ scale_x=scale_x,
108
+ scale_y=scale_y,
109
+ )
110
+
111
+ return results
@@ -0,0 +1,140 @@
1
+ import cv2
2
+ import numpy as np
3
+ from .schema import CircleData, EllipseData, Mask, Points
4
+ from .misc import crop_mask, get_contours
5
+
6
+
7
+ def _ellipse_payload(
8
+ *,
9
+ center_x: float,
10
+ center_y: float,
11
+ angle: float,
12
+ width: float,
13
+ height: float,
14
+ bbox: list[list[float]],
15
+ ) -> EllipseData:
16
+ return {
17
+ "major": float(max(width, height)),
18
+ "minor": float(min(width, height)),
19
+ "x": float(center_x),
20
+ "y": float(center_y),
21
+ "angle": float(angle),
22
+ "w": float(width),
23
+ "h": float(height),
24
+ "bbox": bbox,
25
+ }
26
+
27
+
28
+ def find_inscribed_circle(mask: Mask) -> CircleData | None:
29
+ cropped_mask, pad_x0, pad_y0 = crop_mask(mask, pad=1)
30
+
31
+ if cropped_mask.size == 0:
32
+ return None
33
+
34
+ distance_transform = cv2.distanceTransform(
35
+ cropped_mask.astype(np.uint8), cv2.DIST_L2, cv2.DIST_MASK_PRECISE
36
+ )
37
+
38
+ max_idx = np.argmax(distance_transform)
39
+ crop_center_y, crop_center_x = np.unravel_index(max_idx, distance_transform.shape)
40
+
41
+ center_x = float(int(crop_center_x) + pad_x0)
42
+ center_y = float(int(crop_center_y) + pad_y0)
43
+ radius = float(distance_transform[crop_center_y, crop_center_x])
44
+
45
+ return {"x": center_x, "y": center_y, "r": radius}
46
+
47
+
48
+ def find_enclosing_circle(mask: Mask) -> CircleData | None:
49
+ if not np.any(mask):
50
+ return None
51
+
52
+ contours = get_contours(mask)
53
+ if not contours:
54
+ return None
55
+
56
+ points = np.concatenate(contours)
57
+ hull = cv2.convexHull(points)
58
+
59
+ (center_x, center_y), radius = cv2.minEnclosingCircle(hull)
60
+
61
+ x = float(center_x)
62
+ y = float(center_y)
63
+ r = float(radius)
64
+
65
+ return {"x": x, "y": y, "r": r}
66
+
67
+
68
+ def fit_ellipse(mask: Mask) -> EllipseData | None:
69
+ contours = get_contours(mask)
70
+ if not contours:
71
+ return None
72
+
73
+ points: Points = np.concatenate(contours).astype(np.float32)
74
+ if len(points) < 5:
75
+ rect = cv2.minAreaRect(points)
76
+ (center_x, center_y), (width, height), angle = rect
77
+
78
+ if width <= 1e-06 or height <= 1e-06:
79
+ return None
80
+
81
+ box = cv2.boxPoints(rect)
82
+ bbox = [[float(x), float(y)] for x, y in box]
83
+ return _ellipse_payload(
84
+ center_x=center_x,
85
+ center_y=center_y,
86
+ angle=angle,
87
+ width=width,
88
+ height=height,
89
+ bbox=bbox,
90
+ )
91
+
92
+ _, _, angle = cv2.fitEllipse(points)
93
+ radians = np.deg2rad(angle)
94
+ cos_a, sin_a = (np.cos(radians), np.sin(radians))
95
+
96
+ width_vector = np.array([cos_a, sin_a])
97
+ height_vector = np.array([-sin_a, cos_a])
98
+
99
+ width_projection = points @ width_vector
100
+ height_projection = points @ height_vector
101
+
102
+ min_width, max_width = (width_projection.min(), width_projection.max())
103
+ min_height, max_height = (height_projection.min(), height_projection.max())
104
+
105
+ tight_width = max_width - min_width
106
+ tight_height = max_height - min_height
107
+
108
+ mid_width = (max_width + min_width) / 2.0
109
+ mid_height = (max_height + min_height) / 2.0
110
+
111
+ center_x = mid_width * width_vector[0] + mid_height * height_vector[0]
112
+ center_y = mid_width * width_vector[1] + mid_height * height_vector[1]
113
+
114
+ bbox = [
115
+ [
116
+ float(min_width * width_vector[0] + min_height * height_vector[0]),
117
+ float(min_width * width_vector[1] + min_height * height_vector[1]),
118
+ ],
119
+ [
120
+ float(max_width * width_vector[0] + min_height * height_vector[0]),
121
+ float(max_width * width_vector[1] + min_height * height_vector[1]),
122
+ ],
123
+ [
124
+ float(max_width * width_vector[0] + max_height * height_vector[0]),
125
+ float(max_width * width_vector[1] + max_height * height_vector[1]),
126
+ ],
127
+ [
128
+ float(min_width * width_vector[0] + max_height * height_vector[0]),
129
+ float(min_width * width_vector[1] + max_height * height_vector[1]),
130
+ ],
131
+ ]
132
+
133
+ return _ellipse_payload(
134
+ center_x=center_x,
135
+ center_y=center_y,
136
+ angle=angle,
137
+ width=tight_width,
138
+ height=tight_height,
139
+ bbox=bbox,
140
+ )
@@ -0,0 +1,105 @@
1
+ import numpy as np
2
+ import skimage.measure
3
+ from .fitting import (
4
+ find_enclosing_circle,
5
+ find_inscribed_circle,
6
+ fit_ellipse,
7
+ )
8
+ from .wadell import compute_roundness as compute_roundness_wadell
9
+ from .misc import crop_mask
10
+ from .validation import to_binary
11
+ from .schema import (
12
+ AspectRatioResult,
13
+ CircularityResult,
14
+ Mask,
15
+ RoundnessResult,
16
+ SphericityResult,
17
+ )
18
+
19
+
20
+ def compute_roundness(
21
+ mask: np.ndarray,
22
+ *,
23
+ max_dev_thresh: float = 0.3,
24
+ circle_fit_thresh: float = 0.98,
25
+ alpha_ratio: float = 0.05,
26
+ beta_ratio: float = 0.001,
27
+ ) -> RoundnessResult | None:
28
+ mask_binary: Mask = to_binary(mask)
29
+
30
+ value = compute_roundness_wadell(
31
+ mask_binary,
32
+ max_dev_thresh=max_dev_thresh,
33
+ circle_fit_thresh=circle_fit_thresh,
34
+ alpha_ratio=alpha_ratio,
35
+ beta_ratio=beta_ratio,
36
+ )
37
+
38
+ if value is None:
39
+ return None
40
+
41
+ value = float(np.clip(value, 0.0, 1.0))
42
+
43
+ return {"val": value}
44
+
45
+
46
+ def compute_circularity(
47
+ mask: np.ndarray, *, eps: float = 0.001
48
+ ) -> CircularityResult | None:
49
+ mask_binary: Mask = to_binary(mask)
50
+
51
+ cropped_mask, _, _ = crop_mask(mask_binary, pad=1)
52
+
53
+ if cropped_mask.size == 0:
54
+ return None
55
+
56
+ perimeter = skimage.measure.perimeter_crofton(cropped_mask, 4)
57
+
58
+ if perimeter < eps:
59
+ return None
60
+
61
+ area = float(np.count_nonzero(mask_binary))
62
+ value = 4.0 * np.pi * area / perimeter**2
63
+ value = float(np.clip(value, 0.0, 1.0))
64
+
65
+ return {"val": value}
66
+
67
+
68
+ def compute_sphericity(
69
+ mask: np.ndarray, *, eps: float = 0.001
70
+ ) -> SphericityResult | None:
71
+ mask_binary: Mask = to_binary(mask)
72
+
73
+ inscribed = find_inscribed_circle(mask_binary)
74
+ enclosing = find_enclosing_circle(mask_binary)
75
+
76
+ if inscribed is None or enclosing is None:
77
+ return None
78
+
79
+ inscribed_radius = inscribed["r"]
80
+ enclosing_radius = enclosing["r"]
81
+
82
+ if enclosing_radius < eps:
83
+ return None
84
+
85
+ value = float(np.clip(inscribed_radius / enclosing_radius, 0.0, 1.0))
86
+
87
+ return {"val": value, "inscribed": inscribed, "enclosing": enclosing}
88
+
89
+
90
+ def compute_aspect_ratio(
91
+ mask: np.ndarray, *, eps: float = 0.001
92
+ ) -> AspectRatioResult | None:
93
+ mask_binary: Mask = to_binary(mask)
94
+
95
+ ellipse_data = fit_ellipse(mask_binary)
96
+
97
+ if ellipse_data is None:
98
+ return None
99
+
100
+ if ellipse_data["minor"] < eps:
101
+ return None
102
+
103
+ value = ellipse_data["major"] / ellipse_data["minor"]
104
+
105
+ return {"val": value, "ellipse": ellipse_data}
@@ -0,0 +1,41 @@
1
+ import cv2
2
+ import numpy as np
3
+
4
+
5
+ def get_contours(mask: np.ndarray) -> tuple[np.ndarray, ...]:
6
+ cropped_mask, offset_x, offset_y = crop_mask(mask, pad=1)
7
+
8
+ if cropped_mask.size == 0:
9
+ return ()
10
+
11
+ if cropped_mask.dtype != np.uint8:
12
+ cropped_mask = cropped_mask.astype(np.uint8)
13
+
14
+ contours, _ = cv2.findContours(
15
+ cropped_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
16
+ )
17
+
18
+ offset = np.array([[[offset_x, offset_y]]], dtype=np.int32)
19
+ return tuple(contour + offset for contour in contours)
20
+
21
+
22
+ def crop_mask(mask: np.ndarray, pad: int = 1) -> tuple[np.ndarray, int, int]:
23
+ if mask.dtype != np.uint8:
24
+ binary_mask_uint8 = mask.astype(np.uint8)
25
+ else:
26
+ binary_mask_uint8 = mask
27
+
28
+ x_min, y_min, width, height = cv2.boundingRect(binary_mask_uint8)
29
+
30
+ if width == 0 or height == 0:
31
+ return (np.zeros((0, 0), dtype=mask.dtype), 0, 0)
32
+
33
+ cropped = mask[y_min : y_min + height, x_min : x_min + width]
34
+
35
+ if pad > 0:
36
+ cropped = np.pad(cropped, pad_width=pad, mode="constant", constant_values=0)
37
+
38
+ offset_y = y_min - pad
39
+ offset_x = x_min - pad
40
+
41
+ return (cropped, offset_x, offset_y)
@@ -0,0 +1,52 @@
1
+ import numpy as np
2
+ from typing import Annotated, TypeAlias, TypedDict
3
+
4
+
5
+ Bbox: TypeAlias = list[list[float]]
6
+ Mask: TypeAlias = Annotated[np.ndarray, "shape=(H, W), dtype=uint8"]
7
+ Coordinates: TypeAlias = Annotated[np.ndarray, "shape=(N, 2), dtype=float64"]
8
+ Boundary: TypeAlias = Annotated[np.ndarray, "shape=(N, 2), dtype=int32"]
9
+ Points: TypeAlias = Annotated[np.ndarray, "shape=(N, 2), dtype=float32"]
10
+
11
+
12
+ class CircleData(TypedDict):
13
+ x: float
14
+ y: float
15
+ r: float
16
+
17
+
18
+ class EllipseData(TypedDict):
19
+ major: float
20
+ minor: float
21
+ x: float
22
+ y: float
23
+ angle: float
24
+ w: float
25
+ h: float
26
+ bbox: Bbox
27
+
28
+
29
+ class RoundnessResult(TypedDict):
30
+ val: float
31
+
32
+
33
+ class CircularityResult(TypedDict):
34
+ val: float
35
+
36
+
37
+ class SphericityResult(TypedDict):
38
+ val: float
39
+ inscribed: CircleData
40
+ enclosing: CircleData
41
+
42
+
43
+ class AspectRatioResult(TypedDict):
44
+ val: float
45
+ ellipse: EllipseData
46
+
47
+
48
+ class AnalysisResult(TypedDict, total=False):
49
+ roundness: RoundnessResult | None
50
+ circularity: CircularityResult | None
51
+ sphericity: SphericityResult | None
52
+ aspect_ratio: AspectRatioResult | None