#!/usr/bin/env python
# -*- coding: utf-8 -*-
# #########################################################################
# Copyright (c) 2016, UChicago Argonne, LLC. All rights reserved. #
# #
# Copyright 2016. UChicago Argonne, LLC. This software was produced #
# under U.S. Government contract DE-AC02-06CH11357 for Argonne National #
# Laboratory (ANL), which is operated by UChicago Argonne, LLC for the #
# U.S. Department of Energy. The U.S. Government has rights to use, #
# reproduce, and distribute this software. NEITHER THE GOVERNMENT NOR #
# UChicago Argonne, LLC MAKES ANY WARRANTY, EXPRESS OR IMPLIED, OR #
# ASSUMES ANY LIABILITY FOR THE USE OF THIS SOFTWARE. If software is #
# modified to produce derivative works, such modified software should #
# be clearly marked, so as not to confuse it with the version available #
# from ANL. #
# #
# Additionally, redistribution and use in source and binary forms, with #
# or without modification, are permitted provided that the following #
# conditions are met: #
# #
# * Redistributions of source code must retain the above copyright #
# notice, this list of conditions and the following disclaimer. #
# #
# * Redistributions in binary form must reproduce the above copyright #
# notice, this list of conditions and the following disclaimer in #
# the documentation and/or other materials provided with the #
# distribution. #
# #
# * Neither the name of UChicago Argonne, LLC, Argonne National #
# Laboratory, ANL, the U.S. Government, nor the names of its #
# contributors may be used to endorse or promote products derived #
# from this software without specific prior written permission. #
# #
# THIS SOFTWARE IS PROVIDED BY UChicago Argonne, LLC AND CONTRIBUTORS #
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT #
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS #
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL UChicago #
# Argonne, LLC OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, #
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, #
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; #
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER #
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT #
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN #
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE #
# POSSIBILITY OF SUCH DAMAGE. #
# #########################################################################
"""Defines an object for simulating X-ray phantoms.
.. moduleauthor:: Daniel J Ching <carterbox@users.noreply.github.com>
"""
__author__ = "Daniel Ching, Doga Gursoy"
__copyright__ = "Copyright (c) 2016, UChicago Argonne, LLC."
__docformat__ = 'restructuredtext en'
__all__ = [
'Phantom',
'save_phantom',
'load_phantom',
'pickle_phantom',
'unpickle_phantom',
]
from copy import deepcopy
import itertools
import logging
import pickle
import warnings
import numpy as np
from scipy.spatial import Delaunay
from xdesign.constants import PI
from xdesign.geometry import *
from xdesign.material import *
logger = logging.getLogger(__name__)
[docs]def save_phantom(phantom, filename):
"""Save phantom to file as a python repr."""
f = open(filename, 'w')
f.write("{}".format(repr(phantom)))
f.close()
logger.info('Save Phantom to {}'.format(filename))
[docs]def load_phantom(filename):
"""Load phantom from file containing a python repr."""
f = open(filename, 'r')
raw_phantom = f.read()
f.close()
logger.info('Load Phantom from {}'.format(filename))
return eval(raw_phantom)
[docs]def pickle_phantom(phantom, filename):
"""Save phantom to file as a python pickle."""
f = open(filename, 'wb')
pickle.dump(phantom, f)
[docs]def unpickle_phantom(filename):
"""Load phantom from file as a python pickle."""
f = open(filename, 'rb')
return pickle.load(f)
[docs]class Phantom(object):
"""An object for the purpose of evaluating X-ray imaging methods.
Phantoms may be hierarchical structures with children that are contained
within and/or a parent which contains them. They have two parts: a geometry
and properties. The geometry defines the spatial extent over which the
properties are valid. Properties are parameters which a :class:`.Probe`
uses to measure the Phantom.
All Phantoms must fit within the geometry of their ancestors. Phantoms
whose geometry is None act as containers.
Attributes
----------
geometry : :class:`.Entity`
The spatial boundary of the Phantom; may be None.
children :
A list of Phantoms contained in this Phantom.
parent :
The Phantom containing this Phantom.
material :
The mass_attenuation of the phantom.
population :
The number of decendents of this phantom.
"""
# OPERATOR OVERLOADS
def __init__(self, geometry=None, children=[], material=None):
self._geometry = geometry
self.population = 0
self.parent = None
self.material = material
self.children = list()
for child in children:
self.append(child)
def __add__(self, other):
"""Combine two Phantoms."""
parent = Phantom()
parent.append(self)
parent.append(other)
return parent
def __str__(self):
return "{}()".format(type(self).__name__)
def __repr__(self):
return "Phantom(geometry={}, children={}, material={})".format(
repr(self.geometry), repr(self.children), repr(self.material)
)
# PROPERTIES
@property
def is_leaf(self):
"""Return whether the Phantom is a leaf node."""
return not self.children
@property
def geometry(self):
"""Return the geometry of the Phantom."""
return self._geometry
@property
def center(self):
"""Return the centroid of the Phantom."""
if self.geometry is None:
return None
return self.geometry.center
@property
def radius(self):
"""Return the radius of the smallest boundary sphere."""
if self.geometry is None:
return None
return self.geometry.radius
@property
def volume(self):
"""Return the volume of the Phantom"""
if self.geometry is None:
return None
if hasattr(self.geometry, 'volume'):
return self.geometry.volume
else:
return self.geometry.area
@property
def density(self):
'''Return the geometric density of the Phantom.'''
if self.geometry is None:
return None
child_volume = 0
for child in self.children:
child_volume += child.volume
return child_volume / self.volume
# GEOMETRIC TRANSFORMATIONS
[docs] def translate(self, vector):
"""Translate the Phantom."""
for child in self.children:
child.translate(vector)
if self._geometry is not None:
self._geometry.translate(vector)
[docs] def rotate(self, theta, point=Point([0.5, 0.5]), axis=None):
"""Rotate around an axis that passes through the given point."""
for child in self.children:
child.rotate(theta, point, axis)
if self._geometry is not None:
self.geometry.rotate(theta, point, axis)
# TREE MANIPULATION
[docs] def append(self, child):
"""Add a child to the Phantom.
Only add the child if it is contained within the geometry of its
ancestors.
"""
boundary = self.geometry
parent = self.parent
while boundary is None and parent is not None:
boundary = parent.geometry
parent = parent.parent
def contains_children(boundary, child):
for grandchild in child.children:
if (
grandchild.geometry is None
and not contains_children(boundary, grandchild)
):
return False
if not boundary.contains(grandchild.geometry):
return False
return True
if (
boundary is None
or (child.geometry is None and contains_children(boundary, child))
or boundary.contains(child.geometry)
):
child.parent = self
self.children.append(child)
self.population += child.population + 1
return True
else:
warnings.warn(
"{} not appended; it is not a subset.".format(repr(child)),
RuntimeWarning
)
return False
[docs] def pop(self, i=-1):
"""Pop the i-th child from the Phantom."""
self.children[i].parent = None
self.population -= self.children[i].population + 1
return self.children.pop(i)
[docs] def sprinkle(
self,
counts,
radius,
gap=0,
region=None,
material=None,
max_density=1,
shape=Circle
):
"""Sprinkle a number of :class:`.Circle` shaped Phantoms around the
Phantom. Uses various termination criteria to determine when to stop
trying to add circles.
Parameters
----------
counts : int
The number of circles to be added.
radius : scalar or list
The radius of the circles to be added.
gap : float, optional
The minimum distance between circle boundaries.
A negative value allows overlapping edges.
region : :class:`.Entity`, optional
The new circles are confined to this shape. None if the circles are
allowed anywhere.
max_density : scalar, optional
Stops adding circles when the geometric density of the phantom
reaches this ratio.
material : scalar, optional
A mass attenuation parameter passed to the circles.
Returns
----------
counts : scalar
The number of circles successfully added.
"""
if counts < 0:
raise ValueError('Cannot add negative number of circles.')
if not isinstance(radius, list):
radius = [radius, radius]
if len(radius) != 2 or radius[0] < radius[1] or radius[1] <= 0:
raise ValueError(
'Radius range must be larger than zero and largest' +
'radius must be listed first.'
)
if gap < 0:
# Support for partially overlapping phantoms is not yet supported
# in the aquisition module
raise NotImplementedError
if max_density < 0:
raise ValueError("Cannot stop at negative density.")
collision = False
if radius[0] + gap < 0: # prevents circles with negative radius
collision = True
kTERM_CRIT = 500 # tries to append a new circle before quitting
n_tries = 0 # attempts to append a new circle
n_added = 0 # circles successfully added
if region is None:
if self.geometry is None:
return 0
region = self.geometry
while (
n_tries < kTERM_CRIT and n_added < counts
and self.density < max_density
):
center = _random_point(region, margin=radius[0])
if collision:
self.append(
Phantom(
geometry=Circle(center, radius[0]), material=material
)
)
n_added += 1
continue
circle = shape(center, radius=radius[0] + gap)
overlap = _collision(self, circle)
if np.max(overlap) <= radius[0] - radius[1]:
candidate = shape(center, radius=radius[0] - np.max(overlap))
# print(center, radius[0], gap, overlap)
# assert np.all(_collision(self, candidate) <= 0), repr(candidate)
self.append(Phantom(geometry=candidate, material=material))
n_added += 1
n_tries = 0
n_tries += 1
if n_added != counts and n_tries == kTERM_CRIT:
warnings.warn((
"Reached termination criteria of {} attempts " +
"before adding all of the circles."
).format(kTERM_CRIT), RuntimeWarning)
# no warning for reaching max_density because that's settable
return n_added
def _collision(phantom, entity):
"""Return the max overlap of the entity and a child of this Phantom.
May return overlap < 0; the distance between the two non-overlapping
circles.
"""
max_overlap = 0
for child in phantom.children:
if child.geometry is None:
# get overlap from grandchildren
overlap = _collision(child, entity)
else:
# calculate overlap with entity
if isinstance(entity, Circle):
dx = child.center.distance(entity.center)
dr = child.radius + entity.radius
else:
dx = np.abs(child.center._x - entity.center._x)
dr = (child.geometry.side_lengths + entity.side_lengths) / 2
overlap = dr - dx
if np.all(overlap > 0):
max_overlap = np.maximum(max_overlap, overlap)
return max_overlap
def _random_point(geometry, margin=0.0):
"""Return a Point located within the geometry.
Parameters
----------
margin : scalar
Determines the margin value of the shape.
Points will not be created in the margin area.
"""
if isinstance(geometry, Rectangle):
[xmin, ymin, xmax, ymax] = geometry.bounds
x = np.random.uniform(xmin + margin, xmax - margin)
y = np.random.uniform(ymin + margin, ymax - margin)
random_point = Point([x, y])
elif isinstance(geometry, Circle):
radius = geometry.radius
center = geometry.center
r = (radius - margin) * np.sqrt(np.random.uniform(0, 1))
a = np.random.uniform(0, 2 * np.pi)
x = r * np.cos(a) + center.x
y = r * np.sin(a) + center.y
random_point = Point([x, y])
elif isinstance(geometry, NOrthotope):
xmin, xmax = geometry.bounding_box
x = np.random.uniform(xmin + margin, xmax - margin)
random_point = Point(x)
else:
raise NotImplementedError(
"Cannot give point in {}.".format(type(geometry))
)
return random_point