|
10 | 10 |
|
11 | 11 | from __future__ import division |
12 | 12 | from itertools import permutations, product |
| 13 | +from functools import partial |
13 | 14 | import pytest |
14 | 15 | import numpy as np |
15 | 16 |
|
@@ -549,10 +550,85 @@ def test_fanbeam_frommatrix(): |
549 | 550 | det_rad, sing_mat) |
550 | 551 |
|
551 | 552 |
|
| 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 | + |
552 | 628 | def test_helical_cone_beam_props(detector_type, shift): |
553 | 629 | """Test basic properties of 3D helical cone beam geometries.""" |
554 | 630 | full_angle = 2 * np.pi |
555 | | - apart = odl.uniform_partition(0, full_angle, 10) |
| 631 | + apart = odl.uniform_partition(0, full_angle, 13) |
556 | 632 | dpart = odl.uniform_partition([0, 0], [1, 1], (10, 10)) |
557 | 633 | src_rad = 10 |
558 | 634 | det_rad = 5 |
@@ -686,6 +762,92 @@ def test_helical_cone_beam_props(detector_type, shift): |
686 | 762 | assert repr(geom) |
687 | 763 |
|
688 | 764 |
|
| 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 | + |
689 | 851 | def test_cone_beam_slanted_detector(): |
690 | 852 | """Check if non-standard detector axes are handled correctly.""" |
691 | 853 | full_angle = np.pi |
@@ -846,5 +1008,41 @@ def test_helical_geometry_helper(): |
846 | 1008 | assert geometry.det_partition.cell_sides[1] <= delta_h |
847 | 1009 |
|
848 | 1010 |
|
| 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 | + |
849 | 1047 | if __name__ == '__main__': |
850 | 1048 | odl.util.test_file(__file__) |
0 commit comments