def create_bb_attenuation_func(diameter, penumbra, max_attenuation): dx = diameter / 100 radius = diameter / 2 image_half_width = penumbra * 2 + radius x = np.arange(-image_half_width, image_half_width + dx, dx) xx, yy = np.meshgrid(x, x) with warnings.catch_warnings(): warnings.simplefilter("ignore") z = np.sqrt(radius**2 - xx**2 - yy**2) / radius z[np.isnan(z)] = 0 sig = profiles.scaled_penumbra_sig() * penumbra sig_pixel = sig / dx filtered = scipy.ndimage.gaussian_filter(z, sig_pixel) interp = scipy.interpolate.RegularGridInterpolator((x, x), filtered, bounds_error=False, fill_value=None) def attenuation(x, y): return 1 - interp((x, y)) * max_attenuation return attenuation
def from_pulse(self, centre, width, domain, increment, meta={}): """ create pulse of unit height Parameters ---------- centre : float width : float domain : tuple (x_left, x_right) increment : float meta : dict, optional Returns ------- Profile """ x_vals = np.arange(domain[0], domain[1] + increment, increment) y = [] for x in x_vals: if abs(x) > (centre + width / 2.0): y.append(0.0) elif abs(x) < (centre + width / 2.0): y.append(1.0) else: y.append(0.5) return Profile().from_lists(x_vals, y, meta=meta)
def create_bb_points_function(bb_diameter): max_distance = bb_diameter * 0.5 min_distance = 0 num_steps = 11 min_dist_between_points = (max_distance - min_distance) / num_steps distances = np.arange(min_distance, max_distance + min_dist_between_points, min_dist_between_points) x = [] y = [] dist = [] for _, distance in enumerate(distances): ( new_x, new_y, ) = pymedphys._utilities.createshells.calculate_coordinates_shell_2d( # pylint: disable = protected-access distance, min_dist_between_points) x.append(new_x) y.append(new_y) dist.append(distance * np.ones_like(new_x)) x = np.concatenate(x) y = np.concatenate(y) dist = np.concatenate(dist) def points_to_check(bb_centre): x_shifted = x + bb_centre[0] y_shifted = y + bb_centre[1] return x_shifted, y_shifted return points_to_check, dist
def _radians(self): interval = (2 * np.pi) / self.size rads = np.arange( 0 + self.start_angle, (2 * np.pi) + self.start_angle - interval, interval ) if self.ccw: rads = rads[::-1] return rads
def get_grid( max_leaf_gap=__DEFAULT_MAX_LEAF_GAP, grid_resolution=__DEFAULT_GRID_RESOLUTION, leaf_pair_widths=__DEFAULT_LEAF_PAIR_WIDTHS, ): """Get the MU Density grid for plotting purposes. Examples -------- See `pymedphys.mudensity.calculate`_. """ leaf_pair_widths = np.array(leaf_pair_widths) grid = dict() grid["mlc"] = np.arange( -max_leaf_gap / 2, max_leaf_gap / 2 + grid_resolution, grid_resolution ).astype("float") _, top_of_reference_leaf = _determine_leaf_centres(leaf_pair_widths) grid_reference_position = _determine_reference_grid_position( top_of_reference_leaf, grid_resolution ) # It might be better to use round instead of ceil here. total_leaf_widths = np.sum(leaf_pair_widths) top_grid_pos = ( np.ceil((total_leaf_widths / 2 - grid_reference_position) / grid_resolution) * grid_resolution + grid_reference_position ) bot_grid_pos = ( grid_reference_position - np.ceil((total_leaf_widths / 2 + grid_reference_position) / grid_resolution) * grid_resolution ) grid["jaw"] = np.arange( bot_grid_pos, top_grid_pos + grid_resolution, grid_resolution ) return grid
def _determine_calc_grid_and_adjustments(mlc, jaw, leaf_pair_widths, grid_resolution): min_y = np.min(-jaw[:, 0]) max_y = np.max(jaw[:, 1]) leaf_centres, top_of_reference_leaf = _determine_leaf_centres(leaf_pair_widths) grid_reference_position = _determine_reference_grid_position( top_of_reference_leaf, grid_resolution ) top_grid_pos = ( np.round((max_y - grid_reference_position) / grid_resolution) ) * grid_resolution + grid_reference_position bot_grid_pos = ( grid_reference_position - (np.round((-min_y + grid_reference_position) / grid_resolution)) * grid_resolution ) grid = dict() grid["jaw"] = np.arange( bot_grid_pos, top_grid_pos + grid_resolution, grid_resolution ).astype("float") grid_leaf_map = np.argmin( np.abs(grid["jaw"][:, None] - leaf_centres[None, :]), axis=1 ) adjusted_grid_leaf_map = grid_leaf_map - np.min(grid_leaf_map) leaves_to_be_calced = np.unique(grid_leaf_map) adjusted_mlc = mlc[:, leaves_to_be_calced, :] min_x = np.round(np.min(-adjusted_mlc[:, :, 0]) / grid_resolution) * grid_resolution max_x = np.round(np.max(adjusted_mlc[:, :, 1]) / grid_resolution) * grid_resolution grid["mlc"] = np.arange(min_x, max_x + grid_resolution, grid_resolution).astype( "float" ) return grid, adjusted_grid_leaf_map, adjusted_mlc
def run_wlutz( field, edge_lengths, penumbra, field_centre, field_rotation, find_bb=True, pixel_size=0.1, pylinac_versions=("v2.2.6", "v2.2.7"), ): centralised_straight_field = create_centralised_field( field, field_centre, field_rotation) half_x_range = edge_lengths[0] / 2 + penumbra * 3 half_y_range = edge_lengths[1] / 2 + penumbra * 3 x_range = np.arange(-half_x_range, half_x_range + pixel_size, pixel_size) y_range = np.arange(-half_y_range, half_y_range + pixel_size, pixel_size) xx_range, yy_range = np.meshgrid(x_range, y_range) centralised_image = centralised_straight_field(xx_range, yy_range) results = {} for key in pylinac_versions: pylinac_field_centre, pylinac_bb_centre = run_pylinac_with_class( VERSION_TO_CLASS_MAP[key], centralised_image, pixel_size, half_x_range, half_y_range, field_centre, field_rotation, find_bb=find_bb, ) results[key] = { "field_centre": pylinac_field_centre, "bb_centre": pylinac_bb_centre, } return results
def make_histogram(sinogram, num_bins=10): """ make a leaf-open-time histogram Return a histogram of leaf-open-times for the provided sinogram comprised of the specified number of bins, in the form of a list of tuples: [(bin, count)...] where bin is a 2-element array setting the bounds and count in the number leaf-open-times in the bin. Parameters ---------- sinogram : np.array num_bins : int Returns ------- histogram : list of tuples: [(bin, count)...] bin is a 2-element array """ lfts = sinogram.flatten() bin_inc = (max(lfts) - min(lfts)) / num_bins bin_min = min(lfts) bin_max = max(lfts) bins_strt = np.arange(bin_min, bin_max, bin_inc) bins_stop = np.arange(bin_inc, bin_max + bin_inc, bin_inc) bins = np.dstack((bins_strt, bins_stop))[0] counts = [0 for b in bins] for lft in lfts: for idx, bin in enumerate(bins): if lft >= bin[0] and lft < bin[1]: counts[idx] = counts[idx] + 1 histogram = list(zip(bins, counts)) return histogram
def _calc_blocked_by_device(grid, positions, grid_resolution, time_steps): blocked_by_device = {} for device, value in positions.items(): blocked_by_device[device] = dict() for multiplier, (start, end) in value.items(): dt = (end - start) / (time_steps - 1) travel = start[None, :] + np.arange( 0, time_steps)[:, None] * dt[None, :] travel_diff = multiplier * (grid[device][None, None, :] - travel[:, :, None]) blocked_by_device[device][multiplier] = _calc_blocked_t( travel_diff, grid_resolution) return blocked_by_device
def resample_x(self, step): """ resampled x-values at a given increment Resulting profile has stepsize of the indicated step based on linear interpolation over the points of the source profile. Parameters ---------- step : float sampling increment Returns ------- Profile """ new_x = np.arange(self.x[0], self.x[-1], step) new_y = self.interp(new_x) return Profile(new_x, new_y, self.meta)
def make_symmetric(self): """ avg of corresponding points Created by averaging over corresponding +/- distances, except at the endpoints. Returns ------- Profile """ reflected = Profile(x=-self.x[::-1], y=self.y[::-1]) step = self.get_increment() new_x = np.arange(min(self.x), max(self.x), step) new_y = [self.y[0]] for n in new_x[1:-1]: # AVOID EXTRAPOLATION new_y.append(0.5 * self.interp(n) + 0.5 * reflected.interp(n)) new_y.append(reflected.y[0]) return Profile(x=new_x, y=new_y, meta=self.meta)
def cross_calibrate(self, reference, measured): """ density mapping, reference -> measured Calculated by overlaying intensity curves and observing values at corresponding points. Note that the result is an unsmoothed, collection of points. Parameters ---------- reference : string measured : string file names with path Returns ------- Profile """ _, ext = os.path.splitext(reference) assert ext == ".prs" reference = Profile().from_snc_profiler(reference, "rad") _, ext = os.path.splitext(measured) assert ext == ".png" measured = Profile().from_narrow_png(measured) measured = measured.align_to(reference) dist_vals = np.arange( max(min(measured.x), min(reference.x)), min(max(measured.x), max(reference.x)), max(reference.get_increment(), measured.get_increment()), ) calib_curve = [(measured.get_y(i), reference.get_y(i)) for i in dist_vals] return Profile().from_tuples(calib_curve)
def resample_y(self, step): """ resampled y-values at a given increment Resulting profile has nonuniform step-size, but each step represents and approximately equal step in dose. Parameters ---------- step : float sampling increment Returns ------- Profile """ temp_x = np.arange(min(self.x), max(self.x), 0.01 * self.get_increment()) temp_y = self.interp(temp_x) resamp_x = [temp_x[0]] resamp_y = [temp_y[0]] last_y = temp_y[0] for i, _ in enumerate(temp_x): if np.abs(temp_y[i] - last_y) >= step: resamp_x.append(temp_x[i]) resamp_y.append(temp_y[i]) last_y = temp_y[i] if temp_x[-1] not in resamp_x: resamp_x.append(temp_x[-1]) resamp_y.append(temp_y[-1]) return Profile().from_lists(resamp_x, resamp_y, meta=self.meta)
def main(energy, dose_rate, prepend=""): total_mu = "{:.6f}".format(float(dose_rate)) dose_rate = str(int(dose_rate)) nominal_energy = str( float("".join(i for i in energy if i in "0123456789."))) fff = "fff" in str(energy).lower() vmat_example, fff_example, collimation = load_templates() fff_fluence_mode = fff_example.BeamSequence[0].PrimaryFluenceModeSequence beam_collimation = (collimation.BeamSequence[0].ControlPointSequence[0]. BeamLimitingDevicePositionSequence) gantry_step_size = 15.0 gantry_beam_1 = from_bipolar(np.arange(-180, 181, gantry_step_size)) gantry_beam_1[0] = 180.1 gantry_beam_1[-1] = 179.9 coll_beam_1 = from_bipolar(np.arange(-180, 1, gantry_step_size / 2)) coll_beam_1[0] = 180.1 gantry_beam_2 = from_bipolar(np.arange(180, -181, -gantry_step_size)) gantry_beam_2[-1] = 180.1 gantry_beam_2[0] = 179.9 coll_beam_2 = from_bipolar(np.arange(180, -1, -gantry_step_size / 2)) coll_beam_2[0] = 179.9 gant_directions = ["CW", "CC"] coll_directions = ["CC", "CW"] control_point_sequence_beam1 = create_control_point_sequence( vmat_example.BeamSequence[0], beam_collimation, coll_directions[0], dose_rate, gantry_beam_1, coll_beam_1, nominal_energy, ) control_point_sequence_beam2 = create_control_point_sequence( vmat_example.BeamSequence[1], beam_collimation, coll_directions[1], dose_rate, gantry_beam_2, coll_beam_2, nominal_energy, ) plan = copy.deepcopy(vmat_example) plan.BeamSequence[0].ControlPointSequence = control_point_sequence_beam1 plan.BeamSequence[1].ControlPointSequence = control_point_sequence_beam2 num_cps = len(gantry_beam_1) for beam_sequence, direction in zip(plan.BeamSequence, gant_directions): beam_sequence.NumberOfControlPoints = str(num_cps) beam_sequence.BeamName = ( f"WLutzArc-{prepend}-{energy}-{dose_rate.zfill(4)}-{direction}") if fff: for beam_sequence in plan.BeamSequence: beam_sequence.PrimaryFluenceModeSequence = fff_fluence_mode plan.FractionGroupSequence[0].ReferencedBeamSequence[ 0].BeamMeterset = total_mu plan.FractionGroupSequence[0].ReferencedBeamSequence[ 1].BeamMeterset = total_mu plan.RTPlanLabel = f"{prepend}-{energy}-{dose_rate}" plan.RTPlanName = plan.RTPlanLabel plan.PatientID = "WLutzArc" return plan
def xyz_axes_from_dataset(ds, coord_system="DICOM"): r"""Returns the x, y and z axes of a DICOM dataset's pixel array in the specified coordinate system. For DICOM RT Dose datasets, these are the x, y, z axes of the dose grid. Parameters ---------- ds : pydicom.dataset.Dataset A DICOM dataset that contains pixel data. Supported modalities include 'CT' and 'RTDOSE'. coord_system : str, optional The coordinate system in which to return the `x`, `y` and `z` axes of the DICOM dataset. The accepted, case-insensitive values of `coord_system` are: 'DICOM' or 'd': Return axes in the DICOM coordinate system. 'patient', 'IEC patient' or 'p': Return axes in the IEC patient coordinate system. 'fixed', 'IEC fixed' or 'f': Return axes in the IEC fixed coordinate system. Returns ------- (x, y, z) A tuple containing three `numpy.ndarray`s corresponding to the `x`, `y` and `z` axes of the DICOM dataset's pixel array in the specified coordinate system. Notes ----- Supported scan orientations [1]_: =========================== ========================== Orientation ds.ImageOrientationPatient =========================== ========================== Feet First Decubitus Left [0, 1, 0, 1, 0, 0] Feet First Decubitus Right [0, -1, 0, -1, 0, 0] Feet First Prone [1, 0, 0, 0, -1, 0] Feet First Supine [-1, 0, 0, 0, 1, 0] Head First Decubitus Left [0, -1, 0, 1, 0, 0] Head First Decubitus Right [0, 1, 0, -1, 0, 0] Head First Prone [-1, 0, 0, 0, -1, 0] Head First Supine [1, 0, 0, 0, 1, 0] =========================== ========================== References ---------- .. [1] O. McNoleg, "Generalized coordinate transformations for Monte Carlo (DOSXYZnrc and VMC++) verifications of DICOM compatible radiotherapy treatment plans", arXiv:1406.0014, Table 1, https://arxiv.org/ftp/arxiv/papers/1406/1406.0014.pdf """ position = np.array(ds.ImagePositionPatient) orientation = np.array(ds.ImageOrientationPatient) if not ( np.array_equal(np.abs(orientation), np.array([1, 0, 0, 0, 1, 0])) or np.array_equal(np.abs(orientation), np.array([0, 1, 0, 1, 0, 0])) ): raise ValueError( "Dose grid orientation is not supported. Dose " "grid slices must be aligned along the " "superoinferior axis of patient." ) is_decubitus = orientation[0] == 0 is_head_first = _orientation_is_head_first(orientation, is_decubitus) di = float(ds.PixelSpacing[0]) dj = float(ds.PixelSpacing[1]) col_range = np.arange(0, ds.Columns * di, di) row_range = np.arange(0, ds.Rows * dj, dj) if is_decubitus: x_dicom_fixed = orientation[1] * position[1] + col_range y_dicom_fixed = orientation[3] * position[0] + row_range else: x_dicom_fixed = orientation[0] * position[0] + col_range y_dicom_fixed = orientation[4] * position[1] + row_range if is_head_first: z_dicom_fixed = position[2] + np.array(ds.GridFrameOffsetVector) else: z_dicom_fixed = -position[2] + np.array(ds.GridFrameOffsetVector) if coord_system.upper() in ("FIXED", "IEC FIXED", "F"): x = x_dicom_fixed y = z_dicom_fixed z = -np.flip(y_dicom_fixed) elif coord_system.upper() in ("DICOM", "D", "PATIENT", "IEC PATIENT", "P"): if orientation[0] == 1: x = x_dicom_fixed elif orientation[0] == -1: x = np.flip(x_dicom_fixed) elif orientation[1] == 1: y_d = x_dicom_fixed elif orientation[1] == -1: y_d = np.flip(x_dicom_fixed) if orientation[4] == 1: y_d = y_dicom_fixed elif orientation[4] == -1: y_d = np.flip(y_dicom_fixed) elif orientation[3] == 1: x = y_dicom_fixed elif orientation[3] == -1: x = np.flip(y_dicom_fixed) if not is_head_first: z_d = np.flip(z_dicom_fixed) else: z_d = z_dicom_fixed if coord_system.upper() in ("DICOM", "D"): y = y_d z = z_d elif coord_system.upper() in ("PATIENT", "IEC PATIENT", "P"): y = z_d z = -np.flip(y_d) return (x, y, z)
def create_axes(image, dpcm=100): shape = np.shape(image) x_span = (np.arange(0, shape[0]) - shape[0] // 2) * 10 / dpcm y_span = (np.arange(0, shape[1]) - shape[1] // 2) * 10 / dpcm return x_span, y_span
def calculate_min_dose_difference(options, distance, to_be_checked, distance_step_size): """Determine the minimum dose difference. Calculated for a given distance from each reference point. """ min_relative_dose_difference = np.nan * np.ones_like( options.flat_dose_reference[to_be_checked]) num_dimensions = np.shape(options.flat_mesh_axes_reference)[0] coordinates_at_distance_shell = pymedphys._utilities.createshells.calculate_coordinates_shell( # pylint: disable = protected-access distance, num_dimensions, distance_step_size) num_points_in_shell = np.shape(coordinates_at_distance_shell)[1] estimated_ram_needed = (np.uint64(num_points_in_shell) * np.uint64(np.count_nonzero(to_be_checked)) * np.uint64(32) * np.uint64(num_dimensions) * np.uint64(2)) num_slices = np.floor( estimated_ram_needed / options.ram_available).astype(int) + 1 if not options.quiet: sys.stdout.write( " | Points tested per reference point: {} | RAM split count: {}". format(num_points_in_shell, num_slices)) sys.stdout.flush() all_checks = np.where(np.ravel(to_be_checked))[0] index = np.arange(len(all_checks)) sliced = np.array_split(index, num_slices) sorted_sliced = [np.sort(current_slice) for current_slice in sliced] for current_slice in sorted_sliced: to_be_checked_sliced = np.full_like(to_be_checked, False, dtype=bool) to_be_checked_sliced[ # pylint: disable=unsupported-assignment-operation all_checks[current_slice]] = True assert np.all(to_be_checked[to_be_checked_sliced]) axes_reference_to_be_checked = options.flat_mesh_axes_reference[:, to_be_checked_sliced] evaluation_dose = interpolate_evaluation_dose_at_distance( options.evaluation_interpolation, axes_reference_to_be_checked, coordinates_at_distance_shell, ) if options.local_gamma: with np.errstate(divide="ignore"): relative_dose_difference = ( evaluation_dose - options.flat_dose_reference[to_be_checked_sliced][None, :] ) / ( options.flat_dose_reference[to_be_checked_sliced][None, :]) else: relative_dose_difference = ( evaluation_dose - options.flat_dose_reference[to_be_checked_sliced][None, :] ) / options.global_normalisation min_relative_dose_difference[current_slice] = np.min( np.abs(relative_dose_difference), axis=0) return min_relative_dose_difference
def _interp_coords(coord): return scipy.interpolate.interp1d(np.arange(len(coord)), coord)
def read_narrow_png(file_name, step_size=0.1): """ Extract a an relative-density profilee from a narrow png file. Source file is a full color PNG that is sufficiently narrow that density uniform along its short dimension. The image density along its long dimension is reflective of a dose distribution. Requires Python PIL. Parameters ---------- file_name : str step-size : float, optional Distance output increment in cm, defaults to 1 mm Returns ------- array_like Image profile as a list of (distance, density) tuples, where density is an average color intensity value as represented in the source file. Raises ------ ValueError If image is not narrow, i.e. aspect ratio <= 5 AssertionError If step_size is too small, i.e. step_size <= 12.7 / dpi """ image_file = Image.open(file_name) assert image_file.mode == "RGB" dpi_horiz, dpi_vert = image_file.info["dpi"] image_array = mpimg.imread(file_name) # DIMENSIONS TO AVG ACROSS DIFFERENT FOR HORIZ VS VERT IMG if image_array.shape[0] > 5 * image_array.shape[1]: # VERT image_vector = np.average(image_array, axis=(1, 2)) pixel_size_in_cm = 2.54 / dpi_vert elif image_array.shape[1] > 5 * image_array.shape[0]: # HORIZ image_vector = np.average(image_array, axis=(0, 2)) pixel_size_in_cm = 2.54 / dpi_horiz else: raise ValueError("The PNG file is not a narrow strip.") assert step_size > 5 * pixel_size_in_cm, "step size too small" if image_vector.shape[0] % 2 == 0: image_vector = image_vector[:-1] # SO ZERO DISTANCE IS MID-PIXEL length_in_cm = image_vector.shape[0] * pixel_size_in_cm full_resolution_distances = np.arange( -length_in_cm / 2, length_in_cm / 2, pixel_size_in_cm ) # TO MOVE FROM FILM RESOLUTION TO DESIRED PROFILE RESOLUTION num_pixels_to_avg_over = int(step_size / pixel_size_in_cm) sample_indices = np.arange( num_pixels_to_avg_over / 2, len(full_resolution_distances), num_pixels_to_avg_over, ).astype(int) downsampled_distances = list(full_resolution_distances[sample_indices]) downsampled_density = [] for idx in sample_indices: # AVERAGE OVER THE SAMPLING WINDOW avg_density = np.average( image_vector[ int(idx - num_pixels_to_avg_over / 2) : int( idx + num_pixels_to_avg_over / 2 ) ] ) downsampled_density.append(avg_density) zipped_profile = list(zip(downsampled_distances, downsampled_density)) return zipped_profile
def from_narrow_png(self, file_name, step_size=0.1): """ import from png file Source file is a full color PNG, sufficiently narrow that density is uniform along its short dimension. The image density along its long dimension is reflective of a dose distribution. Parameters ---------- file_name : str step-size : float, optional Returns ------- Profile Raises ------ ValueError if aspect ratio <= 5, i.e. not narrow AssertionError if step_size <= 12.7 over dpi, i.e. small """ image_file = PIL.Image.open(file_name) assert image_file.mode == "RGB" dpi_horiz, dpi_vert = image_file.info["dpi"] image_array = mpimg.imread(file_name) # DIMENSIONS TO AVG ACROSS DIFFERENT FOR HORIZ VS VERT IMG if image_array.shape[0] > 5 * image_array.shape[1]: # VERT image_vector = np.average(image_array, axis=(1, 2)) pixel_size_in_cm = 2.54 / dpi_vert elif image_array.shape[1] > 5 * image_array.shape[0]: # HORIZ image_vector = np.average(image_array, axis=(0, 2)) pixel_size_in_cm = 2.54 / dpi_horiz else: raise ValueError("The PNG file is not a narrow strip.") assert step_size > 5 * pixel_size_in_cm, "step size too small" if image_vector.shape[0] % 2 == 0: image_vector = image_vector[:-1] # SO ZERO DISTANCE IS MID-PIXEL length_in_cm = image_vector.shape[0] * pixel_size_in_cm full_resolution_distances = np.arange(-length_in_cm / 2, length_in_cm / 2, pixel_size_in_cm) # TO MOVE FROM FILM RESOLUTION TO DESIRED PROFILE RESOLUTION num_pixels_to_avg_over = int(step_size / pixel_size_in_cm) sample_indices = np.arange( num_pixels_to_avg_over / 2, len(full_resolution_distances), num_pixels_to_avg_over, ).astype(int) downsampled_distances = list(full_resolution_distances[sample_indices]) downsampled_density = [] for idx in sample_indices: # AVERAGE OVER THE SAMPLING WINDOW avg_density = np.average( image_vector[int(idx - num_pixels_to_avg_over / 2):int(idx + num_pixels_to_avg_over / 2)]) downsampled_density.append(avg_density) zipped_profile = list(zip(downsampled_distances, downsampled_density)) return Profile().from_tuples(zipped_profile)