Skip to content

Commit 6ea4cda

Browse files
Jevgenija Rudzusikakohr-h
andauthored
Flying Focal Spot for FanBeam and ConeBeam geometries (#1509)
* Flying Focal Spot for FanBeam and ConeBeam geometries * pep fixed * added src_shift_func as an argument to geometry, created function flying_focal_spot (cherry picked from commit a0f161c5d041fb1201fa976116cfccf2f2cb6600) * issues fixed * issues fixed, tests added, needs testing * tests of raytransform added * removed comment * tests improved * detector shifts with tests * detector shift tests added * Apply suggestions from code review Co-authored-by: Holger Kohr <ho.kohr@zoho.com>
1 parent c5da35a commit 6ea4cda

6 files changed

Lines changed: 801 additions & 18 deletions

File tree

odl/test/tomo/geometry/geometry_test.py

Lines changed: 199 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from __future__ import division
1212
from itertools import permutations, product
13+
from functools import partial
1314
import pytest
1415
import numpy as np
1516

@@ -549,10 +550,85 @@ def test_fanbeam_frommatrix():
549550
det_rad, sing_mat)
550551

551552

553+
def test_fanbeam_src_det_shifts(init1=None):
554+
"""Test the source/detector shifts in 2d fan beam geometry."""
555+
full_angle = np.pi
556+
n_angles = 2 * 6
557+
apart = odl.uniform_partition(0, full_angle, n_angles)
558+
dpart = odl.uniform_partition(-1, 1, 11)
559+
src_rad = 10
560+
det_rad = 5
561+
# Source positions with flying focal spot should correspond to
562+
# source positions of 2 geometries with different starting positions
563+
shift1 = np.array([2.0, -3.0])
564+
shift2 = np.array([-2.0, 3.0])
565+
init = np.array([1, 0], dtype=np.float32)
566+
567+
ffs = partial(odl.tomo.flying_focal_spot,
568+
apart=apart,
569+
shifts=[shift1, shift2])
570+
geom_ffs = odl.tomo.FanBeamGeometry(apart, dpart,
571+
src_rad, det_rad,
572+
src_to_det_init=init,
573+
src_shift_func=ffs)
574+
# angles must be shifted to match discretization of apart
575+
ang1 = -full_angle / (n_angles * 2)
576+
apart1 = odl.uniform_partition(ang1, full_angle + ang1, n_angles // 2)
577+
ang2 = full_angle / (n_angles * 2)
578+
apart2 = odl.uniform_partition(ang2, full_angle + ang2, n_angles // 2)
579+
580+
init1 = init + np.array([0, shift1[1]]) / (src_rad + shift1[0])
581+
init2 = init + np.array([0, shift2[1]]) / (src_rad + shift2[0])
582+
# radius also changes when a shift is applied
583+
src_rad1 = np.linalg.norm(np.array([src_rad, 0]) + shift1)
584+
src_rad2 = np.linalg.norm(np.array([src_rad, 0]) + shift2)
585+
geom1 = odl.tomo.FanBeamGeometry(apart1, dpart, src_rad1, det_rad,
586+
src_to_det_init=init1)
587+
geom2 = odl.tomo.FanBeamGeometry(apart2, dpart, src_rad2, det_rad,
588+
src_to_det_init=init2)
589+
590+
sp1 = geom1.src_position(geom1.angles)
591+
sp2 = geom2.src_position(geom2.angles)
592+
sp = geom_ffs.src_position(geom_ffs.angles)
593+
assert all_almost_equal(sp[0::2], sp1)
594+
assert all_almost_equal(sp[1::2], sp2)
595+
596+
# detector positions are not affected by flying focal spot
597+
geom = odl.tomo.FanBeamGeometry(apart, dpart,
598+
src_rad, det_rad,
599+
src_to_det_init=init)
600+
assert all_almost_equal(geom.det_refpoint(geom.angles),
601+
geom_ffs.det_refpoint(geom_ffs.angles))
602+
603+
# However, detector can be shifted similarly as the source
604+
def det_shift(angle):
605+
return ffs(angle) / src_rad * det_rad
606+
geom_ds = odl.tomo.FanBeamGeometry(
607+
apart, dpart,
608+
src_rad, det_rad,
609+
src_to_det_init=init,
610+
det_shift_func=det_shift)
611+
det_rad1 = src_rad1 / src_rad * det_rad
612+
det_rad2 = src_rad2 / src_rad * det_rad
613+
geom1 = odl.tomo.FanBeamGeometry(apart1, dpart, src_rad, det_rad1,
614+
src_to_det_init=init1)
615+
geom2 = odl.tomo.FanBeamGeometry(apart2, dpart, src_rad, det_rad2,
616+
src_to_det_init=init2)
617+
dr1 = geom1.det_refpoint(geom1.angles)
618+
dr2 = geom2.det_refpoint(geom2.angles)
619+
dr = geom_ds.det_refpoint(geom_ds.angles)
620+
assert all_almost_equal(dr[0::2], dr1)
621+
assert all_almost_equal(dr[1::2], dr2)
622+
623+
# source positions are not affected
624+
assert all_almost_equal(geom.src_position(geom.angles),
625+
geom_ds.src_position(geom_ds.angles))
626+
627+
552628
def test_helical_cone_beam_props(detector_type, shift):
553629
"""Test basic properties of 3D helical cone beam geometries."""
554630
full_angle = 2 * np.pi
555-
apart = odl.uniform_partition(0, full_angle, 10)
631+
apart = odl.uniform_partition(0, full_angle, 13)
556632
dpart = odl.uniform_partition([0, 0], [1, 1], (10, 10))
557633
src_rad = 10
558634
det_rad = 5
@@ -686,6 +762,92 @@ def test_helical_cone_beam_props(detector_type, shift):
686762
assert repr(geom)
687763

688764

765+
def test_conebeam_source_detector_shifts():
766+
"""Test source/detector shifts in 3d cone beam geometry."""
767+
full_angle = np.pi
768+
n_angles = 2 * 7
769+
apart = odl.uniform_partition(0, full_angle, n_angles)
770+
dpart = odl.uniform_partition([-1, -1], [1, 1], (10, 10))
771+
src_rad = 10
772+
det_rad = 5
773+
pitch = 3
774+
# Source positions with flying focal spot should correspond to
775+
# source positions of 2 geometries with different starting positions
776+
shift1 = np.array([2.0, -3.0, 1.0])
777+
shift2 = np.array([-2.0, 3.0, -1.0])
778+
init = np.array([1, 0, 0], dtype=np.float32)
779+
ffs = partial(odl.tomo.flying_focal_spot,
780+
apart=apart,
781+
shifts=[shift1, shift2])
782+
geom_ffs = odl.tomo.ConeBeamGeometry(apart, dpart,
783+
src_rad, det_rad,
784+
src_to_det_init=init,
785+
src_shift_func=ffs,
786+
pitch=pitch)
787+
# angles must be shifted to match discretization of apart
788+
ang1 = -full_angle / (n_angles * 2)
789+
apart1 = odl.uniform_partition(ang1, full_angle + ang1, n_angles // 2)
790+
ang2 = full_angle / (n_angles * 2)
791+
apart2 = odl.uniform_partition(ang2, full_angle + ang2, n_angles // 2)
792+
793+
init1 = init + np.array([0, shift1[1], 0]) / (src_rad + shift1[0])
794+
init2 = init + np.array([0, shift2[1], 0]) / (src_rad + shift2[0])
795+
# radius also changes when a shift is applied
796+
src_rad1 = np.linalg.norm(np.array([src_rad + shift1[0], shift1[1], 0]))
797+
src_rad2 = np.linalg.norm(np.array([src_rad + shift2[0], shift2[1], 0]))
798+
geom1 = odl.tomo.ConeBeamGeometry(apart1, dpart, src_rad1, det_rad,
799+
src_to_det_init=init1,
800+
offset_along_axis=shift1[2],
801+
pitch=pitch)
802+
geom2 = odl.tomo.ConeBeamGeometry(apart2, dpart, src_rad2, det_rad,
803+
src_to_det_init=init2,
804+
offset_along_axis=shift2[2],
805+
pitch=pitch)
806+
807+
sp1 = geom1.src_position(geom1.angles)
808+
sp2 = geom2.src_position(geom2.angles)
809+
sp = geom_ffs.src_position(geom_ffs.angles)
810+
assert all_almost_equal(sp[0::2], sp1)
811+
assert all_almost_equal(sp[1::2], sp2)
812+
813+
# detector positions are not affected by flying focal spot
814+
geom = odl.tomo.ConeBeamGeometry(apart, dpart,
815+
src_rad, det_rad,
816+
src_to_det_init=init,
817+
pitch=pitch)
818+
assert all_almost_equal(geom.det_refpoint(geom.angles),
819+
geom_ffs.det_refpoint(geom_ffs.angles))
820+
821+
# However, detector can be shifted similarly as the source
822+
coef = det_rad / src_rad
823+
def det_shift(angle):
824+
return ffs(angle) * coef
825+
geom_ds = odl.tomo.ConeBeamGeometry(apart, dpart,
826+
src_rad, det_rad,
827+
src_to_det_init=init,
828+
det_shift_func=det_shift,
829+
pitch=pitch)
830+
det_rad1 = src_rad1 / src_rad * det_rad
831+
det_rad2 = src_rad2 / src_rad * det_rad
832+
geom1 = odl.tomo.ConeBeamGeometry(apart1, dpart, src_rad, det_rad1,
833+
src_to_det_init=init1,
834+
offset_along_axis=shift1[2] * coef,
835+
pitch=pitch)
836+
geom2 = odl.tomo.ConeBeamGeometry(apart2, dpart, src_rad, det_rad2,
837+
src_to_det_init=init2,
838+
offset_along_axis=shift2[2] * coef,
839+
pitch=pitch)
840+
dr1 = geom1.det_refpoint(geom1.angles)
841+
dr2 = geom2.det_refpoint(geom2.angles)
842+
dr = geom_ds.det_refpoint(geom_ds.angles)
843+
assert all_almost_equal(dr[0::2], dr1)
844+
assert all_almost_equal(dr[1::2], dr2)
845+
846+
# source positions are not affected
847+
assert all_almost_equal(geom.src_position(geom.angles),
848+
geom_ds.src_position(geom_ds.angles))
849+
850+
689851
def test_cone_beam_slanted_detector():
690852
"""Check if non-standard detector axes are handled correctly."""
691853
full_angle = np.pi
@@ -846,5 +1008,41 @@ def test_helical_geometry_helper():
8461008
assert geometry.det_partition.cell_sides[1] <= delta_h
8471009

8481010

1011+
def test_source_detector_shifts():
1012+
"""Test source-detector shift functions, e.g. flying focal spot.
1013+
1014+
See the `flying_focal_spot` documentation for the exact conditions.
1015+
"""
1016+
n_angles = np.random.randint(1, 100)
1017+
apart = odl.uniform_partition(0, np.pi, n_angles)
1018+
part_angles = apart.meshgrid[0]
1019+
1020+
# shifts are periodic
1021+
def check_shifts(ffs, shifts):
1022+
i = 0
1023+
while i < part_angles.size:
1024+
j = min(len(ffs), i + len(shifts))
1025+
assert all_almost_equal(ffs[i:j], shifts[:(j - i)])
1026+
i = j
1027+
1028+
# shifts define ffs at partition points
1029+
n_shifts = np.random.randint(1, n_angles)
1030+
shift_dim = 3
1031+
shifts = np.random.uniform(size=(n_shifts, shift_dim))
1032+
ffs = odl.tomo.flying_focal_spot(part_angles, apart, shifts)
1033+
check_shifts(ffs, shifts)
1034+
1035+
shift_dim = 2
1036+
shifts = np.random.uniform(size=(n_shifts, shift_dim))
1037+
ffs = odl.tomo.flying_focal_spot(part_angles, apart, shifts)
1038+
check_shifts(ffs, shifts)
1039+
1040+
# shifts at other angles ar defined by nearest neighbor interpolation
1041+
d = np.random.uniform(-0.49, 0.49) * apart.cell_volume
1042+
shifts = np.random.uniform(size=(n_shifts, shift_dim))
1043+
ffs = odl.tomo.flying_focal_spot(part_angles + d, apart, shifts)
1044+
check_shifts(ffs, shifts)
1045+
1046+
8491047
if __name__ == '__main__':
8501048
odl.util.test_file(__file__)

0 commit comments

Comments
 (0)