grainmorph 0.2.1__tar.gz → 0.3.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.
- grainmorph-0.3.0/PKG-INFO +164 -0
- grainmorph-0.3.0/README.md +144 -0
- {grainmorph-0.2.1 → grainmorph-0.3.0}/pyproject.toml +1 -1
- {grainmorph-0.2.1 → grainmorph-0.3.0}/src/grainmorph/__init__.py +3 -1
- grainmorph-0.3.0/src/grainmorph/analyzer.py +55 -0
- grainmorph-0.3.0/src/grainmorph/mask.py +60 -0
- grainmorph-0.3.0/src/grainmorph/metrics.py +133 -0
- {grainmorph-0.2.1 → grainmorph-0.3.0}/src/grainmorph/utils/__init__.py +3 -9
- grainmorph-0.3.0/src/grainmorph/utils/particle.py +145 -0
- grainmorph-0.3.0/src/grainmorph/utils/plot.py +65 -0
- grainmorph-0.3.0/src/grainmorph/utils/shape.py +82 -0
- grainmorph-0.3.0/src/grainmorph/wadell/__init__.py +0 -0
- grainmorph-0.3.0/src/grainmorph/wadell/corner.py +114 -0
- grainmorph-0.3.0/src/grainmorph/wadell/discretize.py +41 -0
- grainmorph-0.3.0/src/grainmorph/wadell/smoothing.py +24 -0
- grainmorph-0.3.0/src/grainmorph.egg-info/PKG-INFO +164 -0
- {grainmorph-0.2.1 → grainmorph-0.3.0}/src/grainmorph.egg-info/SOURCES.txt +7 -14
- grainmorph-0.3.0/tests/test_analyzer.py +159 -0
- grainmorph-0.3.0/tests/test_corner.py +100 -0
- grainmorph-0.3.0/tests/test_discretize.py +40 -0
- grainmorph-0.3.0/tests/test_mask.py +58 -0
- grainmorph-0.3.0/tests/test_metrics.py +101 -0
- grainmorph-0.3.0/tests/test_particle.py +72 -0
- {grainmorph-0.2.1 → grainmorph-0.3.0}/tests/test_plot.py +10 -8
- grainmorph-0.3.0/tests/test_shape.py +53 -0
- grainmorph-0.3.0/tests/test_smoothing.py +30 -0
- grainmorph-0.2.1/PKG-INFO +0 -204
- grainmorph-0.2.1/README.md +0 -184
- grainmorph-0.2.1/src/grainmorph/analyzer.py +0 -49
- grainmorph-0.2.1/src/grainmorph/fitting.py +0 -79
- grainmorph-0.2.1/src/grainmorph/mask_ops.py +0 -128
- grainmorph-0.2.1/src/grainmorph/metrics.py +0 -112
- grainmorph-0.2.1/src/grainmorph/schema.py +0 -50
- grainmorph-0.2.1/src/grainmorph/utils/create_mask.py +0 -115
- grainmorph-0.2.1/src/grainmorph/utils/geometry.py +0 -23
- grainmorph-0.2.1/src/grainmorph/utils/parametric_mask.py +0 -199
- grainmorph-0.2.1/src/grainmorph/utils/plot.py +0 -99
- grainmorph-0.2.1/src/grainmorph/validation.py +0 -44
- grainmorph-0.2.1/src/grainmorph/wadell/__init__.py +0 -3
- grainmorph-0.2.1/src/grainmorph/wadell/boundary.py +0 -14
- grainmorph-0.2.1/src/grainmorph/wadell/corner.py +0 -163
- grainmorph-0.2.1/src/grainmorph/wadell/discretize.py +0 -66
- grainmorph-0.2.1/src/grainmorph/wadell/roundness.py +0 -61
- grainmorph-0.2.1/src/grainmorph/wadell/smoothing.py +0 -41
- grainmorph-0.2.1/src/grainmorph.egg-info/PKG-INFO +0 -204
- grainmorph-0.2.1/tests/test_analyzer.py +0 -203
- grainmorph-0.2.1/tests/test_corner.py +0 -128
- grainmorph-0.2.1/tests/test_discretize.py +0 -24
- grainmorph-0.2.1/tests/test_fitting.py +0 -75
- grainmorph-0.2.1/tests/test_generation.py +0 -137
- grainmorph-0.2.1/tests/test_mask_ops.py +0 -37
- grainmorph-0.2.1/tests/test_metrics.py +0 -45
- grainmorph-0.2.1/tests/test_smoothing.py +0 -46
- grainmorph-0.2.1/tests/test_validation.py +0 -21
- {grainmorph-0.2.1 → grainmorph-0.3.0}/LICENSE +0 -0
- {grainmorph-0.2.1 → grainmorph-0.3.0}/setup.cfg +0 -0
- {grainmorph-0.2.1 → grainmorph-0.3.0}/src/grainmorph.egg-info/dependency_links.txt +0 -0
- {grainmorph-0.2.1 → grainmorph-0.3.0}/src/grainmorph.egg-info/requires.txt +0 -0
- {grainmorph-0.2.1 → grainmorph-0.3.0}/src/grainmorph.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: grainmorph
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Particle shape analysis and visualization library.
|
|
5
|
+
Author: GrainMorph Contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/phykn/grainmorph
|
|
8
|
+
Project-URL: Repository, https://github.com/phykn/grainmorph
|
|
9
|
+
Project-URL: Issues, https://github.com/phykn/grainmorph/issues
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: numpy
|
|
14
|
+
Requires-Dist: opencv-python
|
|
15
|
+
Requires-Dist: matplotlib
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest; extra == "dev"
|
|
18
|
+
Requires-Dist: ruff; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
<h1 align="center">GrainMorph</h1>
|
|
22
|
+
|
|
23
|
+
<p align="center">Shape metrics for a single 2D binary particle mask.</p>
|
|
24
|
+
|
|
25
|
+
<p align="center">
|
|
26
|
+
<a href="https://pypi.org/project/grainmorph/"><img src="https://img.shields.io/pypi/v/grainmorph" alt="PyPI"></a>
|
|
27
|
+
<img src="https://img.shields.io/badge/python-3.12%2B-blue" alt="Python 3.12+">
|
|
28
|
+
<img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license">
|
|
29
|
+
</p>
|
|
30
|
+
|
|
31
|
+
- **Wadell roundness**: mean corner radius divided by the inscribed circle radius [1, 2]
|
|
32
|
+
- **Circularity**: `4 * pi * area / perimeter^2`
|
|
33
|
+
- **Sphericity**: inscribed circle radius divided by enclosing circle radius
|
|
34
|
+
- **Aspect ratio**: fitted ellipse major axis divided by minor axis
|
|
35
|
+
- Synthetic test masks and a plot of the fitted circles and ellipse
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install grainmorph
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Analyze a mask
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import grainmorph as gm
|
|
47
|
+
|
|
48
|
+
mask = gm.utils.create_circle_mask((256, 256), (128, 128), 60)
|
|
49
|
+
results = gm.analyze_mask(mask)
|
|
50
|
+
|
|
51
|
+
print(results["roundness"]["val"])
|
|
52
|
+
print(results["circularity"]["val"])
|
|
53
|
+
print(results["sphericity"]["val"])
|
|
54
|
+
print(results["aspect_ratio"]["val"])
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`mask` is a 2D `numpy.ndarray` of `bool` or numeric `{0, 1}` values. The largest
|
|
58
|
+
4-connected component is measured with its holes filled, and an empty mask
|
|
59
|
+
returns `None`.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
{
|
|
63
|
+
"roundness": {"val": float} | None,
|
|
64
|
+
"circularity": {"val": float} | None,
|
|
65
|
+
"sphericity": {
|
|
66
|
+
"val": float,
|
|
67
|
+
"inscribed": {"x": float, "y": float, "r": float},
|
|
68
|
+
"enclosing": {"x": float, "y": float, "r": float},
|
|
69
|
+
} | None,
|
|
70
|
+
"aspect_ratio": {
|
|
71
|
+
"val": float,
|
|
72
|
+
"ellipse": {
|
|
73
|
+
"major": float, "minor": float,
|
|
74
|
+
"x": float, "y": float, "angle": float,
|
|
75
|
+
"w": float, "h": float, "bbox": list[list[float]],
|
|
76
|
+
},
|
|
77
|
+
} | None,
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Roundness, circularity, and sphericity lie in `0..1`, and aspect ratio is `>= 1`.
|
|
82
|
+
A metric is `None` when the geometry cannot be measured. Coordinates are in
|
|
83
|
+
pixels of the input mask.
|
|
84
|
+
|
|
85
|
+
## Options
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
results = gm.analyze_mask(
|
|
89
|
+
mask,
|
|
90
|
+
use_aspect_ratio=False,
|
|
91
|
+
roundness_params={"max_dev_thresh": 0.5, "circle_fit_thresh": 0.9},
|
|
92
|
+
eps=0.001,
|
|
93
|
+
target_dim=384,
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- `use_aspect_ratio`, `use_roundness`, `use_circularity`, `use_sphericity`: a metric set to `False` is left out of the result.
|
|
98
|
+
- `roundness_params`: overrides `max_dev_thresh` (0.3, boundary discretization), `circle_fit_thresh` (0.98, corner circle fitting), `alpha_ratio` (0.05) and `beta_ratio` (0.001, boundary smoothing).
|
|
99
|
+
- `eps`: a metric whose denominator is below this is `None`.
|
|
100
|
+
- `target_dim`: a particle larger than this is downscaled for speed, unless that would split or erase thin parts. Coordinates stay in input pixels.
|
|
101
|
+
|
|
102
|
+
## Plot the result
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
gm.utils.plot_analysis_results(mask, results)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Create test masks
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
shape = (256, 256)
|
|
112
|
+
circle = gm.utils.create_circle_mask(shape, (128, 128), 60)
|
|
113
|
+
ellipse = gm.utils.create_ellipse_mask(shape, (128, 128), 90, 50)
|
|
114
|
+
rectangle = gm.utils.create_rectangle_mask(shape, (60, 40), (200, 220))
|
|
115
|
+
square = gm.utils.create_square_mask(shape, (60, 60), 120)
|
|
116
|
+
pentagon = gm.utils.create_pentagon_mask(shape, (128, 128), 90)
|
|
117
|
+
star = gm.utils.create_star_mask(shape, (128, 128), 100, 40, num_points=5)
|
|
118
|
+
triangle = gm.utils.create_triangle_mask(shape, (30, 200), (220, 200), (128, 40))
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Shapes are `(height, width)`, and centers and rectangle corners are `(y, x)`.
|
|
122
|
+
Triangle vertices are `(x, y)`.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
mask, info = gm.utils.create_particle_mask(
|
|
126
|
+
(256, 256), (128, 128), radius=60, sphericity=0.85, roundness=0.5,
|
|
127
|
+
seed=42, return_info=True,
|
|
128
|
+
)
|
|
129
|
+
print(info["roundness_achieved"], info["target_met"])
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`create_particle_mask` roughens an ellipse until its measured roundness
|
|
133
|
+
approaches the target. `info["roundness_achieved"]` is the roundness
|
|
134
|
+
`analyze_mask` gives for the returned mask.
|
|
135
|
+
|
|
136
|
+
## Develop
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
pip install -e ".[dev]"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
python -m pytest -q
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
python -m ruff check --isolated .
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
python -m ruff format --isolated --check src tests
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Pushing a `vX.Y.Z` tag publishes to PyPI. The tag must match the version in
|
|
155
|
+
`pyproject.toml`.
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
|
160
|
+
|
|
161
|
+
## References
|
|
162
|
+
|
|
163
|
+
1. H. Wadell, "Volume, shape, and roundness of rock particles," The Journal of Geology, 40(5), 443-451, 1932.
|
|
164
|
+
2. J. Zheng and R. D. Hryciw, "Traditional soil particle sphericity, roundness and surface roughness by computational geometry," Géotechnique, 65(6), 494-506, 2015.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<h1 align="center">GrainMorph</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">Shape metrics for a single 2D binary particle mask.</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://pypi.org/project/grainmorph/"><img src="https://img.shields.io/pypi/v/grainmorph" alt="PyPI"></a>
|
|
7
|
+
<img src="https://img.shields.io/badge/python-3.12%2B-blue" alt="Python 3.12+">
|
|
8
|
+
<img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license">
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
- **Wadell roundness**: mean corner radius divided by the inscribed circle radius [1, 2]
|
|
12
|
+
- **Circularity**: `4 * pi * area / perimeter^2`
|
|
13
|
+
- **Sphericity**: inscribed circle radius divided by enclosing circle radius
|
|
14
|
+
- **Aspect ratio**: fitted ellipse major axis divided by minor axis
|
|
15
|
+
- Synthetic test masks and a plot of the fitted circles and ellipse
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install grainmorph
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Analyze a mask
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import grainmorph as gm
|
|
27
|
+
|
|
28
|
+
mask = gm.utils.create_circle_mask((256, 256), (128, 128), 60)
|
|
29
|
+
results = gm.analyze_mask(mask)
|
|
30
|
+
|
|
31
|
+
print(results["roundness"]["val"])
|
|
32
|
+
print(results["circularity"]["val"])
|
|
33
|
+
print(results["sphericity"]["val"])
|
|
34
|
+
print(results["aspect_ratio"]["val"])
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`mask` is a 2D `numpy.ndarray` of `bool` or numeric `{0, 1}` values. The largest
|
|
38
|
+
4-connected component is measured with its holes filled, and an empty mask
|
|
39
|
+
returns `None`.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
{
|
|
43
|
+
"roundness": {"val": float} | None,
|
|
44
|
+
"circularity": {"val": float} | None,
|
|
45
|
+
"sphericity": {
|
|
46
|
+
"val": float,
|
|
47
|
+
"inscribed": {"x": float, "y": float, "r": float},
|
|
48
|
+
"enclosing": {"x": float, "y": float, "r": float},
|
|
49
|
+
} | None,
|
|
50
|
+
"aspect_ratio": {
|
|
51
|
+
"val": float,
|
|
52
|
+
"ellipse": {
|
|
53
|
+
"major": float, "minor": float,
|
|
54
|
+
"x": float, "y": float, "angle": float,
|
|
55
|
+
"w": float, "h": float, "bbox": list[list[float]],
|
|
56
|
+
},
|
|
57
|
+
} | None,
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Roundness, circularity, and sphericity lie in `0..1`, and aspect ratio is `>= 1`.
|
|
62
|
+
A metric is `None` when the geometry cannot be measured. Coordinates are in
|
|
63
|
+
pixels of the input mask.
|
|
64
|
+
|
|
65
|
+
## Options
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
results = gm.analyze_mask(
|
|
69
|
+
mask,
|
|
70
|
+
use_aspect_ratio=False,
|
|
71
|
+
roundness_params={"max_dev_thresh": 0.5, "circle_fit_thresh": 0.9},
|
|
72
|
+
eps=0.001,
|
|
73
|
+
target_dim=384,
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `use_aspect_ratio`, `use_roundness`, `use_circularity`, `use_sphericity`: a metric set to `False` is left out of the result.
|
|
78
|
+
- `roundness_params`: overrides `max_dev_thresh` (0.3, boundary discretization), `circle_fit_thresh` (0.98, corner circle fitting), `alpha_ratio` (0.05) and `beta_ratio` (0.001, boundary smoothing).
|
|
79
|
+
- `eps`: a metric whose denominator is below this is `None`.
|
|
80
|
+
- `target_dim`: a particle larger than this is downscaled for speed, unless that would split or erase thin parts. Coordinates stay in input pixels.
|
|
81
|
+
|
|
82
|
+
## Plot the result
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
gm.utils.plot_analysis_results(mask, results)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Create test masks
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
shape = (256, 256)
|
|
92
|
+
circle = gm.utils.create_circle_mask(shape, (128, 128), 60)
|
|
93
|
+
ellipse = gm.utils.create_ellipse_mask(shape, (128, 128), 90, 50)
|
|
94
|
+
rectangle = gm.utils.create_rectangle_mask(shape, (60, 40), (200, 220))
|
|
95
|
+
square = gm.utils.create_square_mask(shape, (60, 60), 120)
|
|
96
|
+
pentagon = gm.utils.create_pentagon_mask(shape, (128, 128), 90)
|
|
97
|
+
star = gm.utils.create_star_mask(shape, (128, 128), 100, 40, num_points=5)
|
|
98
|
+
triangle = gm.utils.create_triangle_mask(shape, (30, 200), (220, 200), (128, 40))
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Shapes are `(height, width)`, and centers and rectangle corners are `(y, x)`.
|
|
102
|
+
Triangle vertices are `(x, y)`.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
mask, info = gm.utils.create_particle_mask(
|
|
106
|
+
(256, 256), (128, 128), radius=60, sphericity=0.85, roundness=0.5,
|
|
107
|
+
seed=42, return_info=True,
|
|
108
|
+
)
|
|
109
|
+
print(info["roundness_achieved"], info["target_met"])
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`create_particle_mask` roughens an ellipse until its measured roundness
|
|
113
|
+
approaches the target. `info["roundness_achieved"]` is the roundness
|
|
114
|
+
`analyze_mask` gives for the returned mask.
|
|
115
|
+
|
|
116
|
+
## Develop
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pip install -e ".[dev]"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
python -m pytest -q
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
python -m ruff check --isolated .
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
python -m ruff format --isolated --check src tests
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Pushing a `vX.Y.Z` tag publishes to PyPI. The tag must match the version in
|
|
135
|
+
`pyproject.toml`.
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT
|
|
140
|
+
|
|
141
|
+
## References
|
|
142
|
+
|
|
143
|
+
1. H. Wadell, "Volume, shape, and roundness of rock particles," The Journal of Geology, 40(5), 443-451, 1932.
|
|
144
|
+
2. J. Zheng and R. D. Hryciw, "Traditional soil particle sphericity, roundness and surface roughness by computational geometry," Géotechnique, 65(6), 494-506, 2015.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from numbers import Integral
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from .mask import prepare_mask, restore_coordinates
|
|
6
|
+
from .metrics import (
|
|
7
|
+
compute_aspect_ratio,
|
|
8
|
+
compute_circularity,
|
|
9
|
+
compute_roundness,
|
|
10
|
+
compute_sphericity,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def analyze_mask(
|
|
15
|
+
mask,
|
|
16
|
+
*,
|
|
17
|
+
use_aspect_ratio=True,
|
|
18
|
+
use_roundness=True,
|
|
19
|
+
use_circularity=True,
|
|
20
|
+
use_sphericity=True,
|
|
21
|
+
roundness_params=None,
|
|
22
|
+
eps=0.001,
|
|
23
|
+
target_dim=384,
|
|
24
|
+
):
|
|
25
|
+
if not (np.isfinite(eps) and eps > 0):
|
|
26
|
+
raise ValueError("eps must be > 0 and finite.")
|
|
27
|
+
if isinstance(target_dim, bool) or not isinstance(target_dim, Integral):
|
|
28
|
+
raise TypeError("target_dim must be an integer.")
|
|
29
|
+
if target_dim <= 0:
|
|
30
|
+
raise ValueError("target_dim must be > 0.")
|
|
31
|
+
if not isinstance(mask, np.ndarray):
|
|
32
|
+
raise TypeError("mask must be a numpy.ndarray.")
|
|
33
|
+
if mask.ndim != 2:
|
|
34
|
+
raise ValueError(f"mask must be 2D, got {mask.ndim}D.")
|
|
35
|
+
if mask.dtype.kind not in "biuf" or not ((mask == 0) | (mask == 1)).all():
|
|
36
|
+
raise ValueError("mask must be binary with values in {0, 1}.")
|
|
37
|
+
|
|
38
|
+
mask = mask.astype(np.uint8)
|
|
39
|
+
if not mask.any():
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
mask, scale, offset_x, offset_y = prepare_mask(mask, target_dim)
|
|
43
|
+
|
|
44
|
+
results = {}
|
|
45
|
+
if use_aspect_ratio:
|
|
46
|
+
results["aspect_ratio"] = compute_aspect_ratio(mask, eps)
|
|
47
|
+
if use_roundness:
|
|
48
|
+
results["roundness"] = compute_roundness(mask, **(roundness_params or {}))
|
|
49
|
+
if use_circularity:
|
|
50
|
+
results["circularity"] = compute_circularity(mask, eps)
|
|
51
|
+
if use_sphericity:
|
|
52
|
+
results["sphericity"] = compute_sphericity(mask, eps)
|
|
53
|
+
|
|
54
|
+
restore_coordinates(results, scale, offset_x, offset_y)
|
|
55
|
+
return results
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def prepare_mask(mask, target_dim):
|
|
6
|
+
x0, y0, w, h = cv2.boundingRect(mask)
|
|
7
|
+
mask = mask[y0 : y0 + h, x0 : x0 + w]
|
|
8
|
+
|
|
9
|
+
_, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=4)
|
|
10
|
+
largest = 1 + np.argmax(stats[1:, cv2.CC_STAT_AREA])
|
|
11
|
+
x1, y1, w, h = stats[largest, :4]
|
|
12
|
+
mask = (labels[y1 : y1 + h, x1 : x1 + w] == largest).astype(np.uint8)
|
|
13
|
+
|
|
14
|
+
# Background 4-connected to the padded border stays empty; the rest are holes.
|
|
15
|
+
flooded = np.pad(mask, 1)
|
|
16
|
+
cv2.floodFill(flooded, None, (0, 0), 2, flags=4)
|
|
17
|
+
mask = (flooded[1:-1, 1:-1] != 2).astype(np.uint8)
|
|
18
|
+
|
|
19
|
+
scale = 1.0
|
|
20
|
+
ratio = target_dim / max(w, h)
|
|
21
|
+
width, height = round(w * ratio), round(h * ratio)
|
|
22
|
+
if ratio < 1 and min(width, height) > 1:
|
|
23
|
+
shift = (ratio - 1.0) / 2.0
|
|
24
|
+
small = cv2.warpAffine(
|
|
25
|
+
mask,
|
|
26
|
+
np.array([[ratio, 0.0, shift], [0.0, ratio, shift]]),
|
|
27
|
+
(width, height),
|
|
28
|
+
flags=cv2.INTER_NEAREST,
|
|
29
|
+
)
|
|
30
|
+
# Downscaling can erase or split thin parts; such a mask keeps its size.
|
|
31
|
+
if cv2.connectedComponents(small, connectivity=4)[0] == 2:
|
|
32
|
+
mask = small
|
|
33
|
+
scale = 1.0 / ratio
|
|
34
|
+
|
|
35
|
+
# OpenCV maps pixel centers, so the half-pixel term is part of the inverse map.
|
|
36
|
+
offset_x = x0 + x1 + (scale - 1.0) / 2.0
|
|
37
|
+
offset_y = y0 + y1 + (scale - 1.0) / 2.0
|
|
38
|
+
return mask, scale, offset_x, offset_y
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def restore_coordinates(results, scale, offset_x, offset_y):
|
|
42
|
+
sphericity = results.get("sphericity")
|
|
43
|
+
if sphericity:
|
|
44
|
+
for key in ("inscribed", "enclosing"):
|
|
45
|
+
circle = sphericity[key]
|
|
46
|
+
circle["x"] = float(circle["x"] * scale + offset_x)
|
|
47
|
+
circle["y"] = float(circle["y"] * scale + offset_y)
|
|
48
|
+
circle["r"] = float(circle["r"] * scale)
|
|
49
|
+
|
|
50
|
+
aspect_ratio = results.get("aspect_ratio")
|
|
51
|
+
if aspect_ratio:
|
|
52
|
+
ellipse = aspect_ratio["ellipse"]
|
|
53
|
+
ellipse["x"] = float(ellipse["x"] * scale + offset_x)
|
|
54
|
+
ellipse["y"] = float(ellipse["y"] * scale + offset_y)
|
|
55
|
+
for key in ("major", "minor", "w", "h"):
|
|
56
|
+
ellipse[key] = float(ellipse[key] * scale)
|
|
57
|
+
ellipse["bbox"] = [
|
|
58
|
+
[float(x * scale + offset_x), float(y * scale + offset_y)]
|
|
59
|
+
for x, y in ellipse["bbox"]
|
|
60
|
+
]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
from .wadell.corner import compute_corner_radii
|
|
5
|
+
from .wadell.discretize import discretize_boundary
|
|
6
|
+
from .wadell.smoothing import smooth_boundary
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compute_aspect_ratio(mask, eps):
|
|
10
|
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
11
|
+
points = contours[0].reshape(-1, 2).astype(np.float32)
|
|
12
|
+
|
|
13
|
+
if len(points) < 5:
|
|
14
|
+
rect = cv2.minAreaRect(points)
|
|
15
|
+
(x, y), (w, h), angle = rect
|
|
16
|
+
bbox = cv2.boxPoints(rect).tolist()
|
|
17
|
+
else:
|
|
18
|
+
(x, y), (w, h), angle = cv2.fitEllipse(points)
|
|
19
|
+
theta = np.deg2rad(angle)
|
|
20
|
+
axes = np.array(
|
|
21
|
+
[[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]]
|
|
22
|
+
)
|
|
23
|
+
projected = points @ axes
|
|
24
|
+
x_min, y_min = projected.min(axis=0)
|
|
25
|
+
x_max, y_max = projected.max(axis=0)
|
|
26
|
+
corners = [[x_min, y_min], [x_max, y_min], [x_max, y_max], [x_min, y_max]]
|
|
27
|
+
bbox = (np.array(corners) @ axes.T).tolist()
|
|
28
|
+
|
|
29
|
+
if min(w, h) < eps:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
"val": max(w, h) / min(w, h),
|
|
34
|
+
"ellipse": {
|
|
35
|
+
"major": max(w, h),
|
|
36
|
+
"minor": min(w, h),
|
|
37
|
+
"x": x,
|
|
38
|
+
"y": y,
|
|
39
|
+
"angle": angle,
|
|
40
|
+
"w": w,
|
|
41
|
+
"h": h,
|
|
42
|
+
"bbox": bbox,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def compute_roundness(
|
|
48
|
+
mask,
|
|
49
|
+
max_dev_thresh=0.3,
|
|
50
|
+
circle_fit_thresh=0.98,
|
|
51
|
+
alpha_ratio=0.05,
|
|
52
|
+
beta_ratio=0.001,
|
|
53
|
+
):
|
|
54
|
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
|
|
55
|
+
boundary = contours[0].reshape(-1, 2).astype(np.float64)
|
|
56
|
+
if len(boundary) < 3:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
smoothed = smooth_boundary(boundary, alpha_ratio, beta_ratio)
|
|
60
|
+
keypoints = discretize_boundary(smoothed, max_dev_thresh)
|
|
61
|
+
if len(keypoints) < 3:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
inscribed = find_inscribed_circle(mask)
|
|
65
|
+
radii = compute_corner_radii(
|
|
66
|
+
keypoints,
|
|
67
|
+
inscribed["r"],
|
|
68
|
+
np.array([inscribed["x"], inscribed["y"]]),
|
|
69
|
+
circle_fit_thresh,
|
|
70
|
+
)
|
|
71
|
+
if len(radii) == 0:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
return {"val": float(np.clip(np.mean(radii) / inscribed["r"], 0.0, 1.0))}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def compute_circularity(mask, eps):
|
|
78
|
+
# Four-direction Crofton weights indexed by 2x2 pixel pattern, matching
|
|
79
|
+
# skimage.measure.perimeter_crofton(image, directions=4).
|
|
80
|
+
weights = np.array(
|
|
81
|
+
[
|
|
82
|
+
0,
|
|
83
|
+
np.pi / 4 * (1 + 1 / np.sqrt(2)),
|
|
84
|
+
np.pi / (4 * np.sqrt(2)),
|
|
85
|
+
np.pi / (2 * np.sqrt(2)),
|
|
86
|
+
0,
|
|
87
|
+
np.pi / 4 * (1 + 1 / np.sqrt(2)),
|
|
88
|
+
0,
|
|
89
|
+
np.pi / (4 * np.sqrt(2)),
|
|
90
|
+
np.pi / 4,
|
|
91
|
+
np.pi / 2,
|
|
92
|
+
np.pi / (4 * np.sqrt(2)),
|
|
93
|
+
np.pi / (4 * np.sqrt(2)),
|
|
94
|
+
np.pi / 4,
|
|
95
|
+
np.pi / 2,
|
|
96
|
+
0,
|
|
97
|
+
0,
|
|
98
|
+
]
|
|
99
|
+
)
|
|
100
|
+
padded = np.pad(mask, 1)
|
|
101
|
+
patterns = (
|
|
102
|
+
padded[1:, 1:]
|
|
103
|
+
+ 2 * padded[:-1, 1:]
|
|
104
|
+
+ 4 * padded[1:, :-1]
|
|
105
|
+
+ 8 * padded[:-1, :-1]
|
|
106
|
+
)
|
|
107
|
+
perimeter = float(weights @ np.bincount(patterns.ravel(), minlength=16))
|
|
108
|
+
if perimeter < eps:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
area = np.count_nonzero(mask)
|
|
112
|
+
return {"val": float(np.clip(4.0 * np.pi * area / perimeter**2, 0.0, 1.0))}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def compute_sphericity(mask, eps):
|
|
116
|
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
117
|
+
(x, y), r = cv2.minEnclosingCircle(cv2.convexHull(contours[0]))
|
|
118
|
+
if r < eps:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
inscribed = find_inscribed_circle(mask)
|
|
122
|
+
return {
|
|
123
|
+
"val": float(np.clip(inscribed["r"] / r, 0.0, 1.0)),
|
|
124
|
+
"inscribed": inscribed,
|
|
125
|
+
"enclosing": {"x": x, "y": y, "r": r},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def find_inscribed_circle(mask):
|
|
130
|
+
# Padding makes the image border count as background.
|
|
131
|
+
dist = cv2.distanceTransform(np.pad(mask, 1), cv2.DIST_L2, cv2.DIST_MASK_PRECISE)
|
|
132
|
+
y, x = np.unravel_index(np.argmax(dist), dist.shape)
|
|
133
|
+
return {"x": float(x - 1), "y": float(y - 1), "r": float(dist[y, x])}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
from .
|
|
1
|
+
from .particle import create_particle_mask
|
|
2
|
+
from .plot import plot_analysis_results
|
|
3
|
+
from .shape import (
|
|
2
4
|
create_circle_mask,
|
|
3
5
|
create_ellipse_mask,
|
|
4
6
|
create_pentagon_mask,
|
|
@@ -7,14 +9,6 @@ from .create_mask import (
|
|
|
7
9
|
create_star_mask,
|
|
8
10
|
create_triangle_mask,
|
|
9
11
|
)
|
|
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
12
|
|
|
19
13
|
__all__ = [
|
|
20
14
|
"create_circle_mask",
|