grainmorph 0.2.0__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.
Files changed (59) hide show
  1. grainmorph-0.3.0/PKG-INFO +164 -0
  2. grainmorph-0.3.0/README.md +144 -0
  3. {grainmorph-0.2.0 → grainmorph-0.3.0}/pyproject.toml +1 -1
  4. {grainmorph-0.2.0 → grainmorph-0.3.0}/src/grainmorph/__init__.py +3 -1
  5. grainmorph-0.3.0/src/grainmorph/analyzer.py +55 -0
  6. grainmorph-0.3.0/src/grainmorph/mask.py +60 -0
  7. grainmorph-0.3.0/src/grainmorph/metrics.py +133 -0
  8. {grainmorph-0.2.0 → grainmorph-0.3.0}/src/grainmorph/utils/__init__.py +3 -9
  9. grainmorph-0.3.0/src/grainmorph/utils/particle.py +145 -0
  10. grainmorph-0.3.0/src/grainmorph/utils/plot.py +65 -0
  11. grainmorph-0.3.0/src/grainmorph/utils/shape.py +82 -0
  12. grainmorph-0.3.0/src/grainmorph/wadell/__init__.py +0 -0
  13. grainmorph-0.3.0/src/grainmorph/wadell/corner.py +114 -0
  14. grainmorph-0.3.0/src/grainmorph/wadell/discretize.py +41 -0
  15. grainmorph-0.3.0/src/grainmorph/wadell/smoothing.py +24 -0
  16. grainmorph-0.3.0/src/grainmorph.egg-info/PKG-INFO +164 -0
  17. {grainmorph-0.2.0 → grainmorph-0.3.0}/src/grainmorph.egg-info/SOURCES.txt +7 -14
  18. grainmorph-0.3.0/tests/test_analyzer.py +159 -0
  19. grainmorph-0.3.0/tests/test_corner.py +100 -0
  20. grainmorph-0.3.0/tests/test_discretize.py +40 -0
  21. grainmorph-0.3.0/tests/test_mask.py +58 -0
  22. grainmorph-0.3.0/tests/test_metrics.py +101 -0
  23. grainmorph-0.3.0/tests/test_particle.py +72 -0
  24. {grainmorph-0.2.0 → grainmorph-0.3.0}/tests/test_plot.py +11 -9
  25. grainmorph-0.3.0/tests/test_shape.py +53 -0
  26. grainmorph-0.3.0/tests/test_smoothing.py +30 -0
  27. grainmorph-0.2.0/PKG-INFO +0 -208
  28. grainmorph-0.2.0/README.md +0 -188
  29. grainmorph-0.2.0/src/grainmorph/analyzer.py +0 -49
  30. grainmorph-0.2.0/src/grainmorph/fitting.py +0 -79
  31. grainmorph-0.2.0/src/grainmorph/mask_ops.py +0 -128
  32. grainmorph-0.2.0/src/grainmorph/metrics.py +0 -112
  33. grainmorph-0.2.0/src/grainmorph/schema.py +0 -50
  34. grainmorph-0.2.0/src/grainmorph/utils/create_mask.py +0 -115
  35. grainmorph-0.2.0/src/grainmorph/utils/geometry.py +0 -23
  36. grainmorph-0.2.0/src/grainmorph/utils/parametric_mask.py +0 -199
  37. grainmorph-0.2.0/src/grainmorph/utils/plot.py +0 -99
  38. grainmorph-0.2.0/src/grainmorph/validation.py +0 -44
  39. grainmorph-0.2.0/src/grainmorph/wadell/__init__.py +0 -3
  40. grainmorph-0.2.0/src/grainmorph/wadell/boundary.py +0 -14
  41. grainmorph-0.2.0/src/grainmorph/wadell/corner.py +0 -163
  42. grainmorph-0.2.0/src/grainmorph/wadell/discretize.py +0 -66
  43. grainmorph-0.2.0/src/grainmorph/wadell/roundness.py +0 -61
  44. grainmorph-0.2.0/src/grainmorph/wadell/smoothing.py +0 -41
  45. grainmorph-0.2.0/src/grainmorph.egg-info/PKG-INFO +0 -208
  46. grainmorph-0.2.0/tests/test_analyzer.py +0 -203
  47. grainmorph-0.2.0/tests/test_corner.py +0 -128
  48. grainmorph-0.2.0/tests/test_discretize.py +0 -24
  49. grainmorph-0.2.0/tests/test_fitting.py +0 -75
  50. grainmorph-0.2.0/tests/test_generation.py +0 -137
  51. grainmorph-0.2.0/tests/test_mask_ops.py +0 -37
  52. grainmorph-0.2.0/tests/test_metrics.py +0 -45
  53. grainmorph-0.2.0/tests/test_smoothing.py +0 -46
  54. grainmorph-0.2.0/tests/test_validation.py +0 -21
  55. {grainmorph-0.2.0 → grainmorph-0.3.0}/LICENSE +0 -0
  56. {grainmorph-0.2.0 → grainmorph-0.3.0}/setup.cfg +0 -0
  57. {grainmorph-0.2.0 → grainmorph-0.3.0}/src/grainmorph.egg-info/dependency_links.txt +0 -0
  58. {grainmorph-0.2.0 → grainmorph-0.3.0}/src/grainmorph.egg-info/requires.txt +0 -0
  59. {grainmorph-0.2.0 → 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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "grainmorph"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  authors = [
9
9
  { name = "GrainMorph Contributors" }
10
10
  ]
@@ -1,5 +1,7 @@
1
+ from importlib.metadata import version
2
+
1
3
  from . import utils
2
4
  from .analyzer import analyze_mask
3
5
 
4
- __version__ = "0.2.0"
6
+ __version__ = version("grainmorph")
5
7
  __all__ = ["analyze_mask", "utils"]
@@ -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 .create_mask import (
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",