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.
- partimorph-0.1.0/LICENSE +21 -0
- partimorph-0.1.0/PKG-INFO +100 -0
- partimorph-0.1.0/README.md +81 -0
- partimorph-0.1.0/pyproject.toml +31 -0
- partimorph-0.1.0/setup.cfg +4 -0
- partimorph-0.1.0/src/partimorph/__init__.py +5 -0
- partimorph-0.1.0/src/partimorph/analyzer.py +111 -0
- partimorph-0.1.0/src/partimorph/fitting.py +140 -0
- partimorph-0.1.0/src/partimorph/metrics.py +105 -0
- partimorph-0.1.0/src/partimorph/misc.py +41 -0
- partimorph-0.1.0/src/partimorph/schema.py +52 -0
- partimorph-0.1.0/src/partimorph/utils/__init__.py +29 -0
- partimorph-0.1.0/src/partimorph/utils/create_mask.py +109 -0
- partimorph-0.1.0/src/partimorph/utils/geometry.py +27 -0
- partimorph-0.1.0/src/partimorph/utils/parametric_mask.py +180 -0
- partimorph-0.1.0/src/partimorph/utils/plot.py +97 -0
- partimorph-0.1.0/src/partimorph/validation.py +27 -0
- partimorph-0.1.0/src/partimorph/wadell/__init__.py +3 -0
- partimorph-0.1.0/src/partimorph/wadell/boundary.py +93 -0
- partimorph-0.1.0/src/partimorph/wadell/corner.py +84 -0
- partimorph-0.1.0/src/partimorph/wadell/discretize.py +101 -0
- partimorph-0.1.0/src/partimorph/wadell/roundness.py +76 -0
- partimorph-0.1.0/src/partimorph/wadell/smoothing.py +30 -0
- partimorph-0.1.0/src/partimorph.egg-info/PKG-INFO +100 -0
- partimorph-0.1.0/src/partimorph.egg-info/SOURCES.txt +27 -0
- partimorph-0.1.0/src/partimorph.egg-info/dependency_links.txt +1 -0
- partimorph-0.1.0/src/partimorph.egg-info/requires.txt +9 -0
- partimorph-0.1.0/src/partimorph.egg-info/top_level.txt +1 -0
- partimorph-0.1.0/tests/test_partimorph.py +139 -0
partimorph-0.1.0/LICENSE
ADDED
|
@@ -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,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
|