Skip to content

Geometry

Class used to take a volumetric numpy array along with physical center coordinates and voxel spacing values to generate an egsphant file of the volume

Source code in pygrpm/geometry/egsphant.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
class Egsphant:
    """
    Class used to take a volumetric numpy array along with physical center coordinates and
    voxel spacing values to generate an egsphant file of the volume
    """

    # pylint: disable=R0913, R0902
    def __init__(
            self,
            volume: np.ndarray,
            spacings: np.ndarray,
            center: np.ndarray,
            materials: Dict,
    ) -> None:
        """
        Class used to convert a provided 3D numpy array into an egsphant volume
        @param volume: A 3D numpy array who's values dictate material information
        @param spacings: A sequence three values representing spacing in each axis
        @param center: A sequence of three values representing the volume's center, such as
        (0, 0, 0), (-21, -39, 256), etc...
        @param materials: Dictionary with material name, value ranges, and density
        @param slice_axis: Axis where slices are located for a 3-D image (usually 0 or 2).
            Defaults to 0.
        """
        self.volume: np.ndarray = volume
        self.spacings: np.ndarray = spacings
        self.center: np.ndarray = center
        self.materials: Dict = materials

        # Generate base volumes for materials and density
        self.material_volume: np.ndarray = np.chararray(self.volume.shape, unicode=True)
        self.density_volume: np.ndarray = np.zeros_like(self.volume, dtype=np.float16)

        self.header: str = ""
        self.voxel_position_string: str = ""
        self.material_string: str = ""
        self.density_string: str = ""

        self.built: bool = False

    def __str__(self):
        return self.content

    def build(self):
        """
        Generic build method to generate and format the egsphant string as desired
        @return: None
        """
        self._trim_materials()
        self.generate_headers()
        self.generate_voxel_positions()
        self.generate_volumes_string()

        self.built = True

    @property
    def content(self) -> str:
        """
        The full egsphant string representation, headers and volumes included
        @return: The egsphant information
        """
        if not self.built:
            self.build()

        return self.header + self.voxel_position_string + "\n" \
               + self.material_string + "\n\n" + self.density_string + "\n"

    def write_to_file(self, filepath: Union[str, os.PathLike]) -> None:
        """
        Utility method to quickly save the egsphant string to a given filepath
        @param filepath: Filepath to save the contents
        @return: None
        """
        with open(filepath, "w", encoding="utf8") as file:
            file.write(self.content)

    def _trim_materials(self) -> None:
        """
        Method to truncate material dictionaries to the bounds of the current volume
        """

        # Dictionary copy to edit while looping
        materials_copy = self.materials.copy()
        for material_name, details in self.materials.items():
            interval = details["value_interval"]

            # This is rather slow but makes for cleaner output
            # Returns true if nothing in the array is within the given interval
            if np.all((self.volume < interval[0]) | (self.volume > interval[1])):
                del materials_copy[material_name]

        self.materials = materials_copy

    def generate_headers(self) -> str:
        """
        Method used to generate egsphant headers as a single string based on the following format:
            Number of materials
            Name_of_material_1 /n
            Name_of_material_2 /n
            ....
            Name_of_material_n /n
            "0" * # materials (example if you have 3 materials this line will be 000)
            Shape of the volume X Y Z
        @return: Returns headers as a string
        """
        header = ""

        # Number of materials
        header += str(len(self.materials)) + "\n"

        # List of materials
        for name in self.materials.keys():
            header += f"{name}\n"

        # 0 series
        header += ("0 " * len(self.materials)) + "\n"

        # Volume shape (put slices in last axis by standard)
        vol_shape = np.swapaxes(self.volume, 0, -1).shape
        header += f"{vol_shape[0]} {vol_shape[1]} {vol_shape[2]}\n"

        self.header = header
        return self.header

    def _generate_dimensions(self) -> tuple:
        """
        Given the internal sequence of spacings, shape, and length,
        generate physical coordinates for every voxel
        @return: A tuple of arrays containing all voxel positions
        """
        shape = self.volume.swapaxes(0, -1).shape
        steps = np.array(self.spacings)

        # Determine half-lengths since we generate with respect to the center point
        vol_lengths = np.array(shape) * steps
        half_lengths = vol_lengths / 2

        start = (self.center - half_lengths) - (steps / 2)
        end = half_lengths + self.center + (steps / 2)

        # Make a quick and dirty check to see if dimensions are even/odd
        odd_shape = ~np.array(shape) % 2

        # If we have even number of slices, our "center pixel" doesn't exist
        # Add a half pixel offset to compensate
        start += odd_shape * (steps / 2)  # Equivalent to if(odd) {apply addition}
        end += odd_shape * (steps / 2)

        x_spacing = np.linspace(start[0], end[0] + steps[0], shape[0] + 1)
        y_spacing = np.linspace(start[1], end[1] + steps[1], shape[1] + 1)
        z_spacing = np.linspace(start[2], end[2], shape[2] + 1)

        return x_spacing, y_spacing, z_spacing

    def generate_voxel_positions(self, precision=3) -> str:
        """
        Generates and stringifies voxel positions for the volumes
        @param precision: Decimal precision for each voxel position
        @return: stringified voxel positions
        """
        pos_arrays = self._generate_dimensions()

        # Convert arrays of values to string, with N decimals and a space as separator
        # Remove garbage values on start and end of arrays
        pos_strings = [
            np.array2string(
                pos, precision=precision, separator=" ", max_line_width=99999,
                floatmode='fixed', suppress_small=True
            )[1:-1]
            for pos in pos_arrays
        ]
        # array2string leaves "nice" spacing which yield extra whitespace. Trim that to always be 1
        pos_strings = [' '.join(mystring.split()) for mystring in pos_strings]

        self.voxel_position_string = "\n".join(pos_strings)
        return self.voxel_position_string

    def _populate_volumes(self) -> None:
        """
        Assigns material and density values to associated volumes
        based on materials dictionary initial volume values
        """
        for idx, details in enumerate(self.materials.values()):
            # For every material, check all volume values that fall within the value_intervals
            # For every matched position, update the material and density volume accordingly
            # Materials get populated from the characters array, and density from the json density
            self.material_volume[
                (self.volume >= details["value_interval"][0])
                & (self.volume <= details["value_interval"][1])
                ] = CHARACTERS[idx]
            self.density_volume[
                (self.volume >= details["value_interval"][0])
                & (self.volume <= details["value_interval"][1])
                ] = details["density"]

    def generate_volumes_string(self) -> str:
        """
        Method used to stringify the density and material volumes belonging to this class
        @return: stringified, and concatenated, strings representing the volumes
        """
        # Make sure we properly populate both volumes
        self._populate_volumes()

        self.material_string = "\n\n".join(
            "\n".join("".join(f"{x}" for x in y) for y in z)
            for z in self.material_volume
        )

        self.density_string = "\n\n".join(
            # pylint: disable=C0209
            # Speed difference of % formatting is very much needed
            "\n".join(" ".join("%0.5f" % x for x in y) for y in z)
            for z in self.density_volume
        )

        return self.material_string + "\n" + self.density_string

content: str property

The full egsphant string representation, headers and volumes included @return: The egsphant information

__init__(volume, spacings, center, materials)

Class used to convert a provided 3D numpy array into an egsphant volume @param volume: A 3D numpy array who's values dictate material information @param spacings: A sequence three values representing spacing in each axis @param center: A sequence of three values representing the volume's center, such as (0, 0, 0), (-21, -39, 256), etc... @param materials: Dictionary with material name, value ranges, and density @param slice_axis: Axis where slices are located for a 3-D image (usually 0 or 2). Defaults to 0.

Source code in pygrpm/geometry/egsphant.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def __init__(
        self,
        volume: np.ndarray,
        spacings: np.ndarray,
        center: np.ndarray,
        materials: Dict,
) -> None:
    """
    Class used to convert a provided 3D numpy array into an egsphant volume
    @param volume: A 3D numpy array who's values dictate material information
    @param spacings: A sequence three values representing spacing in each axis
    @param center: A sequence of three values representing the volume's center, such as
    (0, 0, 0), (-21, -39, 256), etc...
    @param materials: Dictionary with material name, value ranges, and density
    @param slice_axis: Axis where slices are located for a 3-D image (usually 0 or 2).
        Defaults to 0.
    """
    self.volume: np.ndarray = volume
    self.spacings: np.ndarray = spacings
    self.center: np.ndarray = center
    self.materials: Dict = materials

    # Generate base volumes for materials and density
    self.material_volume: np.ndarray = np.chararray(self.volume.shape, unicode=True)
    self.density_volume: np.ndarray = np.zeros_like(self.volume, dtype=np.float16)

    self.header: str = ""
    self.voxel_position_string: str = ""
    self.material_string: str = ""
    self.density_string: str = ""

    self.built: bool = False

build()

Generic build method to generate and format the egsphant string as desired @return: None

Source code in pygrpm/geometry/egsphant.py
64
65
66
67
68
69
70
71
72
73
74
def build(self):
    """
    Generic build method to generate and format the egsphant string as desired
    @return: None
    """
    self._trim_materials()
    self.generate_headers()
    self.generate_voxel_positions()
    self.generate_volumes_string()

    self.built = True

generate_headers()

Method used to generate egsphant headers as a single string based on the following format: Number of materials Name_of_material_1 /n Name_of_material_2 /n .... Name_of_material_n /n "0" * # materials (example if you have 3 materials this line will be 000) Shape of the volume X Y Z @return: Returns headers as a string

Source code in pygrpm/geometry/egsphant.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def generate_headers(self) -> str:
    """
    Method used to generate egsphant headers as a single string based on the following format:
        Number of materials
        Name_of_material_1 /n
        Name_of_material_2 /n
        ....
        Name_of_material_n /n
        "0" * # materials (example if you have 3 materials this line will be 000)
        Shape of the volume X Y Z
    @return: Returns headers as a string
    """
    header = ""

    # Number of materials
    header += str(len(self.materials)) + "\n"

    # List of materials
    for name in self.materials.keys():
        header += f"{name}\n"

    # 0 series
    header += ("0 " * len(self.materials)) + "\n"

    # Volume shape (put slices in last axis by standard)
    vol_shape = np.swapaxes(self.volume, 0, -1).shape
    header += f"{vol_shape[0]} {vol_shape[1]} {vol_shape[2]}\n"

    self.header = header
    return self.header

generate_volumes_string()

Method used to stringify the density and material volumes belonging to this class @return: stringified, and concatenated, strings representing the volumes

Source code in pygrpm/geometry/egsphant.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
def generate_volumes_string(self) -> str:
    """
    Method used to stringify the density and material volumes belonging to this class
    @return: stringified, and concatenated, strings representing the volumes
    """
    # Make sure we properly populate both volumes
    self._populate_volumes()

    self.material_string = "\n\n".join(
        "\n".join("".join(f"{x}" for x in y) for y in z)
        for z in self.material_volume
    )

    self.density_string = "\n\n".join(
        # pylint: disable=C0209
        # Speed difference of % formatting is very much needed
        "\n".join(" ".join("%0.5f" % x for x in y) for y in z)
        for z in self.density_volume
    )

    return self.material_string + "\n" + self.density_string

generate_voxel_positions(precision=3)

Generates and stringifies voxel positions for the volumes @param precision: Decimal precision for each voxel position @return: stringified voxel positions

Source code in pygrpm/geometry/egsphant.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
def generate_voxel_positions(self, precision=3) -> str:
    """
    Generates and stringifies voxel positions for the volumes
    @param precision: Decimal precision for each voxel position
    @return: stringified voxel positions
    """
    pos_arrays = self._generate_dimensions()

    # Convert arrays of values to string, with N decimals and a space as separator
    # Remove garbage values on start and end of arrays
    pos_strings = [
        np.array2string(
            pos, precision=precision, separator=" ", max_line_width=99999,
            floatmode='fixed', suppress_small=True
        )[1:-1]
        for pos in pos_arrays
    ]
    # array2string leaves "nice" spacing which yield extra whitespace. Trim that to always be 1
    pos_strings = [' '.join(mystring.split()) for mystring in pos_strings]

    self.voxel_position_string = "\n".join(pos_strings)
    return self.voxel_position_string

write_to_file(filepath)

Utility method to quickly save the egsphant string to a given filepath @param filepath: Filepath to save the contents @return: None

Source code in pygrpm/geometry/egsphant.py
88
89
90
91
92
93
94
95
def write_to_file(self, filepath: Union[str, os.PathLike]) -> None:
    """
    Utility method to quickly save the egsphant string to a given filepath
    @param filepath: Filepath to save the contents
    @return: None
    """
    with open(filepath, "w", encoding="utf8") as file:
        file.write(self.content)