Source code for nngt.geometry.shape

#!/usr/bin/env python
#-*- coding:utf-8 -*-
#
# This file is part of the PyNCulture project, which aims at providing tools to
# easily generate complex neuronal cultures.
# Copyright (C) 2017 SENeC Initiative
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

"""
Shape implementation using the
`shapely <http://toblerity.org/shapely/index.html>`_ library.
"""

import weakref
from copy import deepcopy
import logging

logger = logging.getLogger(__name__)

import shapely
from shapely.wkt import loads
from shapely.affinity import scale, translate
from shapely.geometry import Point, Polygon, MultiPolygon

import numpy as np
from numpy.random import uniform

from .geom_utils import conversion_magnitude
from .tools import indexable, pop_largest, _insert_area

# unit support

try:
    from .units import _unit_support
except ImportError:
    _unit_support = False

# opengl support

try:
    from .triangulate import triangulate, rnd_pts_in_tr
    _opengl_support = True
except ImportError:
    _opengl_support = False


__all__ = ["Area", "Shape"]


[docs]class Shape(Polygon): """ Class containing the shape of the area where neurons will be distributed to form a network. Attributes ---------- area : double Area of the shape in the :class:`Shape`'s :func:`Shape.unit` squared (:math:`\mu m^2`, :math:`mm^2`, :math:`cm^2`, :math:`dm^2` or :math:`m^2`). centroid : tuple of doubles Position of the center of mass of the current shape in `unit`. See also -------- Parent class: :class:`shapely.geometry.Polygon` """
[docs] @staticmethod def from_file(filename, min_x=None, max_x=None, unit='um', parent=None, interpolate_curve=50, default_properties=None): ''' Create a shape from a DXF, an SVG, or a WTK/WKB file. .. versionadded:: 0.3 Parameters ---------- filename : str Path to the file that should be loaded. min_x : float, optional (default: -5000.) Absolute horizontal position of the leftmost point in the environment in `unit` (default: 'um'). If None, no rescaling occurs. max_x : float, optional (default: 5000.) Absolute horizontal position of the rightmost point in the environment in `unit`. If None, no rescaling occurs. unit : string (default: 'um') Unit in the metric system among 'um' (:math:`\mu m`), 'mm', 'cm', 'dm', 'm'. parent : :class:`nngt.Graph` object The parent which will become a :class:`nngt.SpatialGraph`. interpolate_curve : int, optional (default: 50) Number of points that should be used to interpolate a curve. default_properties : dict, optional (default: None) Default properties of the environment. ''' from .shape_io import culture_from_file if _unit_support: from .units import Q_ if isinstance(min_x, Q_): min_x = min_x.m_as(unit) if isinstance(max_x, Q_): max_x = max_x.m_as(unit) return culture_from_file( filename, min_x=min_x, max_x=max_x, unit=unit, parent=parent, interpolate_curve=interpolate_curve, default_properties=default_properties)
[docs] @staticmethod def from_polygon(polygon, min_x=None, max_x=None, unit='um', parent=None, default_properties=None): ''' Create a shape from a :class:`shapely.geometry.Polygon`. Parameters ---------- polygon : :class:`shapely.geometry.Polygon` The initial polygon. min_x : float, optional (default: -5000.) Absolute horizontal position of the leftmost point in the environment in `unit` If None, no rescaling occurs. max_x : float, optional (default: 5000.) Absolute horizontal position of the rightmost point in the environment in `unit` If None, no rescaling occurs. unit : string (default: 'um') Unit in the metric system among 'um' (:math:`\mu m`), 'mm', 'cm', 'dm', 'm' parent : :class:`nngt.Graph` object The parent which will become a :class:`nngt.SpatialGraph`. default_properties : dict, optional (default: None) Default properties of the environment. ''' assert isinstance(polygon, Polygon), "`polygon` is not a Polygon " +\ "but a {}.".format(polygon.__class__) if _unit_support: from .units import Q_ if isinstance(min_x, Q_): min_x = min_x.m_as(unit) if isinstance(max_x, Q_): max_x = max_x.m_as(unit) obj = None g_type = None # find the scaling factor if None not in (min_x, max_x): ext = np.array(polygon.exterior.coords) leftmost = np.min(ext[:, 0]) rightmost = np.max(ext[:, 0]) scaling = (max_x - min_x) / (rightmost - leftmost) obj = scale(polygon, scaling, scaling) else: obj = Polygon(polygon) obj.__class__ = Shape obj._parent = None obj._unit = unit obj._geom_type = g_type obj._return_quantity = False obj._areas = { "default_area": Area.from_shape(obj, name="default_area", properties=default_properties) } return obj
[docs] @staticmethod def from_wkt(wtk, min_x=None, max_x=None, unit='um', parent=None, default_properties=None): ''' Create a shape from a WKT string. .. versionadded:: 0.2 Parameters ---------- wtk : str The WKT string. min_x : float, optional (default: -5000.) Absolute horizontal position of the leftmost point in the environment in `unit` If None, no rescaling occurs. max_x : float, optional (default: 5000.) Absolute horizontal position of the rightmost point in the environment in `unit` If None, no rescaling occurs. unit : string (default: 'um') Unit in the metric system among 'um' (:math:`\mu m`), 'mm', 'cm', 'dm', 'm' parent : :class:`nngt.Graph` object The parent which will become a :class:`nngt.SpatialGraph`. default_properties : dict, optional (default: None) Default properties of the environment. See also -------- :func:`Shape.from_polygon` for details about the other arguments. ''' if _unit_support: from .units import Q_ if isinstance(min_x, Q_): min_x = min_x.m_as(unit) if isinstance(max_x, Q_): max_x = max_x.m_as(unit) p = loads(wtk) return Shape.from_polygon( p, min_x=min_x, max_x=max_x, unit=unit, parent=parent, default_properties=default_properties)
[docs] @staticmethod def rectangle(height, width, centroid=(0., 0.), unit='um', parent=None, default_properties=None): ''' Generate a rectangle of given height, width and center of mass. Parameters ---------- height : float Height of the rectangle in `unit` width : float Width of the rectangle in `unit` centroid : tuple of floats, optional (default: (0., 0.)) Position of the rectangle's center of mass in `unit` unit : string (default: 'um') Unit in the metric system among 'um' (:math:`\mu m`), 'mm', 'cm', 'dm', 'm' parent : :class:`nngt.Graph` or subclass, optional (default: None) The parent container. default_properties : dict, optional (default: None) Default properties of the environment. Returns ------- shape : :class:`Shape` Rectangle shape. ''' if _unit_support: from .units import Q_ if isinstance(width, Q_): width = width.m_as(unit) if isinstance(height, Q_): height = height.m_as(unit) if isinstance(centroid, Q_): centroid = centroid.m_as(unit) elif isinstance(centroid, Point): centroid = list(centroid.coords)[0] elif isinstance(centroid[0], Q_): centroid = (centroid[0].m_as(unit), centroid[1].m_as(unit)) half_w = 0.5 * width half_h = 0.5 * height centroid = np.array(centroid) points = [centroid + [half_w, half_h], centroid + [half_w, -half_h], centroid - [half_w, half_h], centroid - [half_w, -half_h]] shape = Shape(points, unit=unit, parent=parent, default_properties=default_properties) shape._geom_type = "Rectangle" return shape
[docs] @staticmethod def disk(radius, centroid=(0.,0.), unit='um', parent=None, default_properties=None): ''' Generate a disk of given radius and center (`centroid`). Parameters ---------- radius : float Radius of the disk in `unit` centroid : tuple of floats, optional (default: (0., 0.)) Position of the rectangle's center of mass in `unit` unit : string (default: 'um') Unit in the metric system among 'um' (:math:`\mu m`), 'mm', 'cm', 'dm', 'm' parent : :class:`nngt.Graph` or subclass, optional (default: None) The parent container. default_properties : dict, optional (default: None) Default properties of the environment. Returns ------- shape : :class:`Shape` Rectangle shape. ''' if _unit_support: from .units import Q_ if isinstance(radius, Q_): radius = radius.m_as(unit) if isinstance(centroid, Q_): centroid = centroid.m_as(unit) elif isinstance(centroid, Point): centroid = list(centroid.coords)[0] elif isinstance(centroid[0], Q_): centroid = (centroid[0].m_as(unit), centroid[1].m_as(unit)) centroid = np.array(centroid) minx = centroid[0] - radius maxx = centroid[0] + radius disk = Shape.from_polygon( Point(centroid).buffer(radius), min_x=minx, max_x=maxx, unit=unit, parent=parent, default_properties=default_properties) disk._geom_type = "Disk" disk.radius = radius return disk
[docs] @staticmethod def ellipse(radii, centroid=(0.,0.), unit='um', parent=None, default_properties=None): ''' Generate a disk of given radius and center (`centroid`). Parameters ---------- radii : tuple of floats Couple (rx, ry) containing the radii of the two axes in `unit` centroid : tuple of floats, optional (default: (0., 0.)) Position of the rectangle's center of mass in `unit` unit : string (default: 'um') Unit in the metric system among 'um' (:math:`\mu m`), 'mm', 'cm', 'dm', 'm' parent : :class:`nngt.Graph` or subclass, optional (default: None) The parent container. default_properties : dict, optional (default: None) Default properties of the environment. Returns ------- shape : :class:`Shape` Rectangle shape. ''' if _unit_support: from .units import Q_ if isinstance(radii, Q_): radii = radii.m_as(unit) elif isinstance(radii[0], Q_): radii = (radii[0].m_as(unit), radii[1].m_as(unit)) if isinstance(centroid, Q_): centroid = centroid.m_as(unit) elif isinstance(centroid, Point): centroid = list(centroid.coords)[0] elif isinstance(centroid[0], Q_): centroid = (centroid[0].m_as(unit), centroid[1].m_as(unit)) centroid = np.array(centroid) rx, ry = radii minx = centroid[0] - rx maxx = centroid[0] + rx ellipse = Shape.from_polygon( scale(Point(centroid).buffer(1.), rx, ry), min_x=minx, max_x=maxx, unit=unit, parent=parent, default_properties=default_properties) ellipse._geom_type = "Ellipse" ellipse.radii = radii return ellipse
def __init__(self, shell, holes=None, unit='um', parent=None, default_properties=None): ''' Initialize the :class:`Shape` object and the underlying :class:`shapely.geometry.Polygon`. Parameters ---------- exterior : array-like object of shape (N, 2) List of points defining the external border of the shape. interiors : array-like, optional (default: None) List of array-like objects of shape (M, 2), defining empty regions inside the shape. unit : string (default: 'um') Unit in the metric system among 'um' (:math:`\mu m`), 'mm', 'cm', 'dm', 'm'. parent : :class:`nngt.Graph` or subclass The graph which is associated to this Shape. default_properties : dict, optional (default: None) Default properties of the environment. ''' if _unit_support: from .units import Q_ if isinstance(shell, Q_): shell = shell.m_as(unit) else: try: if isinstance(shell[0], Q_): shell = [q.m_as(unit) for q in shell] except: pass if holes is not None: for i, h in enumerate(holes): if isinstance(h, Q_): h = h.m_as(unit) else: try: if isinstance(h[0], Q_): holes[i] = [q.m_as(unit) for q in h] except: pass self._return_quantity = False self._parent = weakref.proxy(parent) if parent is not None else None self._unit = unit self._geom_type = 'Polygon' # create the default area tmp = Polygon(shell, holes=holes) self._areas = { "default_area": Area.from_shape( tmp, name="default_area", properties=default_properties, unit=unit) } super(Shape, self).__init__(shell, holes=holes)
[docs] def copy(self): ''' Create a copy of the current Shape. ''' copy = Shape.from_polygon(self) copy._return_quantity = self._return_quantity copy._parent = None copy._unit = self._unit copy._geom_type = self._geom_type if self._areas: copy._areas = { key: val.copy() for key, val in self._areas.items() } return copy
@property def parent(self): ''' Return the parent of the :class:`Shape`. ''' return self._parent @property def unit(self): ''' Return the unit for the :class:`Shape` coordinates. ''' return self._unit @property def areas(self): ''' Returns the dictionary containing the Shape's areas. ''' return deepcopy(self._areas) @property def default_areas(self): ''' Returns the dictionary containing only the default areas. .. versionadded:: 0.4 ''' areas = { k: deepcopy(v) for k,v in self._areas.items() if k.find("default_area") == 0 } return areas @property def non_default_areas(self): ''' Returns the dictionary containing all Shape's areas except the default ones. .. versionadded:: 0.4 ''' areas = { k: deepcopy(v) for k,v in self._areas.items() if k.find("default_area") != 0 } return areas @property def return_quantity(self): ''' Whether `seed_neurons` returns positions with units by default. .. versionadded:: 0.5 ''' return self._return_quantity
[docs] def add_area(self, area, height=None, name=None, properties=None, override=False): ''' Add a new area to the :class:`Shape`. If the new area has a part that is outside the main :class:`Shape`, it will be cut and only the intersection between the area and the container will be kept. Parameters ---------- area : :class:`Area` or :class:`Shape`, or :class:`shapely.Polygon`. Delimitation of the area. Only the intersection between the parent :class:`Shape` and this new area will be kept. name : str, optional, default ("areaX" where X is the number of areas) Name of the area, under which it can be retrieved using the :func:`Shape.area` property of the :class:`Shape` object. properties : dict, optional (default: None) Properties of the area. If `area` is a :class:`Area`, then this is not necessary. override : bool, optional (default: False) If True, the new area will be made over existing areas that will be reduced in consequence. ''' # check that area and self overlap assert self.overlaps(area) or self.contains(area), "`area` must be " +\ "contained or at least overlap with the current shape." # check units if _unit_support: from .units import Q_ if isinstance(height, Q_): height = height.m_as(self.unit) # check whether this area intersects with existing areas other than # the default area. intersection = self.intersection(area) if not override: for key, other_area in self._areas.items(): if key.find("default_area") == -1: assert not intersection.overlaps(other_area), \ "Different areas of a given Shape should not overlap." else: delete = [] for key, other_area in self.non_default_areas.items(): if other_area.overlaps(area) or other_area.contains(area): new_existing = other_area.difference(area) if new_existing.empty(): delete.append(key) else: _insert_area(self, key, new_existing, other_area.height, other_area.properties) for key in delete: del self._areas[key] # check properties if name is None: if isinstance(area, Area): name = area.name else: name = "area{}".format(len(self._areas)) if height is None: if isinstance(area, Area): height = area.height else: height = self.areas["default_area"].height if properties is None: if isinstance(area, Area): properties = area.properties else: properties = {} # update the default area default_area = self._areas["default_area"] new_default = default_area.difference(intersection) # check that we do not add an area containing default if not new_default.is_empty: _insert_area(self, "default_area", new_default, default_area.height, default_area.properties) # create the area _insert_area(self, name, intersection, height, properties)
[docs] def add_hole(self, hole): ''' Make a hole in the shape. .. versionadded:: 0.4 ''' areas = self.areas.copy() new_shape = Shape.from_polygon( self.difference(hole), unit=self.unit, parent=self.parent, default_properties=areas["default_area"].properties) self._geom = new_shape._geom new_shape._other_owned = True for name, area in areas.items(): if name.find("default_area") != 0: _insert_area(self, name, area.difference(hole), area.height, area.properties)
[docs] def random_obstacles(self, n, form, params=None, heights=None, properties=None, etching=0, on_area=None): ''' Place random obstacles inside the shape. .. versionadded:: 0.4 Parameters ---------- n : int or float Number of obstacles if `n` is an :obj:`int`, otherwise represents the fraction of the shape's bounding box that should be occupied by the obstacles' bounding boxes. form : str or Shape Form of the obstacles, among "disk", "ellipse", "rectangle", or a custom shape. params : dict, optional (default: None) Dictionnary containing the instructions to build a predefined form ("disk", "ellipse", "rectangle"). See their creation methods for details. Leave `None` when using a custom shape. heights : float or list, optional (default: None) Heights of the obstacles. If None, the obstacle will considered as a "hole" in the structure, i.e. an uncrossable obstacle. properties : dict or list, optional (default: None) Properties of the obstacles if they constitue areas (only used if `heights` is not None). If not provided and `heights` is not None, will default to the "default_area" properties. etching : float, optional (default: 0) Etching of the obstacles' corners (rounded corners). Valid only for ''' form_center = None if heights is not None: if _unit_support: from .units import Q_ if isinstance(heights, Q_): heights = heights.m_as(self.unit) elif indexable(heights): if isinstance(heights[0], Q_): heights = [h.m_as(self.unit) for h in heights] # check n if not isinstance(n, np.integer): assert n <= 1, "Filling fraction (floating point `n`) must be " +\ "smaller or equal to 1." # check form if form == "disk": form = self.disk(**params) elif form == "ellipse": form = self.ellipse(**params) elif form == "rectangle": form = self.rectangle(**params) elif not isinstance(form, (Polygon, MultiPolygon, Shape, Area)): raise RuntimeError("Invalid form: '{}'.".format(form)) # get form center and center on (0, 0) xmin, ymin, xmax, ymax = form.bounds form_center = (0.5*(xmax + xmin), 0.5*(ymax + ymin)) form_width = xmax - xmin form_height = ymax - ymin form_bbox_area = float((xmax - xmin)*(ymax - ymin)) # get shape width and height xmin, ymin, xmax, ymax = self.bounds width = xmax - xmin height = ymax - ymin if not np.allclose(form_center, (0, 0)): form = translate(form, -form_center[0], -form_center[1]) # create points where obstacles can be located locations = [] on_width = int(np.rint(width / form_width)) on_height = int(np.rint(height / form_height)) x_offset = 0.5*(width - on_width*form_width) y_offset = 0.5*(height - on_height*form_height) for i in range(on_width): for j in range(on_height): x = xmin + x_offset + i*form_width y = ymin + y_offset + j*form_height locations.append((x, y)) # get elected locations if not isinstance(n, np.integer): n = int(np.rint(len(locations) * n)) indices = list(range(len(locations))) indices = np.random.choice(indices, n, replace=False) locations = [locations[i] for i in indices] # check heights same_prop = [] if heights is not None: try: if len(heights) != n: raise RuntimeError("One `height` entry per obstacle is " "required; expected " "{} but got {}".format(n, len(heights))) same_prop.append(np.allclose(heights, heights[0])) except TypeError: same_prop.append(True) heights = [heights for _ in range(n)] # check properties if isinstance(properties, dict): properties = (properties for _ in range(n)) same_prop.append(True) elif properties is not None: assert len(properties) == n, \ "One `properties` entry per obstacle is required; " +\ "expected {} but got {}".format(n, len(properties)) same_prop.append(True) for dic in properties: same_prop[-1] *= (dic == properties[0]) else: same_prop.append(True) properties = ( self.areas["default_area"].properties.copy() for _ in range(n) ) # make names num_obstacles = 0 for name in self.areas: if name.find("obstacle_") == 0: num_obstacles += 1 names = ["obstacle_{}".format(num_obstacles + i) for i in range(n)] # create the obstacles if heights is None: new_form = Polygon() for loc in locations: new_form = new_form.union(translate(form, loc[0], loc[1])) if etching > 0: new_form = new_form.buffer(-etching, cap_style=3) new_form = new_form.buffer(etching) self.add_hole(new_form) else: if np.all(same_prop): # potentially contiguous areas new_form = Polygon() h = next(iter(heights)) prop = next(iter(properties)) for loc in locations: new_form = new_form.union(translate(form, loc[0], loc[1])) if etching > 0: new_form = new_form.buffer(-etching, cap_style=3) new_form = new_form.buffer(etching) if self.overlaps(new_form) or self.contains(new_form): self.add_area(new_form, height=h, name="obstacle", properties=prop, override=True) else: # many separate areas prop = (locations, heights, names, properties) for loc, h, name, p in zip(*prop): new_form = translate(form, loc[0], loc[1]) if etching > 0: new_form = new_form.buffer(-etching, cap_style=3) new_form = new_form.buffer(etching) if h is None: self.add_hole(new_form) elif self.overlaps(new_form) or self.contains(new_form): self.add_area(new_form, height=h, name=name, properties=p, override=True)
[docs] def set_parent(self, parent): ''' Set the parent :class:`nngt.Graph`. ''' self._parent = weakref.proxy(parent) if parent is not None else None
[docs] def set_return_units(self, b): ''' Set the default behavior for positions returned by `seed_neurons`. If `True`, then the positions returned are quantities with units (from the `pint` library), otherwise they are simply numpy arrays. .. versionadded:: 0.5 Note ---- `set_return_units(True)` requires `pint` to be installed on the system, otherwise an error will be raised. ''' if b and not _unit_support: raise RuntimeError("Cannot set 'return_quantity' to True as " "`pint` is not installed.") self._return_quantity = b for area in self.areas.values(): area._return_quantity = b
[docs] def seed_neurons(self, neurons=None, container=None, on_area=None, xmin=None, xmax=None, ymin=None, ymax=None, soma_radius=0, unit=None, return_quantity=None): ''' Return the positions of the neurons inside the :class:`Shape`. Parameters ---------- neurons : int, optional (default: None) Number of neurons to seed. This argument is considered only if the :class:`Shape` has no `parent`, otherwise, a position is generated for each neuron in `parent`. container : :class:`Shape`, optional (default: None) Subshape acting like a mask, in which the neurons must be contained. The resulting area where the neurons are generated is the :func:`~shapely.Shape.intersection` between of the current shape and the `container`. on_area : str or list, optional (default: None) Area(s) where the seeded neurons should be. xmin : double, optional (default: lowest abscissa of the Shape) Limit the area where neurons will be seeded to the region on the right of `xmin`. xmax : double, optional (default: highest abscissa of the Shape) Limit the area where neurons will be seeded to the region on the left of `xmax`. ymin : double, optional (default: lowest ordinate of the Shape) Limit the area where neurons will be seeded to the region on the upper side of `ymin`. ymax : double, optional (default: highest ordinate of the Shape) Limit the area where neurons will be seeded to the region on the lower side of `ymax`. unit : string (default: None) Unit in which the positions of the neurons will be returned, among 'um', 'mm', 'cm', 'dm', 'm'. return_quantity : bool, optional (default: False) Whether the positions should be returned as ``pint.Quantity`` objects (requires Pint). .. versionchanged:: 0.5 Accepts `pint` units and `return_quantity` argument. Note ---- If both `container` and `on_area` are provided, the intersection of the two is used. Returns ------- positions : array of double with shape (N, 2) or `pint.Quantity` if `return_quantity` is `True`. ''' return_quantity = (self._return_quantity if return_quantity is None else return_quantity) if return_quantity: unit = self._unit if unit is None else unit if not _unit_support: raise RuntimeError("`return_quantity` requested but Pint is " "not available. Please install it first.") if _unit_support: from .units import Q_ if isinstance(xmin, Q_): xmin = xmin.m_as(unit) if isinstance(xmax, Q_): xmax = xmax.m_as(unit) if isinstance(ymin, Q_): ymin = ymin.m_as(unit) if isinstance(ymax, Q_): ymax = ymax.m_as(unit) if isinstance(soma_radius, Q_): soma_radius = soma_radius.m_as(unit) positions = None if neurons is None and self._parent is not None: neurons = self._parent.node_nb() if neurons is None: raise ValueError("`neurons` cannot be None if `parent` is None.") if on_area is not None: if not hasattr(on_area, '__iter__'): on_area = [on_area] min_x, min_y, max_x, max_y = self.bounds custom_shape = (container is not None) if container is None and on_area is None: # set min/max if xmin is None: xmin = -np.inf if ymin is None: ymin = -np.inf if xmax is None: xmax = np.inf if ymax is None: ymax = np.inf min_x = max(xmin, min_x) # smaller that Shape max x assert min_x <= self.bounds[2], "`min_x` must be inside Shape." min_y = max(ymin, min_y) # smaller that Shape max y assert min_y <= self.bounds[3], "`min_y` must be inside Shape." max_x = min(xmax, max_x) # larger that Shape min x assert max_x >= self.bounds[0], "`max_x` must be inside Shape." max_y = min(ymax, max_y) # larger that Shape min y assert max_y >= self.bounds[1], "`max_y` must be inside Shape." # remaining tests if self._geom_type == "Rectangle": xx = uniform( min_x + soma_radius, max_x - soma_radius, size=neurons) yy = uniform( min_y + soma_radius, max_y - soma_radius, size=neurons) positions = np.vstack((xx, yy)).T elif (self._geom_type == "Disk" and (xmin, ymin, xmax, ymax) == self.bounds): theta = uniform(0, 2*np.pi, size=neurons) # take some precaution to stay inside the shape r = (self.radius - soma_radius) *\ np.sqrt(uniform(0, 0.99, size=neurons)) positions = np.vstack( (r*np.cos(theta) + self.centroid[0], r*np.sin(theta) + self.centroid[1])).T else: custom_shape = True container = Polygon([(min_x, min_y), (min_x, max_y), (max_x, max_y), (max_x, min_y)]) elif on_area is not None: custom_shape = True area_shape = Polygon() for area in on_area: area_shape = area_shape.union(self._areas[area]) if container is not None: container = container.intersection(area_shape) else: container = area_shape assert container.area > 0, "`container` and `on_area` have " +\ "empty intersection." # enter here only if Polygon or `container` is not None if custom_shape: seed_area = self.intersection(container) area_buffer = seed_area.buffer(-soma_radius) if not area_buffer.is_empty: seed_area = area_buffer assert not seed_area.is_empty, "Empty area for seeding, check " +\ "your `container` and min/max values." if not isinstance(seed_area, (Polygon, MultiPolygon)): raise ValueError("Invalid boundary value for seed region; " "check that the min/max values you requested " "are inside the shape.") if _opengl_support: triangles = [] if isinstance(seed_area, MultiPolygon): for g in seed_area.geoms: triangles.extend((Polygon(v) for v in triangulate(g))) else: triangles = [Polygon(v) for v in triangulate(seed_area)] positions = rnd_pts_in_tr(triangles, neurons) else: logger.warning("Random point generation can be very slow " "without advanced triangulation methods. " "Please install PyOpenGL for faster seeding " "inside complex shapes.") points = [] p = Point() while len(points) < neurons: new_x = uniform(min_x, max_x, neurons-len(points)) new_y = uniform(min_y, max_y, neurons-len(points)) for x, y in zip(new_x, new_y): p.coords = (x, y) if seed_area.contains(p): points.append((x, y)) positions = np.array(points) if unit is not None and unit != self._unit: positions *= conversion_magnitude(unit, self._unit) if _unit_support and return_quantity: from .units import Q_ return positions * Q_("um" if unit is None else unit) return positions
[docs] def contains_neurons(self, positions): ''' Check whether the neurons are contained in the shape. .. versionadded:: 0.4 Parameters ---------- positions : point or 2D-array of shape (N, 2) Returns ------- contained : bool or 1D boolean array of length N True if the neuron is contained, False otherwise. ''' if _unit_support: from .units import Q_ if isinstance(positions, Q_): positions = positions.m_as(self._unit) elif len(positions): if isinstance(positions[0], Q_): positions = np.array( [q.m_as(self._unit) for q in positions]) elif isinstance(positions[0][0], Q_): positions = np.array( [[q.m_as(self._unit) for q in row] for row in positions]) if np.shape(positions) == (len(positions), 2): contained = [] for pos in positions: contained.append(self.contains(Point(*pos))) return np.array(contained, dtype=bool) else: return self.contains(Point(*positions))
[docs]class Area(Shape): """ Specialized :class:`Shape` that stores additional properties regarding the interactions with the neurons. Each Area is characteristic of a given substrate and height. These two properties are homogeneous over the whole area, meaning that the neurons interact in the same manner with an Area reagardless of their position inside. The substrate is described through its modulation of the neuronal properties compared to their default behavior. Thus, a given area will modulate the speed, wall affinity, etc, of the growth cones that are growing above it. """
[docs] @classmethod def from_shape(cls, shape, height=0., name="area", properties=None, unit='um', min_x=None, max_x=None): ''' Create an :class:`Area` from a :class:`Shape` object. Parameters ---------- shape : :class:`Shape` Shape that should be converted to an Area. Returns ------- :class:`Area` object. ''' if _unit_support: from .units import Q_ if isinstance(height, Q_): height = height.m_as(unit) if isinstance(min_x, Q_): min_x = min_x.m_as(unit) if isinstance(max_x, Q_): max_x = max_x.m_as(unit) obj = None g_type = None if isinstance(shape, MultiPolygon): g_type = "MultiPolygon" elif isinstance(shape, (Polygon, Shape, Area)): g_type = "Polygon" else: raise TypeError("Expected a Polygon or MultiPolygon object.") # find the scaling factor scaling = 1. if None not in (min_x, max_x): ext = np.array(shape.exterior.coords) leftmost = np.min(ext[:, 0]) rightmost = np.max(ext[:, 0]) scaling = (max_x - min_x) / (rightmost - leftmost) obj = scale(shape, scaling, scaling) else: if g_type == "Polygon": obj = Polygon(shape) else: obj = MultiPolygon(shape) obj.__class__ = cls obj._parent = None obj._unit = unit obj._geom_type = g_type obj.__class__ = Area obj._areas = None obj.height = height obj.name = name obj._prop = _PDict( {} if properties is None else deepcopy(properties)) obj._return_quantity = False return obj
def __init__(self, shell, holes=None, unit='um', height=0., name="area", properties=None): ''' Initialize the :class:`Shape` object and the underlying :class:`shapely.geometry.Polygon`. Parameters ---------- shell : array-like object of shape (N, 2) List of points defining the external border of the shape. holes : array-like, optional (default: None) List of array-like objects of shape (M, 2), defining empty regions inside the shape. unit : string (default: 'um') Unit in the metric system among 'um' (:math:`\mu m`), 'mm', 'cm', 'dm', 'm'. height : float, optional (default: 0.) Height of the area. name : str, optional (default: "area") The name of the area. properties : dict, optional (default: default neuronal properties) Dictionary containing the list of the neuronal properties that are modified by the substrate. Since this describes how the default property is modulated, all values must be positive reals or NaN. ''' if _unit_support: from .units import Q_ if isinstance(height, Q_): height = height.m_as(unit) super(Area, self).__init__(shell, holes=holes, unit=unit, parent=None) self._areas = None self.height = height self.name = name self._prop = _PDict( {} if properties is None else deepcopy(properties)) def __deepcopy__(self, *args, **kwargs): obj = Area.from_shape( self, height=self.height, name=self.name, properties=self._prop) return obj
[docs] def copy(self): ''' Create a copy of the current Area. ''' return Area.from_shape(super().copy(), height=self.height, name=self.name, properties=self._prop.todict())
@property def areas(self): raise AttributeError("Areas do not have sub-Areas.") @property def properties(self): p = self._prop.copy() p["height"] = self.height return p def add_subshape(self, subshape, position, unit='um'): raise NotImplementedError("Areas cannot be modified.")
class _PDict(dict): """ Modified dictionary storing the modulation of the properties of an :class:`Area`. """ def __getitem__(self, key): ''' Returns 1 if key is not present. ''' return super(_PDict, self).__getitem__(key) if key in self else 1. def __setitem__(self, key, value): ''' Check that the value is a positive real or NaN before setting it. ''' if key != "substrate_affinity": assert value >= 0 or np.isnan(value), \ "`{}` property must be a positive real or NaN.".format(key) else: assert isinstance(value, float), \ "`substrate_affinity` must be real or NaN." super(_PDict, self).__setitem__(key, float(value)) def todict(self): return {k: v for k, v in self.items()}