from math import atan2, degrees
import numpy as np
from numpy.linalg import norm
from openalea.plantgl.all import (
centroid_of_group,
direction,
dot,
estimate_radii_from_points,
pointset_orientation,
)
from .mtgmanip import gaussian_filter, mtg2pgltree, pipemodel
from .processpoints import (
filter_points,
load_points,
skeleton,
subsample,
)
[docs]
def characterize_mtg(g):
"""
Function to characterize a MTG object.
Parameters
~~~~~~~~~~
g : openalea.mtg.MTG
MTG object to be characterized
Returns
~~~~~~~
Dictionary of results
- trunk_length : length of the trunk
- trunk_branching_zone_start : start of the trunk branching zone
- trunk_branching_zone_end : end of the trunk branching zone
- trunk_branching_zone_length : length of the trunk branching zone
- trunk_base_radius : base radius of the trunk
- trunk_top_radius : top radius of the trunk
- nb_short_axis : number of short lateral axis
- nb_long_axis : number of long lateral axis
"""
shortaxis, longaxis = trunk_lateral_axes(g)
trunk_dir = trunk_direction(g, 0.5)
trunkbaseradius, trunktopradius = trunk_radii(g, 0.1, 0.1)
res = dict(
trunk_length=trunk_length(g),
trunk_branching_zone_start=trunk_branching_zone_start(g),
trunk_branching_zone_end=trunk_branching_zone_end(g),
trunk_branching_zone_length=trunk_branching_zone_length(g),
trunk_base_radius=trunkbaseradius,
trunk_top_radius=trunktopradius,
nb_short_axis=len(shortaxis),
nb_long_axis=len(longaxis),
)
bot_angle_param = 0, 0.15
top_angle_param = 0.85, 1
res.update(
[
("axis1_length_" + str(i), axis_length(g, longax))
for i, longax in enumerate(longaxis)
]
)
res.update(
[
("axis1_chord_length_" + str(i), axis_chord_length(g, longax))
for i, longax in enumerate(longaxis)
]
)
res.update(
[
(
"axis1_angle_bot_" + str(i),
axis_subpart_angle(
g, longax, bot_angle_param[0], bot_angle_param[1], trunk_dir
),
)
for i, longax in enumerate(longaxis)
]
)
res.update(
[
(
"axis1_angle_top_" + str(i),
axis_subpart_angle(
g, longax, top_angle_param[0], top_angle_param[1], trunk_dir
),
)
for i, longax in enumerate(longaxis)
]
)
res.update(
[
(
"axis1_extremities_angle_" + str(i),
axis_extremities_angle(g, longax, trunk_dir),
)
for i, longax in enumerate(longaxis)
]
)
for i, longax in enumerate(longaxis):
rfirst, rmedian, rlast = retrieve_axis_radii(g, longax)
res["axis1_rfirst_" + str(i)] = rfirst
res["axis1_rlast_" + str(i)] = rlast
res["axis1_rmedian_" + str(i)] = rmedian
return res
[docs]
def generate_mtg(
filename,
do_filter_points=True,
do_gaussian_filter=True,
do_determine_radius=True,
do_pipemodel=True,
**kwargs,
):
"""
Summary function that generates a MTG from a point cloud.
Parameters
~~~~~~~~~~
filename : str
Filename of the point cloud.
do_filter_points: bool
If True, points are filtered to keep only points with a density above a threshold.
Default to True.
do_gaussian_filter: bool
If True, a gaussian filter is applied to the position property of the mtg.
Default to True.
do_determine_radius: bool
If True, the radius of each node is estimated.
Default to True.
do_pipemodel: bool
If True, the pipe model is applied to correct the radius of each node.
Default to True.
kwargs:
additional arguments to pass to the inner functions
Returns
~~~~~~~
openalea.mtg.MTG
mtg object representing the point cloud with a skeleton and radius estimation for each node.
Notes
~~~~~
`generate_mtg` is a wrapper around the following functions (in that order):
- :func:`load_points`
- :func:`subsample`
- :func:`filter_points`
- :func:`skeleton`
- :func:`determine_radius`
- :func:`pipemodel`
Arguments passed to the inner functions can be passed as keyword arguments to `generate_mtg`.
Example
~~~~~~~
>>> mtg = generate_mtg("path/to/pointcloud.pts")
>>> mtg = generate_mtg("path/to/pointcloud.pts",
gaussian_filter=False,
subsample={"ptsnb": 1000},
filter_points={"densityfilterratio": 0.05, "densityradius": 0.1, "k": 20},
skeleton={"skel_func": adaptivespacecolonization_method, "asc_max_growth_lengt": 0.01},
determine_radius={"radiusproperty": "Radii", "maxmethod": False}
)
"""
points = load_points(filename)
points = subsample(points, **kwargs.get("subsample", {}))
if do_filter_points:
points = filter_points(points, **kwargs.get("filter_points", {}))
mtg = skeleton(points, **kwargs.get("skeleton", {}))
if do_gaussian_filter:
gaussian_filter(mtg, "position")
if do_determine_radius:
determine_radius(mtg, points, **kwargs.get("determine_radius", {}))
if do_pipemodel:
pipemodel_radii = pipemodel(mtg, **kwargs.get("pipemodel", {}))
mtg.property("radius").update(pipemodel_radii)
return mtg
[docs]
def determine_radius(g, points, maxmethod=True, radiusproperty="radius"):
"""
Function to determine the radius of each node of a MTG object.
Parameters
~~~~~~~~~~
g: openalea.mtg.MTG
mtg object. Usually, the skeleton of the point cloud.
points: PointSet
PointSet of the point cloud.
maxmethod: bool
Wether to use the max (True) or the mean (False) method to estimate the radius.
Default is True.
radiusproperty: str
Name of the property in the mtg to store the radius.
Default is "radius".
"""
nodes, parents, vertex2node = mtg2pgltree(g)
estimatedradii = estimate_radii_from_points(
points, nodes, parents, maxmethod=maxmethod
)
radii = g.property(radiusproperty)
for vid, nid in vertex2node.items():
radii[vid] = estimatedradii[nid]
[docs]
class Line:
"""Internal class to represent a line with 'position', 'direction' and 'extend' attributes."""
def __init__(self, position, direction, extend):
self.position = position
self.direction = direction
self.extend = extend
def __repr__(self):
return (
"Line("
+ str(self.position)
+ ","
+ str(self.direction)
+ ","
+ str(self.extend)
+ ")"
)
[docs]
@staticmethod
def estimate(positions):
"""Method to estimate a line from a list of points."""
idx = range(len(positions))
pos = centroid_of_group(positions, idx)
dir = direction(pointset_orientation(positions, idx))
if dot(pos - positions[0], dir) < 0:
dir *= -1
extend = max([abs(dot(p - pos, dir)) for p in positions])
return Line(pos, dir, extend)
[docs]
def nb_lateral_children(g, vid):
return sum(lateral_children(g, vid))
[docs]
def trunk_branching_zone_start(g):
"""
Detemine the start of the trunk branching zone.
Parameters
~~~~~~~~~~
g: openalea.mtg.MTG
MTG object reconstructed.
Returns
~~~~~~~
The length of the trunk from bottom to the start of the branching zone.
"""
trunknodes = trunk_nodes(g)
firstidpos = None
for i, vid in enumerate(trunknodes):
if nb_lateral_children(g, vid) > 0:
firstidpos = i
break
if firstidpos is None:
return None
trunknodes = trunknodes[: firstidpos + 1]
return sum([node_length(g, n) for n in trunknodes])
[docs]
def trunk_branching_zone_end(g):
"""
Determine the end of the trunk branching zone.
Parameters
~~~~~~~~~~
g: openalea.mtg.MTG
MTG object reconstructed.
Returns
~~~~~~~
The length of the trunk from bottom to the end of the branching zone.
"""
trunknodes = trunk_nodes(g)
lastidpos = None
for i, vid in enumerate(reversed(trunknodes)):
if nb_lateral_children(g, vid) > 0:
lastidpos = i
break
if lastidpos is None:
return None
trunknodes = trunknodes[:-lastidpos]
return sum([node_length(g, n) for n in trunknodes])
[docs]
def trunk_branching_zone_length(g):
"""
Determine the length of the trunk's branching zone.
Parameters
~~~~~~~~~~
g: openalea.mtg.MTG
MTG object reconstructed.
Returns
~~~~~~~
The length of the trunk's branching zone.
"""
trunknodes = trunk_nodes(g)
firstidpos = None
for i, vid in enumerate(trunknodes):
if nb_lateral_children(g, vid) > 0:
firstidpos = i
break
if firstidpos is None:
return None
trunknodes = trunknodes[firstidpos + 1 :]
lastidpos = None
for i, vid in enumerate(reversed(trunknodes)):
if nb_lateral_children(g, vid) > 0:
lastidpos = i
break
if lastidpos is None:
return 0
trunknodes = trunknodes[:-lastidpos]
return sum([node_length(g, n) for n in trunknodes])
[docs]
def trunk_length(g):
"""
Determine the length of the trunk.
Parameters
~~~~~~~~~~
g: openalea.mtg.MTG
MTG object reconstructed.
Returns
~~~~~~~
The length of the trunk.
"""
return axis_length(g, trunk_root(g))
[docs]
def axis_length(g, n):
"""
Determine the length of the axis.
Parameters
~~~~~~~~~~
g: openalea.mtg.MTG
MTG object reconstructed.
n: int
Node id.
Returns
~~~~~~~
The length of the axis.
"""
return sum(map(lambda vid: node_length(g, vid), g.Axis(n)))
[docs]
def node_length(g, n):
"""
Determine the length of the node.
Parameters
~~~~~~~~~~
g: openalea.mtg.MTG
MTG object reconstructed.
n: int
Node id.
Returns
~~~~~~~
The length between a node and his parent.
"""
parent = g.parent(n)
if parent:
p1 = node_position(g, n)
p0 = node_position(g, parent)
return norm(p1 - p0)
else:
return 0
[docs]
def axis_chord_length(g, n):
"""
Determine the chord length of the axis.
Parameters
~~~~~~~~~~
g: openalea.mtg.MTG
MTG object reconstructed.
n: int
Node id.
Returns
~~~~~~~
The chord length of an axis.
"""
from numpy.linalg import norm
p0, p1 = axis_extremities(g, n)
return norm(p1 - p0)
[docs]
def retrieve_axis_radii(g, vid):
"""
Determine the radius of the axis:
radius of the first node, radius of the last node and mean value of the radius of the axis.
Parameters
~~~~~~~~~~
g: openalea.mtg.MTG
MTG object reconstructed.
n: int
Node id.
Returns
~~~~~~~
Tuple of the radius of the first node, mean value of the radius of the axis and radius of the last node.
"""
radii = g.property("radius")
axis = g.Axis(vid)
axis = [vid for vid in axis if vid in radii]
nbnodes = len(axis)
if nbnodes == 0:
return None, None, None
elif nbnodes == 1:
r = radii[axis[0]]
return r, r, r
elif nbnodes == 2:
r0 = radii[axis[0]]
r1 = radii[axis[1]]
return r0, (r0 + r1) / 2, r1
elif nbnodes == 3:
r0 = radii[axis[0]]
r1 = radii[axis[1]]
r2 = radii[axis[2]]
return r0, r2, r1
else:
lradii = list(map(lambda v: radii[v], axis))
lradii.sort()
meanradius = lradii[int(len(lradii) / 2)]
return (radii[axis[0]], meanradius, radii[axis[-2]])
[docs]
def trunk_radii(g, begratio=0.1, endratio=0.1):
"""
Determine the radius of the trunk at the base and the top.
Parameters
~~~~~~~~~~
g: openalea.mtg.MTG
MTG object reconstructed, on which to determine the trunk radius.
begratio: float
Ratio of the bottom of the trunk used to determine the bottom radius.
Default to 0.1.
endratio: float
Ratio of the top of the trunk used to determine the top radius.
Default to 0.1.
Returns
~~~~~~~
Tuple of the bottom and top radius of the trunk.
"""
begtrunk = axis_subpart(g, trunk_root(g), 0, begratio)
endtrunk = axis_subpart(g, trunk_root(g), 1 - endratio, 1)
radii = g.property("radius")
return max([radii[n] for n in begtrunk]), max([radii[n] for n in endtrunk])
[docs]
def node_position(g, n):
"""
Retuns the position of a node in a MTG.
Parameters
~~~~~~~~~~
g: openalea.mtg.MTG
MTG object reconstructed, on which to determine the trunk radius.
n: int
Index of the node.
Returns
~~~~~~~
Tupole of the position of the node (x,y,z).
"""
if "position" in g.property_names():
return g.property("position")[n]
return np.array([g.property("XX")[n], g.property("YY")[n], g.property("ZZ")[n]])
[docs]
def axis_extremities(g, n):
"""
Determine the extremities of an axis.
"""
axis = g.Axis(n)
p1 = node_position(g, axis[-1])
a0 = axis[0]
if g.parent(a0):
a0 = g.parent(a0)
p0 = node_position(g, a0)
return p0, p1
[docs]
def axis_extremities_angle(g, n, refdir=(0, 0, 1)):
"""
Determine the angle between a reference direction and the extremities of an axis.
"""
p0, p1 = axis_extremities(g, n)
return angle(p1 - p0, refdir)
[docs]
def angle(direction, refdir=(0, 0, 1)):
"""
Determine the angle between a direction and a reference direction (in degrees).
"""
cosinus = np.dot(direction, refdir)
vy = np.cross(direction, refdir)
sinus = norm(vy)
return degrees(atan2(sinus, cosinus))
[docs]
def axis_subpart(g, axisroot, beglengthratio, endlengthratio):
"""
Extract a subpart of an axis.
Parameters
~~~~~~~~~~
g: openalea.mtg.MTG
MTG object reconstructed, on which to determine the trunk radius.
axisroot: int
Node id to determine the axis.
beglengthratio: float
Ratio of the length of the subpart to extract to the beginning of the axis.
endlengthratio: float
Ratio of the length of the subpart to extract to the end of the axis.
Returns
~~~~~~~
List of node ids of the subpart of the axis.
"""
data = axis_nodes_normedposition(g, axisroot)
beg = 0
end = len(data) - 1
while data[beg][1] < beglengthratio:
if beg + 1 == end or data[beg + 1][1] >= endlengthratio:
break
else:
beg += 1
while data[end][1] > endlengthratio:
if end - 1 == beg or data[end - 1][1] <= beglengthratio:
break
else:
end -= 1
return [node for node, _ in data[beg : end + 1]]
[docs]
def axis_subpart_angle(g, axisroot, beglengthratio, endlengthratio, refdir=(0, 0, 1)):
"""
Determine the angle between a subpart of an axis and a reference direction.
Parameters
~~~~~~~~~~
g: openalea.mtg.MTG
MTG object reconstructed, on which to determine the trunk radius.
axisroot: int
Node id to determine the axis.
beglengthratio: float
Ratio of the length of the subpart to extract to the beginning of the axis.
endlengthratio: float
Ratio of the length of the subpart to extract to the end of the axis.
refdir: Vector3
Reference direction.
Returns
~~~~~~~
Angle (in degrees) between the subpart of the axis and the reference direction.
"""
nodes = axis_subpart(g, axisroot, beglengthratio, endlengthratio)
positions = [node_position(g, n) for n in nodes]
if len(positions) > 2:
return angle(Line.estimate(positions).direction, refdir)
return angle(positions[1] - positions[0], refdir)
[docs]
def axis_nodes_normedposition(g, axisroot):
"""
Compute the normalized position of each node along an axis.
Parameters
~~~~~~~~~~
g: openalea.mtg.MTG
MTG object.
axisroot: int
Root node of the axis to compute positions for.
Returns
~~~~~~~
list of tuple
List of (node_id, normalized_position) where normalized_position
is the cumulative length from the axis root divided by the total
axis length. The parent of the axis root is prepended with
position 0 if it exists.
"""
data = [(node, node_length(g, node)) for node in g.Axis(axisroot)]
length = 0
for i in range(len(data)):
length += data[i][1]
data[i] = (data[i][0], length)
res = [(node, len_n / length) for node, len_n in data]
if g.parent(axisroot) is not None:
res = [(g.parent(axisroot), 0)] + res
return res
[docs]
def trunk_direction(g, trunkratio=1):
"""
Determine the direction of the trunk.
Parameters
~~~~~~~~~~
g: openalea.mtg.MTG
MTG object reconstructed, on which to determine the trunk direction.
trunkratio: float
Ratio of the trunk length to the total length of the trunk.
Returns
~~~~~~~
A list of Vector3 objects representing the direction of the trunk.
"""
nodes = axis_subpart(g, trunk_root(g), 0, trunkratio)
positions = [node_position(g, v) for v in nodes]
line = Line.estimate(positions)
return line.direction
[docs]
def trunk_lateral_axes(g, length_threshold=0.05):
"""
Returns the trunk lateral axes. Two lists are returned, the first one
is the short axes, the second one is the long axes.
Parameters
~~~~~~~~~~
g : openalea.mtg.MTG
MTG object
length_threshold : float
minimum length of long lateral axis
Returns
~~~~~~~
shortaxis, longaxis : list
list of lateral axis nodes. Short axis if smaller than length_threshold,
long otherwise.
"""
trunknodes = trunk_nodes(g)
shortaxis, longaxis = [], []
for n in trunknodes:
for lateral in lateral_children(g, n):
if axis_length(g, lateral) >= length_threshold:
longaxis.append(lateral)
else:
shortaxis.append(lateral)
return shortaxis, longaxis
[docs]
def lateral_children(g, vid):
"""
Returns a list of the lateral children of a node.
"""
return [cid for cid in g.children(vid) if g.edge_type(cid) == "+"]
[docs]
def trunk_root(g):
"""
Returns the root node of the trunk.
"""
return g.roots(scale=g.max_scale())[0]
[docs]
def trunk_nodes(g):
"""
Returns the nodes of the trunk.
"""
return g.Axis(trunk_root(g))