Source code for xdesign.phantom.standards

"""Defines an object for simulating X-ray phantoms.

.. moduleauthor:: Daniel J Ching <carterbox@users.noreply.github.com>
"""
__author__ = "Daniel Ching"
__copyright__ = "Copyright (c) 2016, UChicago Argonne, LLC."
__docformat__ = 'restructuredtext en'
__all__ = [
    'XDesignDefault',
    'HyperbolicConcentric',
    'DynamicRange',
    'DogaCircles',
    'SlantedSquares',
    'UnitCircle',
    'SiemensStar',
]

from copy import deepcopy
import logging
import pickle
import warnings

import numpy as np

from xdesign.constants import PI
from xdesign.geometry import *
from xdesign.material import *
from xdesign.phantom.phantom import *

logger = logging.getLogger(__name__)


[docs]class XDesignDefault(Phantom): """Generates a Phantom for internal testing of XDesign. The default phantom is: (1) nested, it contains phantoms within phantoms; (2) geometrically simple, the sinogram can be verified visually; and (3) representative, it contains the three main geometric elements: circle, polygon, and mesh. """ def __init__(self): super(XDesignDefault, self).__init__( geometry=Circle(Point([0.5, 0.5]), radius=0.5), material=SimpleMaterial(0.0) ) # define the points of the mesh a = Point([0.6, 0.6]) b = Point([0.6, 0.4]) c = Point([0.8, 0.4]) d = (a + c) / 2 e = (a + b) / 2 t0 = Triangle(deepcopy(b), deepcopy(c), deepcopy(d)) # construct and reposition the mesh m0 = Mesh() m0.append(Triangle(deepcopy(a), deepcopy(e), deepcopy(d))) m0.append(Triangle(deepcopy(b), deepcopy(d), deepcopy(e))) # define the circles m1 = Mesh() m1.append(Circle(Point([0.3, 0.5]), radius=0.1)) m1.append(-Circle(Point([0.3, 0.5]), radius=0.02)) # construct Phantoms self.append( Phantom( children=[ Phantom(geometry=t0, material=SimpleMaterial(0.5)), Phantom(geometry=m0, material=SimpleMaterial(0.5)) ] ) ) self.append(Phantom(geometry=m1, material=SimpleMaterial(1.0))) self.translate([-0.5, -0.5])
[docs]class HyperbolicConcentric(Phantom): """Generates a series of cocentric alternating black and white circles whose radii are changing at a parabolic rate. These line spacings cover a range of scales and can be used to estimate the Modulation Transfer Function. The radii change according to this function: r(n) = r0*(n+1)^k. Attributes ---------- radii : list The list of radii of the circles widths : list The list of the widths of the bands """ def __init__(self, min_width=0.1, exponent=1 / 2): """ Parameters ---------- min_width : scalar The radius of the smallest ring in the series. exponent : scalar The exponent in the function r(n) = r0*(n+1)^k. """ super(HyperbolicConcentric, self).__init__() center = Point([0.0, 0.0]) Nmax_rings = 512 radii = [0] widths = [min_width] for ring in range(0, Nmax_rings): radius = min_width * np.power(ring + 1, exponent) if radius > 0.5 and ring % 2: break self.append( Phantom( geometry=Circle(center, radius), material=SimpleMaterial((-1.)**(ring % 2)) ) ) # record information about the rings widths.append(radius - radii[-1]) radii.append(radius) self.children.reverse() # smaller circles on top self.radii = radii self.widths = widths
[docs]class DynamicRange(Phantom): """Generates a phantom of randomly placed circles for determining dynamic range. Parameters ------------- steps : scalar, optional The orders of magnitude (base 2) that the colors of the circles cover. jitter : bool, optional True : circles are placed in a jittered grid False : circles are randomly placed shape : string, optional """ def __init__( self, steps=10, jitter=True, geometry=Square(center=Point([0.5, 0.5]), side_length=1) ): super(DynamicRange, self).__init__(geometry=geometry) # determine the size and and spacing of the circles around the box. spacing = 1.0 / np.ceil(np.sqrt(steps)) radius = spacing / 4 colors = [2.0**j for j in range(0, steps)] np.random.shuffle(colors) if jitter: # generate grid _x = np.arange(0, 1, spacing) + spacing / 2 px, py, = np.meshgrid(_x, _x) px = np.ravel(px) py = np.ravel(py) # calculate jitters jitters = 2 * radius * (np.random.rand(2, steps) - 0.5) # place the circles for i in range(0, steps): center = Point([px[i] + jitters[0, i], py[i] + jitters[1, i]]) self.append( Phantom( geometry=Circle(center, radius), material=SimpleMaterial(colors[i]) ) ) else: # completely random for i in range(0, steps): if 1 > self.sprinkle( 1, radius, gap=radius * 0.9, material=SimpleMaterial(colors[i]) ): None # TODO: ensure that all circles are placed self.translate([-0.5, -0.5])
[docs]class DogaCircles(Phantom): """Rows of increasingly smaller circles. Initally arranged in an ordered Latin square, the inital arrangement can be randomly shuffled. Attributes ---------- radii : ndarray radii of circles x : ndarray x position of circles y : ndarray y position of circles """ # IDEA: Use method in this reference to calculate uniformly distributed # latin squares. # DOI: 10.1002/(SICI)1520-6610(1996)4:6<405::AID-JCD3>3.0.CO;2-J def __init__(self, n_sizes=5, size_ratio=0.5, n_shuffles=5): """ Parameters ---------- n_sizes : int number of different sized circles size_ratio : scalar the nth size / the n-1th size n_shuffles : int The number of times to shuffles the latin square """ super(DogaCircles, self).__init__( geometry=Circle(center=Point([0.5, 0.5]), radius=0.5) ) n_sizes = int(n_sizes) if n_sizes <= 0: raise ValueError('There must be at least one size.') if size_ratio > 1 or size_ratio <= 0: raise ValueError('size_ratio should be <= 1 and > 0.') n_shuffles = int(n_shuffles) if n_shuffles < 0: raise ValueError('Cant shuffle a negative number of times') # Seed a latin square, use integers to prevent rounding errors top_row = np.array(range(0, n_sizes), dtype=int) rowsum = np.sum(top_row) lsquare = np.empty([n_sizes, n_sizes], dtype=int) for i in range(0, n_sizes): lsquare[:, i] = np.roll(top_row, i) # Choose a row or column shuffle sequence sequence = np.random.randint(0, 2, n_shuffles) # Shuffle the square for dim in sequence: lsquare = np.rollaxis(lsquare, dim, 0) np.random.shuffle(lsquare) # Assert that it is still a latin square. for i in range(0, n_sizes): assert np.sum(lsquare[:, i]) == rowsum, \ "Column {0} is {1} and should be {2}".format(i, np.sum( lsquare[:, i]), rowsum) assert np.sum(lsquare[i, :]) == rowsum, \ "Column {0} is {1} and should be {2}".format(i, np.sum( lsquare[i, :]), rowsum) # Draw it period = (np.arange(0, n_sizes) / n_sizes + 1 / (2 * n_sizes)) * 0.7 _x, _y = np.meshgrid(period, period) radii = (1 - 1e-10) / (2 * n_sizes) * size_ratio**lsquare * 0.7 _x += (1 - 0.7) / 2 _y += (1 - 0.7) / 2 for (k, x, y) in zip(radii.flatten(), _x.flatten(), _y.flatten()): self.append( Phantom( geometry=Circle(Point([x, y]), radius=k), material=SimpleMaterial(1.0) ) ) self.radii = radii self.x = _x self.y = _y self.translate([-0.5, -0.5])
[docs]class SlantedSquares(Phantom): """Generates a collection of slanted squares. Squares are arranged in concentric circles such that the space between squares is at least gap. The size of the squares is adaptive such that they all remain within the unit circle. Attributes ---------- angle : scalar the angle of slant in radians count : scalar the total number of squares gap : scalar the minimum space between squares side_length : scalar the size of the squares squares_per_level : list the number of squares at each level radius_per_level : list the radius at each level n_levels : scalar the number of levels """ def __init__(self, count=10, angle=5 / 360 * 2 * PI, gap=0): super(SlantedSquares, self).__init__() if count < 1: raise ValueError("There must be at least one square.") # approximate the max diameter from total area available d_max = np.sqrt(PI / 4 / (2 * count)) if 1 < count and count < 5: # bump all the squares to the 1st ring and calculate sizes # as if there were 5 total squares pass while True: squares_per_level = [1] radius_per_level = [0] remaining = count - 1 n_levels = 1 while remaining > 0: # calculate next level capacity radius_per_level.append( radius_per_level[n_levels - 1] + d_max + gap ) this_circumference = PI * 2 * radius_per_level[n_levels] this_capacity = this_circumference // (d_max + gap) # assign squares to levels if remaining - this_capacity >= 0: squares_per_level.append(this_capacity) remaining -= this_capacity else: squares_per_level.append(remaining) remaining = 0 n_levels += 1 assert (remaining >= 0) # Make sure squares will not be outside the phantom, else # decrease diameter by 5% if radius_per_level[-1] < (0.5 - d_max / 2 - gap): break d_max *= 0.95 assert (len(squares_per_level) == len(radius_per_level)) # determine center positions of squares x, y = np.array([]), np.array([]) for level in range(0, n_levels): radius = radius_per_level[level] thetas = ((( np.arange(0, squares_per_level[level]) / squares_per_level[level] ) + 1 / (squares_per_level[level] * 2)) * 2 * PI) x = np.concatenate((x, radius * np.cos(thetas))) y = np.concatenate((y, radius * np.sin(thetas))) # move to center of phantom. x += 0.5 y += 0.5 # add the squares to the phantom side_length = d_max / np.sqrt(2) for i in range(0, x.size): center = Point([x[i], y[i]]) s = Square(center=center, side_length=side_length) s.rotate(angle, center) self.append(Phantom(geometry=s, material=SimpleMaterial(1))) self.angle = angle self.count = count self.gap = gap self.side_length = side_length self.squares_per_level = squares_per_level self.radius_per_level = radius_per_level self.n_levels = n_levels self.translate([-0.5, -0.5])
[docs]class UnitCircle(Phantom): """Generates a phantom with a single circle in its center.""" def __init__(self, radius=0.5, material=SimpleMaterial(1.0)): super(UnitCircle, self).__init__( geometry=Circle(Point([0.0, 0.0]), radius), material=material )
[docs]class SiemensStar(Phantom): """Generates a Siemens star. Attributes ---------- ratio : scalar The spatial frequency times the proportional radius. e.g to get the frequency, f, divide this ratio by some fraction of the maximum radius: f = ratio/radius_fraction """ def __init__(self, n_sectors=4, center=Point([0.0, 0.0]), radius=0.5): """ Parameters ---------- n_sectors: int >= 4 The number of spokes/blades on the star. center: Point radius: scalar > 0 """ super(SiemensStar, self).__init__() if n_sectors < 4: raise ValueError("Must have >= 4 sectors.") if radius <= 0: raise ValueError("radius must be greater than zero.") if not isinstance(center, Point): raise TypeError("center must be of type Point.!") n_points = n_sectors # generate an even number of points around the unit circle points = [] for t in (np.arange(0, n_points) / n_points) * 2 * PI: x = radius * np.cos(t) + center.x y = radius * np.sin(t) + center.y points.append(Point([x, y])) assert (len(points) == n_points) # connect pairs of points to the center to make triangles for i in range(0, n_sectors // 2): f = Phantom( geometry=Triangle(points[2 * i], points[2 * i + 1], center), material=SimpleMaterial(1) ) self.append(f) self.ratio = n_points / (4 * PI * radius) self.n_sectors = n_sectors