"""Geometry parameter objects for fan- and parallel-beam CT."""
# =============================================================================
# This file is part of LION library
# License : BSD-3
#
# Author : Ander Biguri
# Modifications: -
# =============================================================================
import pathlib
import numpy as np
import json
from LION.utils.parameter import LIONParameter
[docs]
class Geometry(LIONParameter):
"""Describe a discretised CT image, detector, and acquisition trajectory.
Geometry instances are serialisable :class:`LIONParameter` objects. They
retain native image dimensions alongside optionally scaled reconstruction
dimensions, allowing datasets and checkpoints to preserve the physical
field of view while changing numerical resolution.
"""
def __init__(self, **kwargs):
self.mode = kwargs.get("mode", None)
self.native_image_shape = np.array(kwargs.get("native_image_shape", None))
self.native_image_size = np.array(kwargs.get("native_image_size", None))
self.image_scaling = kwargs.get("image_scaling", 1.0)
self.image_shape = np.array(
kwargs.get(
"image_shape",
np.array(
[
self.native_image_shape[0],
self.native_image_shape[1] * self.image_scaling,
self.native_image_shape[2] * self.image_scaling,
]
),
),
dtype=int,
)
self.image_size = np.array(
kwargs.get(
"image_size",
[
self.native_image_size[0] / self.image_scaling,
self.native_image_size[1],
self.native_image_size[2],
],
)
)
if not self.native_image_shape.all() and not self.native_image_size.all():
self.native_image_shape = self.image_shape
self.native_image_size = self.image_size
if self.image_shape.all() and self.image_size.all():
self.voxel_size = self.image_size / self.image_shape
if "image_pos" in kwargs:
self.image_pos = np.array(kwargs.get("image_pos", None))
else:
self.image_pos = np.array([0, 0, 0])
self.detector_shape = np.array(kwargs.get("detector_shape", None))
self.detector_size = np.array(kwargs.get("detector_size", None))
if self.detector_size.all() and self.detector_shape.all():
self.pixel_size = self.detector_size / self.detector_shape
self.dso = np.array(kwargs.get("dso", None))
self.dsd = np.array(kwargs.get("dsd", None))
self.angles = np.array(kwargs.get("angles", None))
# PLEASE SOMEONE FIND A SMARTER WAY TO DO THIS
[docs]
@classmethod
def init_from_parameter(cls, parameter: LIONParameter):
"""Construct a geometry from a serialised LION parameter object."""
if hasattr(parameter, "native_image_shape"):
native_image_shape = parameter.native_image_shape
else:
native_image_shape = parameter.image_shape
if hasattr(parameter, "native_image_size"):
native_image_size = parameter.native_image_size
else:
native_image_size = parameter.image_size
if hasattr(parameter, "image_scaling"):
image_scaling = parameter.image_scaling
else:
image_scaling = 1.0
return cls(
image_shape=parameter.image_shape,
image_size=parameter.image_size,
native_image_shape=native_image_shape,
native_image_size=native_image_size,
image_scaling=image_scaling,
detector_shape=parameter.detector_shape,
detector_size=parameter.detector_size,
dso=parameter.dso,
dsd=parameter.dsd,
mode=parameter.mode,
angles=parameter.angles,
)
[docs]
@staticmethod
def default_parameters(image_scaling=1.0):
"""Return the default LIDC-style fan-beam geometry."""
native_image_shape = [1, 512, 512]
native_image_size = [300.0 / 512.0, 300, 300]
return Geometry(
native_image_shape=native_image_shape,
native_image_size=native_image_size,
image_scaling=image_scaling,
image_shape=np.array(
[
native_image_shape[0],
native_image_shape[1] * image_scaling,
native_image_shape[2] * image_scaling,
],
dtype=int,
),
image_size=[
native_image_size[0] / image_scaling,
native_image_size[1],
native_image_size[2],
],
detector_shape=[1, 900],
detector_size=[1, 900],
dso=575,
dsd=1050,
mode="fan",
angles=np.linspace(0, 2 * np.pi, 360, endpoint=False),
)
[docs]
@staticmethod
def sparse_view_parameters(image_scaling=1.0):
"""Return the default fan-beam geometry with sparse angular views."""
native_image_shape = [1, 512, 512]
native_image_size = [300.0 / 512.0, 300, 300]
return Geometry(
native_image_shape=native_image_shape,
native_image_size=native_image_size,
image_scaling=image_scaling,
image_shape=np.array(
[
native_image_shape[0],
native_image_shape[1] * image_scaling,
native_image_shape[2] * image_scaling,
],
dtype=int,
),
image_size=[
native_image_size[0] / image_scaling,
native_image_size[1],
native_image_size[2],
],
detector_shape=[1, 900],
detector_size=[1, 900],
dso=575,
dsd=1050,
mode="fan",
angles=np.linspace(0, 2 * np.pi, 50, endpoint=False),
)
[docs]
@staticmethod
def sparse_angle_parameters(image_scaling=1.0):
"""Return the default fan-beam geometry with limited angular span."""
native_image_shape = [1, 512, 512]
native_image_size = [300.0 / 512.0, 300, 300]
return Geometry(
native_image_shape=native_image_shape,
native_image_size=native_image_size,
image_scaling=image_scaling,
image_shape=np.array(
[
native_image_shape[0],
native_image_shape[1] * image_scaling,
native_image_shape[2] * image_scaling,
],
dtype=int,
),
image_size=[
native_image_size[0] / image_scaling,
native_image_size[1],
native_image_size[2],
],
detector_shape=[1, 900],
detector_size=[1, 900],
dso=575,
dsd=1050,
mode="fan",
angles=np.linspace(0, 2 * np.pi / 6, 60, endpoint=False),
)
[docs]
@staticmethod
def padis_fan_beam_parameters(image_scaling=1.0):
"""Return the public-PaDIS fan-beam coordinate convention."""
"""Return the 180-view, 180-degree fan-beam geometry used by PaDIS."""
native_image_shape = [1, 512, 512]
native_image_size = [40.0 / 512.0, 40.0, 40.0]
image_shape = np.array(
[
native_image_shape[0],
native_image_shape[1] * image_scaling,
native_image_shape[2] * image_scaling,
],
dtype=int,
)
voxel_size = 40.0 / image_shape[1]
return Geometry(
native_image_shape=native_image_shape,
native_image_size=native_image_size,
image_scaling=image_scaling,
image_shape=image_shape,
image_size=[voxel_size, 40.0, 40.0],
detector_shape=[1, 512],
detector_size=[voxel_size, 80.0],
dso=40.0,
dsd=80.0,
mode="fan",
angles=np.linspace(0, np.pi, 180, endpoint=False),
)
[docs]
@staticmethod
def parallel_default_parameters(image_shape=None, image_scaling=1.0):
"""Return a full-angle parallel-beam geometry."""
if image_shape is None:
native_image_shape = [1, 512, 512]
image_shape = np.array(
[
native_image_shape[0],
native_image_shape[1] * image_scaling,
native_image_shape[2] * image_scaling,
],
dtype=int,
)
else:
native_image_shape = image_shape
if image_scaling != 1.0:
raise Exception("If image_shape is provided, image_scaling must be 1.0")
return Geometry(
native_image_shape=native_image_shape,
native_image_size=native_image_shape,
image_scaling=image_scaling,
image_shape=image_shape,
image_size=image_shape,
detector_shape=image_shape[0:2],
detector_size=image_shape[0:2],
dso=image_shape[1] * 2,
dsd=image_shape[1] * 4,
mode="parallel",
angles=np.linspace(0, 2 * np.pi, 360, endpoint=False),
)
[docs]
@staticmethod
def parallel_sparse_view_parameters(image_shape=None, image_scaling=1.0):
"""Return a sparse-view parallel-beam geometry."""
if image_shape is None:
native_image_shape = [1, 512, 512]
image_shape = np.array(
[
native_image_shape[0],
native_image_shape[1] * image_scaling,
native_image_shape[2] * image_scaling,
],
dtype=int,
)
else:
native_image_shape = image_shape
if image_scaling != 1.0:
raise Exception("If image_shape is provided, image_scaling must be 1.0")
return Geometry(
native_image_shape=native_image_shape,
native_image_size=native_image_shape,
image_scaling=image_scaling,
image_shape=image_shape,
image_size=image_shape,
detector_shape=image_shape[0:2],
detector_size=image_shape[0:2],
dso=image_shape[1] * 2,
dsd=image_shape[1] * 4,
mode="parallel",
angles=np.linspace(0, 2 * np.pi, 50, endpoint=False),
)
staticmethod
[docs]
def parallel_sparse_angle_parameters(image_shape=None, image_scaling=1.0):
"""Return a limited-angle parallel-beam geometry."""
if image_shape is None:
native_image_shape = [1, 512, 512]
image_shape = np.array(
[
native_image_shape[0],
native_image_shape[1] * image_scaling,
native_image_shape[2] * image_scaling,
],
dtype=int,
)
else:
native_image_shape = image_shape
if image_scaling != 1.0:
raise Exception("If image_shape is provided, image_scaling must be 1.0")
return Geometry(
native_image_shape=native_image_shape,
native_image_size=native_image_shape,
image_scaling=image_scaling,
image_shape=image_shape,
image_size=image_shape,
detector_shape=image_shape[0:2],
detector_size=image_shape[0:2],
dso=image_shape[1] * 2,
dsd=image_shape[1] * 4,
mode="parallel",
angles=np.linspace(0, 2 * np.pi / 6, 60, endpoint=False),
)
[docs]
def default_geo(self):
"""Populate this instance with the default fan-beam geometry."""
_native_image_shape = ([1, 512, 512],)
_native_image_size = ([300.0 / 512.0, 300, 300],)
_image_scaling = (1.0,)
self.__init__(
native_image_shape=[1, 512, 512],
native_image_size=[300.0 / 512.0, 300, 300],
image_scaling=_image_scaling,
image_shape=np.array(
[
_native_image_shape[0],
_native_image_shape[1] * _image_scaling,
_native_image_shape[2] * _image_scaling,
],
dtype=int,
),
image_size=[
_native_image_size[0] / _image_scaling,
_native_image_size[1],
_native_image_size[2],
],
detector_shape=[1, 900],
detector_size=[1, 900],
dso=575,
dsd=1050,
mode="fan",
angles=np.linspace(0, 2 * np.pi, 360, endpoint=False),
)
def __str__(self):
string = []
string.append("CT Geometry")
string.append("CT type/mode = " + self.mode)
string.append("-----")
string.append(
"Distance from source to detector (DSD) = " + str(self.dsd) + " mm"
)
string.append("Distance from source to origin (DSO)= " + str(self.dso) + " mm")
string.append("-----")
string.append("Detector")
string.append("Detector shape = " + str(self.detector_shape))
string.append("Detector size = " + str(self.detector_size) + " mm")
string.append("-----")
string.append("Image")
string.append("Image shape = " + str(self.image_shape))
string.append("Image size = " + str(self.image_size) + " mm")
string.append("Number of angles = " + str(self.angles.shape))
string.append("Image scaling = " + str(self.image_scaling))
return "\n".join(string)