# -*- coding: utf-8 -*-
"""
This module contains functions to provide geometrical descriptions of a label image.
Each labelled region is turned to a surface. This allows mesh generators to work on
the geometry instead of an image, thereby creating good quality meshes.
The following functions require PythonOCC version 0.18 to be installed to allow spline
manipulations:
- `fit_spline`
- `branches2splines`
- `region_as_splinegon`
- `write_step_file`
- `regions2step`
- `plot_splinegons`
- `splinegonize`
- `face_area`
- `subtract`
- `make_holes`
Functions
---------
.. autosummary::
:toctree: functions/
build_skeleton
skeleton2regions
polygon_orientation
branches2boundary
region_as_polygon
polygonize
branches2splines
fit_spline
region_as_splinegon
make_holes
splinegonize
regions2step
plot_polygons
plot_splinegons
write_step_file
plot_polygon
overlay_regions
search_neighbor
face_area
subtract
"""
import os
import warnings
from collections import Counter
from functools import partial
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import collections
from skimage.segmentation import find_boundaries
from skimage.morphology import skeletonize
from skimage.color import label2rgb
from skan import Skeleton, summarize
from skan.draw import overlay_skeleton_networkx
try:
from OCC.gp import gp_Pnt
from OCC.TColgp import TColgp_Array1OfPnt
from OCC.GeomAPI import GeomAPI_PointsToBSpline
from OCC.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakeEdge, \
BRepBuilderAPI_MakeWire
from OCC.TopoDS import TopoDS_Compound
from OCC.BRep import BRep_Builder
from OCC.TopTools import TopTools_ListOfShape
from OCC.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
from OCC.GProp import GProp_GProps
from OCC.BRepGProp import brepgprop_SurfaceProperties
from OCC.STEPControl import STEPControl_Writer, STEPControl_AsIs
from OCC.Interface import Interface_Static_SetCVal
from OCC.IFSelect import IFSelect_RetDone
from OCC.Display.SimpleGui import init_display
from OCC.Display.OCCViewer import rgb_color
except:
warnings.warn('PythonOCC is not available. Some functions cannot be used.', ImportWarning)
from .utils import toggle, index_list, non_unique, neighborhood
[docs]def build_skeleton(label_image, connectivity=1, detect_boundaries=True):
"""Builds skeleton connectivity of a label image.
A single-pixel wide network is created, separating the labelled image regions. The resulting
network contains information about how the regions are connected.
Parameters
----------
label_image : ndarray
Labeled input image, represented as a 2D numpy array of positive integers.
connectivity : {1,2}, optional
A connectivity of 1 (default) means pixels sharing an edge will be considered neighbors.
A connectivity of 2 means pixels sharing a corner will be considered neighbors.
detect_boundaries : bool, optional
When True, the image boundaries will be treated as part of the skeleton. This allows
identifying boundary regions in the `skeleton2regions` function. The default is True.
Returns
-------
skeleton_network : Skeleton
Geometrical and topological information about the skeleton network of the input image.
See Also
--------
:class:`skan.csr.Skeleton`
"""
# 2D image, given as a numpy array is expected
if type(label_image) != np.ndarray:
raise Exception('Label image must be a numpy array (ndarray).')
image_size = np.shape(label_image)
if len(image_size) != 2:
raise Exception('A 2D array is expected.')
if not issubclass(label_image.dtype.type, np.signedinteger):
raise Exception('Matrix entries must be positive integers.')
# Surround the image with an outer region
if detect_boundaries:
label_image = np.pad(label_image, pad_width=1, mode='constant', constant_values=-1)
# Find the boundaries of the label image and then extract its skeleton
boundaries = find_boundaries(label_image, connectivity=connectivity)
skeleton = skeletonize(boundaries)
# Build the skeleton network using `skan`
skeleton_network = Skeleton(skeleton, source_image=label_image, keep_images=True)
return skeleton_network
[docs]def skeleton2regions(skeleton_network, neighbor_search_algorithm):
"""Determines the regions bounded by a skeleton network.
This function can be perceived as an intermediate step between a skeleton network and
completely geometrical representation of the regions. That is, it keeps the key topological
information required to create a fully geometrical description, but it also contains the
coordinates of the region boundaries. The outputs of this function can be used to build
different region representations.
Parameters
----------
skeleton_network : Skeleton
Geometrical and topological information about the skeleton network of a label image.
neighbor_search_algorithm : functools.partial
Specifies which algorithm to use for constructing the branch-region connectivity.
The function to be passed (along with its arguments) is :func:`search_neighbor`.
For further details, see the Notes below.
Returns
-------
region_branches : dict
For each region it contains the branch indices that bound that region.
branch_coordinates : list
Coordinates of the points on each branch.
branch_regions : dict
For each branch it contains the neighboring regions.
This auxiliary data is not essential as it can be restored from :code:`region_branches`.
However, it is computed as temporary data needed for :code:`region_branches`.
See Also
--------
build_skeleton
search_neighbor
overlay_regions
Notes
-----
Although the algorithms were created to require minimum user intervention, some parameters
must be fine-tuned so as to achieve an optimal result in identifying the regions. Visualization
plays an important role in it. Full automation is either not possible or would require a huge
computational cost. The shortcoming of the algorithms in this function is the following.
The recognition of which branches form a region is based on the premise that a node of a branch
belongs to a region if its `n`-pixel neighbourhood contains a pixel from that region. Ideally,
`n=1` would be used, meaning that the single-pixel width skeleton is located at most 1 pixel
afar from the regions it lies among. This is true but the nodes of the skeleton can be farther
than 1 pixel from a region. Hence, `n` has to be a parameter of our model. Increasing `n` helps
in identifying the connecting regions to a node of a branch. On the other hand, if `n` is too
large, regions that "in reality" are not neighbors of a branch will be included. Currently, we
recommend trying different parameters `n`, plot the reconstructed regions over the label image
using the :func:`overlay_regions` function, and see how good the result is. As a heuristic,
start with `n=2`.
"""
if not isinstance(skeleton_network, Skeleton):
raise Exception('Skeleton object is expected.')
S = skeleton_network
skeleton_data = summarize(S)
junction_to_junction = skeleton_data['branch-type'] == 2
isolated_cycle = skeleton_data['branch-type'] == 3
mask = junction_to_junction | isolated_cycle
image_size = np.shape(S.source_image)
image_index_grid = [(0, image_size[0] - 1), (0, image_size[1] - 1)]
# Find which two regions are incident to a branch
branch_coordinates = [S.path_coordinates(i) for i in range(S.n_paths) if mask[i]]
branch_regions = []
for nodes in branch_coordinates:
c = Counter()
internal_nodes = range(1, np.size(nodes, 0) - 1)
for node in internal_nodes:
# Snap node to the nearest image coordinate
node_coord = np.round(nodes[node, :]).astype(np.uint32)
# Look-around for the neighboring pixels (be careful on the image boundaries)
neighbors = neighbor_search_algorithm(node_coord, bounds=image_index_grid)
neighbors = S.source_image[neighbors]
c.update(neighbors)
neighboring_regions = [pair[0] for pair in c.most_common(2)]
branch_regions.append(neighboring_regions)
branch_regions = {key: val for key, val in enumerate(branch_regions)}
# For each region, find the branches that bound it
region_branches = {}
for branch, regions in branch_regions.items():
for region in regions:
if region not in region_branches:
region_branches[region] = [branch]
else:
region_branches[region].append(branch)
return region_branches, branch_coordinates, branch_regions
[docs]def polygon_orientation(polygon):
"""Determines whether a polygon is oriented clockwise or counterclockwise.
Parameters
----------
polygon : list
Each element of the list denotes a vertex of the polygon and in turn is another list of two
elements: the x and y coordinates of a vertex.
Returns
-------
orientation : {'cw', 'ccw'}
'cw': clockwise, 'ccw': counterclockwise orientation
Notes
-----
The formula to determine the orientation is from https://stackoverflow.com/a/1165943/4892892.
For simple polygons (polygons that admit a well-defined interior), a faster algorithm exits, see
https://en.wikipedia.org/wiki/Curve_orientation#Orientation_of_a_simple_polygon.
Examples
--------
>>> polygon = [[5, 0], [6, 4], [4, 5], [1, 5], [1, 0]]
>>> polygon_orientation(polygon)
'ccw'
"""
n_vertex = len(polygon)
edge_sum = 0
for idx, vertex in enumerate(polygon):
next_vertex = polygon[(idx + 1) % n_vertex] # allow indexing past the last vertex
edge_sum += (next_vertex[0] - vertex[0]) * (next_vertex[1] + vertex[1])
if edge_sum > 0:
orientation = 'cw'
else:
orientation = 'ccw'
return orientation
[docs]def branches2boundary(branches, orientation='ccw'):
"""Joins connecting branches so that they form the boundary of a surface.
This function assumes that you already know that a set of branches form the boundary of a
surface, but you want to know the ordering. Clockwise or counterclockwise orientation is
supported. A branch is given by its two end points but it can also contain intermediate points
in between, as in the general case. The points on the branches are assumed to be ordered. If
certain branches are not used in forming the boundary of a surface, they are excluded from the
output lists.
Parameters
----------
branches : list
Each element of the list gives N>=2 points on the branch, ordered from one end point to the
other. If N=2, the two end points are meant. The points are provided as an Nx2 ndarray,
the first column giving the x, the second column giving the y coordinates of the points.
orientation : {'cw', 'ccw'}, optional
Clockwise ('cw') or counterclockwise ('ccw') orientation of the surface boundary.
The default is 'ccw'.
Returns
-------
order : list
Order of the branches so that they form the boundary of a surface.
redirected : list
A list of bool with True value if the orientation of the corresponding branch had to be
swapped to form the surface boundary.
polygon : ndarray
The polygon formed by connecting the points of the branches along the boundary. It is given
given as an Mx2 ndarray, where M is the number of unique points on the boundary (i.e. only
one end point is kept for two connecting branches).
This auxiliary data is not essential as it can be restored from the set of branches,
and their ordering. However, it is computed as temporary data needed for determining the
orientation of the boundary.
Examples
--------
>>> import numpy as np
>>> branches = [np.array([[1, 1], [1.5, 2], [2, 3]]), np.array([[1, 1], [-1, 2]]),
... np.array([[1.5, -3], [2, 3]]), np.array([[1.5, -3], [-1, 2]])]
>>> order, redirected, polygon = branches2boundary(branches, orientation='cw')
>>> order
[0, 2, 3, 1]
>>> redirected
[False, True, True, False]
>>> polygon
array([[ 1. , 1. ],
[ 1.5, 2. ],
[ 2. , 3. ],
[ 1.5, -3. ],
[-1. , 2. ]])
"""
# The path is the collection of consecutively added branches. When there are no more branches
# to add, the path becomes the boundary of a surface.
# First, we identify the branches that form the boundary.
n_branch = len(branches)
redirected = [False for i in range(n_branch)]
# Start with an arbitrary branch, the first one
last_branch = 0 # index of the lastly added branch to the path
order = [0]
first_vertex = branches[last_branch][0, :]
last_vertex = branches[last_branch][-1, :]
# Visit all vertices of the would-be surface and find the chain of connecting branches
for vertex in range(n_branch - 1):
if np.allclose(last_vertex, first_vertex): # one complete cycle is finished
break
# Search for branches connecting to the last vertex of the path
for branch_index, branch in enumerate(branches):
if branch_index == last_branch: # exclude the last branch of the path
continue
# Check if the first or second end point of the branch connects to the last vertex
first_endpoint = branch[0, :]
second_endpoint = branch[-1, :]
vertex_connects_to_first_endpoint = np.allclose(first_endpoint, last_vertex)
vertex_connects_to_second_endpoint = np.allclose(second_endpoint, last_vertex)
if vertex_connects_to_first_endpoint or vertex_connects_to_second_endpoint:
order.append(branch_index)
last_branch = branch_index
if vertex_connects_to_first_endpoint:
last_vertex = second_endpoint
break
elif vertex_connects_to_second_endpoint:
last_vertex = first_endpoint
redirected[branch_index] = True # change the orientation of the connecting branch
break
# TODO: add checks against edge cases
# Polygonize the boundary branches so as to determine the orientation in the next step
polygon = []
for branch_index in order:
branch = branches[branch_index].copy()
if redirected[branch_index]:
branch = np.flipud(branch)
polygon.append(branch[0:-1])
polygon = np.vstack(polygon)
# Handle the orientation of the polygon
if polygon_orientation(polygon) != orientation:
order.reverse()
redirected = toggle(redirected)
polygon = np.flipud(polygon)
return order, redirected, polygon
[docs]def region_as_polygon(branches, orientation='ccw', close='False'):
"""Represents a region as a polygon.
Based on the combined topological-geometrical (intermediate) representation of the regions,
(done by :func:`skeleton2regions`), this function provides a fully geometrical description
of a region.
Parameters
----------
branches : list
Each element of the list gives N>=2 points on the branch, ordered from one end point to the
other. If N=2, the two end points are meant. The points are provided as an Nx2 ndarray,
the first column giving the x, the second column giving the y coordinates of the points.
The branches are not necessarily ordered.
orientation : {'cw', 'ccw'}, optional
Clockwise ('cw') or counterclockwise ('ccw') orientation of the polygons.
The default is 'ccw'.
close : bool, optional
When True, one vertex in the polygons is repeated to indicate that the polygons are
indeed closed. The default is False.
Returns
-------
polygon : ndarray
The resulting polygon, given as an Mx2 ndarray, where M is the number of unique points on
the polygon (i.e. only one end point is kept for two connecting branches).
See Also
--------
skeleton2regions
"""
_, _, polygon = branches2boundary(branches, orientation)
if close:
polygon = np.vstack((polygon, polygon[0, :]))
return polygon
[docs]def polygonize(label_image, neighbor_search_algorithm, connectivity=1, detect_boundaries=True,
orientation='ccw', close=False):
"""Polygon representation of a label image.
Parameters
----------
label_image : ndarray
Labeled input image, represented as a 2D numpy array of positive integers.
neighbor_search_algorithm : functools.partial
Specifies which algorithm to use for constructing the branch-region connectivity.
The function to be passed (along with its arguments) is :func:`skeleton2regions`.
connectivity : {1,2}, optional
A connectivity of 1 (default) means pixels sharing an edge will be considered neighbors.
A connectivity of 2 means pixels sharing a corner will be considered neighbors.
detect_boundaries : bool, optional
When True, the image boundaries will be treated as part of the skeleton. This allows
identifying boundary regions in the `skeleton2regions` function. The default is True.
orientation : {'cw', 'ccw'}, optional
Clockwise ('cw') or counterclockwise ('ccw') orientation of the polygons.
The default is 'ccw'.
close : bool, optional
When True, one vertex in the polygons is repeated to indicate that the polygons are
indeed closed. The default is False.
Returns
-------
polygons : dict
The keys in the dictionary correspond to the labels of the input image, while the values
are ndarray objects with two columns, the x and y coordinates of the polygons.
See Also
--------
build_skeleton
skeleton2regions
region_as_polygon
Examples
--------
>>> test_image = np.array([
... [1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3],
... [2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3],
... [2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]],
... dtype=np.int8)
>>> polygons = polygonize(test_image, search_neighbor(2, np.inf), connectivity=1)
"""
polygons = {}
# Build the skeleton network from the label image
S = build_skeleton(label_image, connectivity, detect_boundaries)
# Identify the regions from the skeleton
region_branches, branch_coordinates, _ = skeleton2regions(S, neighbor_search_algorithm)
# Represent each region as a polygon
for region, branches in region_branches.items():
if region == -1: # artificial outer region
continue
points_on_boundary = index_list(branch_coordinates, branches)
poly = region_as_polygon(points_on_boundary, orientation, close)
polygons[region] = poly
# overlay_skeleton_networkx(S.graph, S.coordinates, image=label_image)
return polygons
[docs]def branches2splines(branches, degree_min=3, degree_max=8, continuity='C2', tol=1e-3):
"""Lays splines on branches.
Parameters
----------
branches : list
Each element of the list gives N>=2 points on the branch, ordered from one end point to the
other. If N=2, the two end points are meant. The points are provided as an Nx2 ndarray,
the first column giving the x, the second column giving the y coordinates of the points.
Other Parameters
----------------
degree_min, degree_max, continuity, tol
See the :func:`fit_spline` function.
Returns
-------
list
Each member of the list is a `Geom_BSplineCurve` object, the B-spline approximation of
the corresponding input branch.
For details on the resulting spline, see the OpenCASCADE documentation:
https://www.opencascade.com/doc/occt-7.4.0/refman/html/class_geom___b_spline_curve.html
See Also
--------
fit_spline
"""
return [fit_spline(branch, degree_min, degree_max, continuity, tol) for branch in branches]
[docs]def fit_spline(points, degree_min=3, degree_max=8, continuity='C2', tol=1e-3):
"""Approximates a set of points on the plane with a B-spline.
Parameters
----------
points : ndarray
2D ndarray with N>=2 rows and 2 columns, representing the points on the plane, ordered
from one end point to the other. The first column gives the x, the second column gives the
the y coordinates of the points.
degree_min : int, optional
Minimum degree of the spline. The default is 3.
degree_max : int, optional
Maximum degree of the spline. The default is 8.
continuity : {'C0', 'G1', 'C1', 'G2', 'C2', 'C3', 'CN'}, optional
The continuity of the spline will be at least `continuity`. The default is 'C2'.
For their meanings, consult with
https://www.opencascade.com/doc/occt-7.4.0/refman/html/_geom_abs___shape_8hxx.html
tol : float, optional
The distance from the points to the spline will be lower than `tol`. The default is 1e-3.
Returns
-------
spline : Geom_BSplineCurve
For details on the resulting spline, see the OpenCASCADE documentation:
https://www.opencascade.com/doc/occt-7.4.0/refman/html/class_geom___b_spline_curve.html
Raises
------
ValueError
If the minimum degree of the B-spline to be constructed is greater than its maximum degree.
"""
if degree_min > degree_max:
raise ValueError('Minimum degree must not be lower than the maximum degree.')
# Create points from the input array that OCCT understands
n_point = np.size(points, 0)
pts = TColgp_Array1OfPnt(0, n_point-1)
for i in range(n_point):
pts.SetValue(i, gp_Pnt(float(points[i][0]), float(points[i][1]), 0))
# Construct the spline
continuity = _spline_continuity_enum(continuity)
spline = GeomAPI_PointsToBSpline(pts, degree_min, degree_max, continuity, tol).Curve()
return spline
[docs]def region_as_splinegon(boundary_splines):
"""Represents a region as a splinegon.
Based on the combined topological-geometrical (intermediate) representation of the regions,
(done by :func:`skeleton2regions`), this function provides a fully geometrical description
of a region.
Parameters
----------
boundary_splines : list
Each element of the list is a `Handle_Geom_BSplineCurve` object, giving a reference to the
B-splines bounding the region. The splines must either be ordered (see
:func:`branches2boundary`) or they must appear in an order such that the n-th spline in
the list can be connected to one of the first n-1 splines in the list.
Returns
-------
splinegon : TopoDS_Face
The resulting splinegon (surface). For details on the object, see the OpenCASCADE API:
https://www.opencascade.com/doc/occt-7.4.0/refman/html/class_topo_d_s___face.html
boundary : TopoDS_Wire
The boundary of the splinegon. For details on the resulting object, see the OpenCASCADE API:
https://www.opencascade.com/doc/occt-7.4.0/refman/html/class_topo_d_s___wire.html
See Also
--------
skeleton2regions
fit_spline
Notes
-----
The syntax `Handle_classname` in PythonOCC corresponds to wrapping the object of class
`classname` with a smart pointer. In the C++ interface, it is done by a template:
`Handle<classname>`.
"""
# Combine the splines so that they form the boundary (a collection of joining splines)
boundary = BRepBuilderAPI_MakeWire()
for spline in boundary_splines:
edge = BRepBuilderAPI_MakeEdge(spline).Edge()
boundary.Add(edge)
_wire_error(boundary.Error())
# The planar surface (face) is stretched by the wire
splinegon = BRepBuilderAPI_MakeFace(boundary.Wire())
_face_error(splinegon.Error())
return splinegon.Face(), boundary.Wire()
[docs]def make_holes(splinegons):
"""Creates holes for splinegons that contain other splinegons.
When a smaller splinegon is embedded to a larger one, the boundary of the larger splinegon
does not describe it properly. The smaller splinegons have to be subtracted to reproduce the
non-simply connected domain. As an analogy to material science, the intended application of
this module, we will use the term `matrix` for the larger grain and `inclusion` for the
smaller one. This function handles multiple inclusion in a matrix and should be called after
the splinegon regions have been constructed. No need to directly call :func:`make_holes` if
you use the :func:`splinegonize` utility function.
.. todo:: Implement it more generally, see the related `issue
<https://github.com/CsatiZoltan/CristalX/issues/37>`_.
Parameters
----------
splinegons : dict
The keys in the dictionary correspond to the labels of the input image, while the values
are `TopoDS_Face` objects, the surfaces of the regions.
Returns
-------
splinegons : dict
The input dictionary modified such that the inclusions are removed from the matrix.
"""
# Find and successively subtract the embedded grains from the outer grains
for i, Ai in splinegons.items():
for j, Aj in splinegons.items():
if i != j and np.isclose(face_area(subtract(Aj, Ai)), 0): # Aj is a proper subset of Ai
splinegons[i] = subtract(splinegons[i], Aj)
return splinegons
[docs]def splinegonize(label_image, neighbor_search_algorithm, connectivity=1, detect_boundaries=True,
degree_min=3, degree_max=8, continuity='C2', tol=1e-4):
"""Polygon representation of a label image.
Parameters
----------
label_image : ndarray
Labeled input image, represented as a 2D numpy array of positive integers.
neighbor_search_algorithm : functools.partial
Specifies which algorithm to use for constructing the branch-region connectivity.
The function to be passed (along with its arguments) is :func:`skeleton2regions`.
connectivity : {1,2}, optional
A connectivity of 1 (default) means pixels sharing an edge will be considered neighbors.
A connectivity of 2 means pixels sharing a corner will be considered neighbors.
detect_boundaries : bool, optional
When True, the image boundaries will be treated as part of the skeleton. This allows
identifying boundary regions in the `skeleton2regions` function. The default is True.
Other Parameters
----------------
degree_min, degree_max, continuity, tol
See the :func:`fit_spline` function.
Returns
-------
splinegons : dict
The keys in the dictionary correspond to the labels of the input image, while the values
are `TopoDS_Face` objects, the surfaces of the regions.
boundaries : dict
The keys in the dictionary correspond to the labels of the input image, while the values
are `TopoDS_Wire` objects, the boundaries of the regions.
See Also
--------
build_skeleton
skeleton2regions
region_as_splinegon
Examples
--------
>>> test_image = np.array([
... [1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3],
... [2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3],
... [2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3],
... [2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]],
... dtype=np.int8)
>>> splinegons, _ = splinegonize(test_image, search_neighbor(2, np.inf), connectivity=1, tol=0.1)
>>> plot_splinegons(list(splinegons.values()))
"""
# Build the skeleton network from the label image
S = build_skeleton(label_image, connectivity, detect_boundaries)
# Identify the regions from the skeleton
region_branches, branch_coordinates, branch_regions = skeleton2regions(S, neighbor_search_algorithm)
# Approximate each branch with a B-spline
splines = branches2splines(branch_coordinates, degree_min, degree_max, continuity, tol)
# Represent each region as a splinegon
splinegons = {}
boundaries = {}
for region, branches in region_branches.items():
if region == -1: # artificial outer region
continue
# Splines that form the boundary of the current region
boundary_splines = index_list(splines, branches)
# Orient the boundary so that the region surface can be defined
order, _, _, = branches2boundary(index_list(branch_coordinates, branches))
# Construct the surface and the boundary of the region
splinegon, boundary = region_as_splinegon(index_list(boundary_splines, order))
# Save the created regions and their boundaries
splinegons[region] = splinegon
boundaries[region] = boundary
# Handle the embedded grains
splinegons = make_holes(splinegons)
return splinegons, boundaries
[docs]def regions2step(splinegons, filename, application_protocol='AP203'):
"""Writes splinegon regions to a STEP file.
Parameters
----------
splinegons : list
Each member of the list is a `TopoDS_Face` object, the surface of a region.
filename : str
Name of the file the regions are saved into.
application_protocol : {'AP203', 'AP214IS', 'AP242DIS'}, optional
Version of schema used for the output STEP file. The default is 'AP203'.
Returns
-------
None
See Also
--------
splinegonize
write_step_file
"""
# Initialize a container that we will populate with the splinegons
builder = BRep_Builder()
compound = TopoDS_Compound()
builder.MakeCompound(compound)
# Combine multiple faces
for splinegon in splinegons:
builder.Add(compound, splinegon)
# Write to STEP file
write_step_file(compound, filename, application_protocol)
[docs]def plot_polygons(polygons, **kwargs):
"""Plots polygons.
Parameters
----------
polygons : list
Each member of the list is a 2D numpy array with 2 columns, each row corresponding to a
vertex of the polygon, and the two columns giving the Cartesian coordinates of the vertices.
Other Parameters
----------------
kwargs : optional
Keyword arguments supported by the constructor of the :class:`matplotlib.figure.Figure`
class.
Returns
-------
fig : matplotlib.figure.Figure
Figure the polygons are plotted into.
See Also
--------
polygonize
"""
fig, ax = plt.subplots()
ax.set_aspect('equal')
for poly in polygons:
plot_polygon(poly, ax)
return fig
[docs]def plot_splinegons(splinegons, transparency=0.5, color='random'):
"""Plots splinegons.
Parameters
----------
splinegons : list
Each member of the list is a `TopoDS_Face` object, the surface of a splinegon.
transparency : float, optional
The transparency value should be between 0 and 1. At 0, an object will be totally opaque,
and at 1, fully transparent.
color : tuple or str, optional
Color of the splinegons. Either 'random' to associate a random color for each splinegon
in the list `splinegons` or a 3-tuple with entries between 0 and 1 to give the same RGB
values to all the splinegons.
Returns
-------
None
See Also
--------
splinegonize
"""
display, start_display, add_menu, add_function_to_menu = init_display()
# display.default_drawer.SetFaceBoundaryDraw(False)
# Check the format of the optional arguments
if not (0 <= transparency <= 1):
raise ValueError('Transparency must be in the interval [0, 1].')
if color != 'random':
# TODO: Check for format
col = rgb_color(*color)
for i, splinegon in enumerate(splinegons):
# Plot the faces
if color == 'random':
col = rgb_color(np.random.random(), np.random.random(), np.random.random())
display.DisplayShape(splinegon, update=True, transparency=transparency, color=col)
start_display()
[docs]def write_step_file(shape, filename, application_protocol='AP203'):
""" Exports a shape to a STEP file.
Parameters
----------
shape : TopoDS_Shape
Shape to be exported, an object of the `TopoDS_Shape` class or one of its subclasses. See
https://www.opencascade.com/doc/occt-7.4.0/refman/html/class_topo_d_s___shape.html
filename : str
Name of the file the shape is saved into.
application_protocol : {'AP203', 'AP214IS', 'AP242DIS'}, optional
Version of schema used for the output STEP file. The default is 'AP203'.
Notes
-----
For details on how to read and write STEP files, see the documentation on
https://dev.opencascade.org/doc/overview/html/occt_user_guides__step.html.
"""
if shape.IsNull():
raise AssertionError('Shape is null.')
if application_protocol not in ['AP203', 'AP214IS', 'AP242DIS']:
raise ValueError('Application protocol must be one of {"AP203", "AP214IS", "AP242DIS"}.')
if os.path.isfile(filename):
warnings.warn('File already exists and will be replaced.', RuntimeWarning)
# Create and initialize the STEP exporter
step_writer = STEPControl_Writer()
Interface_Static_SetCVal("write.step.schema", application_protocol)
# Transfer shape and write file
step_writer.Transfer(shape, STEPControl_AsIs)
status = step_writer.Write(filename)
if status != IFSelect_RetDone:
raise IOError('Error while writing shape to STEP file.')
if not os.path.isfile(filename):
raise IOError('File was not saved.')
[docs]def plot_polygon(vertices, ax=None, **kwargs):
"""Plots a polygon.
Parameters
----------
vertices : ndarray
2D ndarray of size Nx2, with each row designating a vertex and the two columns
giving the x and y coordinates of the vertices, respectively.
ax : matplotlib.axes.Axes, optional
The `Axes` instance the polygon is plotted into. The default is None, in which case a new
`Axes` within a new figure is created.
**kwargs : Line2D properties, optional
Keyword arguments accepted by matplotlib.pyplot.plot
Returns
-------
None
See Also
--------
:func:`matplotlib.pyplot.plot`
Examples
--------
>>> plot_polygon(np.array([[1, 1], [2, 3], [1.5, -3], [-1, 2]]), marker='o'); plt.show()
"""
# Close the polygon (repeat one vertex) if not yet closed
first_vertex = vertices[0, :]
last_vertex = vertices[-1, :]
closed = np.allclose(first_vertex, last_vertex)
if not closed:
vertices = np.vstack((vertices, first_vertex))
ax = ax or plt.figure().add_subplot()
ax.plot(vertices[:, 0], vertices[:, 1], **kwargs)
[docs]def overlay_regions(label_image, polygons, axes=None):
"""Plots a label image, and overlays polygonal regions over it.
Parameters
----------
label_image : ndarray
Labeled input image, represented as a 2D numpy array of positive integers.
polygons : dict
The keys in the dictionary correspond to the labels of the input image, while the values
are ndarray objects with two columns, the x and y coordinates of the polygons.
This format is respected by the output of the `polygonize` function.
axes : matplotlib.axes.Axes, optional
An Axes object on which to draw. If None, a new one is created.
Returns
-------
axes : matplotlib.axes.Axes
The Axes object on which the plot is drawn.
See Also
--------
polygonize
:class:`matplotlib.collections.LineCollection`
"""
# TODO: support an option to give the number of identified regions as a title
if axes is None:
_, axes = plt.subplots()
# Extract the polygons from the dictionary and convert them to a list to ease the plotting.
# At the same time, swap the x and y coordinates so that the polygons are expressed in the
# same coordinate system as the label image.
polygons = [polygons[i][:, ::-1] for i in polygons.keys()]
# Plot the polygons efficiently
axes.add_collection(collections.LineCollection(polygons, colors='black'))
# Plot the label image, with each color corresponding to a different region
random_colors = np.random.random((len(polygons), 3))
plt.imshow(label2rgb(label_image, colors=random_colors))
# Axis settings
axes.set_aspect('equal')
axes.set_axis_off()
plt.tight_layout()
return axes
[docs]def search_neighbor(radius, norm, method='sphere'):
"""Neighbor search algorithm.
Parameters
----------
radius, norm, method
See the :func:`grains.utils.neighborhood` function.
Returns
-------
functools.partial
A new function with partial application of the given input arguments on the
:func:`grains.utils.neighborhood` function. The returned partial function is used
internally by :func:`skeleton2regions` when performing neighbor searching.
See Also
--------
:func:`grains.utils.neighborhood`, :func:`skeleton2regions`
"""
return partial(neighborhood, radius=radius, norm=norm, method=method)
[docs]def face_area(face):
"""Area of a face.
Parameters
----------
face : TopoDS_Face
The face for which the area is searched.
Returns
-------
float
Area of the face.
Notes
-----
For references, see
- https://dev.opencascade.org/doc/refman/html/class_b_rep_g_prop.html#abd91b892df8d0f6b8571deed5562ca1f
- https://techoverflow.net/2019/06/13/how-to-compute-surface-area-of-topods_face-in-opencascade/
- https://github.com/tpaviot/pythonocc-demos/blob/master/examples/core_shape_properties.py#L48
"""
properties = GProp_GProps()
brepgprop_SurfaceProperties(face, properties)
return properties.Mass()
[docs]def subtract(shape1, shape2):
"""Boolean difference of two shapes.
Parameters
----------
shape1, shape2 : TopoDS_Shape
Surfaces.
Returns
-------
difference : TopoDS_Shape
The set difference :code:`shape1 \ shape2`.
Notes
-----
The implementation follows the following sources:
- https://techoverflow.net/2019/06/14/how-to-fuse-topods_shapes-in-opencascade-boolean-and/
- https://github.com/tpaviot/pythonocc-demos/blob/master/examples/core_boolean_fuzzy_cut_emmenthaler.py#L41-L54
For Boolean operations in Open CASCADE, see its `documentation
<https://dev.opencascade.org/doc/overview/html/specification__boolean_operations.html>`_.
"""
arguments = TopTools_ListOfShape()
arguments.Append(shape1)
tools = TopTools_ListOfShape()
tools.Append(shape2)
difference = BRepAlgoAPI_Cut()
difference.SetTools(tools)
difference.SetArguments(arguments)
difference.Build()
return difference.Shape()
[docs]def _spline_continuity_enum(continuity):
"""Enumeration value corresponding to the continuity of a B-spline.
Parameters
----------
continuity : {'C0', 'G1', 'C1', 'G2', 'C2', 'C3', 'CN'}
Continuity of the B-spline.
Returns
-------
enum : int
Integer value corresponding to continuity key in the OpenCASCADE API:
https://www.opencascade.com/doc/occt-7.4.0/refman/html/_geom_abs___shape_8hxx.html
"""
if continuity == 'C0':
enum = 0
elif continuity == 'G1':
enum = 1
elif continuity == 'C1':
enum = 2
elif continuity == 'G2':
enum = 3
elif continuity == 'C2':
enum = 4
elif continuity == 'C3':
enum = 5
elif continuity == 'CN':
enum = 6
else:
raise ValueError('Choose one of {"C0", "G1", "C1", "G2", "C2", "C3", "CN"}.')
return enum
[docs]def _wire_error(error_code):
"""Indicates the outcome of the wire construction.
Parameters
----------
error_code : int
Error code returned by the `Error` method of `BRepBuilderAPI_MakeWire`.
Returns
-------
None
Raises
------
ValueError
If the error code is 1, 2 or 3. See the OpenCASCADE API:
https://www.opencascade.com/doc/occt-7.4.0/refman/html/_b_rep_builder_a_p_i___wire_error_8hxx.html
The exception is raised within this function, as an incompatible wire makes it
impossible to construct a valid surface.
"""
if error_code == 0:
pass # No error occurred. The wire is correctly built.
elif error_code == 1:
raise ValueError('Empty constructor was used. The wire does not yet exist.')
elif error_code == 2:
raise ValueError('Disconnected wire. The last edge you attempted to add was not '
'connected to the wire.')
elif error_code == 3:
raise ValueError('Non-manifold wire. The wire has singularity.')
else:
raise ValueError('Error code not recognized. It must be one of the following: 0, 1, 2, 3.')
[docs]def _face_error(error_code):
"""Indicates the outcome of the face construction.
Parameters
----------
error_code : int
Error code returned by the `Error` method of `BRepBuilderAPI_MakeFace`.
Returns
-------
None
Raises
------
ValueError
If the error code is 1, 2, 3 or 4. See the OpenCASCADE API:
https://www.opencascade.com/doc/occt-7.4.0/refman/html/_b_rep_builder_a_p_i___face_error_8hxx.html
The exception is raised within this function if the face could not be created.
"""
if error_code == 0:
pass # No error occurred. The face is correctly built.
elif error_code == 1:
raise ValueError('Empty constructor was used. The face does not yet exist.')
elif error_code == 2:
raise ValueError('The face cannot be constructed from a non-planar wire.')
elif error_code == 3:
raise ValueError('Curve projection failed.')
elif error_code == 4:
raise ValueError('The parameters given to limit the surface are out of its bounds.')
else:
raise ValueError('Error code not recognized. It must be one of the following: 0, 1, 2, 3, 4.')
if __name__ == "__main__":
import doctest
doctest.testmod(verbose=True)