From e167679de2617a0603478423a395a585f53ba70b Mon Sep 17 00:00:00 2001 From: Tamino Huxohl <thuxohl@techfak.uni-bielefeld.de> Date: Thu, 17 Nov 2022 16:07:18 +0100 Subject: [PATCH] add script for reconstruction --- mu_map/recon/recon.py | 228 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 mu_map/recon/recon.py diff --git a/mu_map/recon/recon.py b/mu_map/recon/recon.py new file mode 100644 index 0000000..1d48d6b --- /dev/null +++ b/mu_map/recon/recon.py @@ -0,0 +1,228 @@ +import os +from typing import Dict, Optional, Tuple +import tempfile + +import numpy as np +import stir + +from mu_map.file import load_as_interfile +from mu_map.file.interfile import ( + parse_interfile_header_str, + load_interfile, + write_interfile, + KEY_DIM_1, + KEY_DIM_2, + KEY_SPACING_1, + KEY_SPACING_2, + TEMPLATE_HEADER_IMAGE, +) + + +TEMPLATE_RECON_PARAMS = """ +OSMAPOSLParameters := + + objective function type:= PoissonLogLikelihoodWithLinearModelForMeanAndProjData + PoissonLogLikelihoodWithLinearModelForMeanAndProjData Parameters:= + + input file := {PROJECTION} + + projector pair type := Matrix + Projector Pair Using Matrix Parameters := + Matrix type := SPECT UB + Projection Matrix By Bin SPECT UB Parameters:= + ; width of PSF + maximum number of sigmas:= 2.0 + + ;PSF type of correction { 2D // 3D // Geometrical } + psf type:= Geometrical + ; next 2 parameters define the PSF. They are ignored if psf_type is "Geometrical" + ; These values are mostly dependent on your collimator. + ; the PSF is modelled as a Gaussian with sigma dependent on the distance from the collimator + ; sigma_at_depth = collimator_slope * depth_in_cm + collimator sigma 0(cm) + collimator slope := 0.0163 + collimator sigma 0(cm) := 0.1466 + + ;Attenuation correction { Simple // Full // No } + attenuation type := {ATTENUATION_TYPE} + ;Values in attenuation map in cm-1 + attenuation map := {ATTENUATION_MAP} + + ;Mask properties { Cylinder // Attenuation Map // Explicit Mask // No} + mask type := {MASK_TYPE} + mask file := {MASK_FILE} + + ; if next variable is set to 0, only a single view is kept in memory + keep all views in cache:=1 + + End Projection Matrix By Bin SPECT UB Parameters:= + End Projector Pair Using Matrix Parameters := + end PoissonLogLikelihoodWithLinearModelForMeanAndProjData Parameters:= + + initial estimate := {INIT_FILE} + output filename prefix := {OUTPUT_PREFIX} + + number of subsets:= {N_SUBSETS} + number of subiterations:= {N_SUBITERATIONS} + Save estimates at subiteration intervals:= {SAVE_INTERVALS} + + ; keywords that specify the filtering that occurs after every subiteration + ; warning: do not normally use together with a prior + ;inter-iteration filter subiteration interval := 4 + ;inter-iteration filter type := Separable Gaussian + post-filter type := Separable Gaussian + separable gaussian filter parameters := + x-dir filter fwhm (in mm) := 6 + y-dir filter fwhm (in mm) := 6 + z-dir filter fwhm (in mm) := 6 + x-dir maximum kernel size := 129 + y-dir maximum kernel size := 129 + z-dir maximum kernel size := 31 + Normalise filter to 1 := 1 + end separable gaussian filter parameters := + +END := +""" + + +def uniform_estimate(projection: Tuple[Dict[str, str], np.ndarray]): + header_proj, image_proj = projection + + image = np.ones( + (image_proj.shape[1], image_proj.shape[2], image_proj.shape[2]), np.float32 + ) + + offset = -0.5 * image_proj.shape[2] * float(header_proj[KEY_SPACING_1]) + header = TEMPLATE_HEADER_IMAGE.replace("{ROWS}", str(image.shape[2])) + header = header.replace("{COLUMNS}", str(image.shape[1])) + header = header.replace("{SLICES}", str(image.shape[0])) + header = header.replace("{SPACING_X}", header_proj[KEY_SPACING_1]) + header = header.replace("{SPACING_Y}", header_proj[KEY_SPACING_1]) + header = header.replace("{SPACING_Z}", header_proj[KEY_SPACING_2]) + header = header.replace("{OFFSET_X}", f"{offset:.4f}") + header = header.replace("{OFFSET_Y}", f"{offset:.4f}") + header = parse_interfile_header_str(header) + + return header, image + + +def reconstruct( + projection: Tuple[Dict[str, str], np.ndarray], + mu_map: Optional[Tuple[Dict[str, str], np.ndarray]] = None, + mask: Optional[Tuple[Dict[str, str], np.ndarray]] = None, + init: Optional[Tuple[Dict[str, str], np.ndarray]] = None, + n_subsets: Optional[int] = 4, + n_iterations: Optional[int] = 10, +): + # sanitize parameters + n_subiterations = n_subsets * n_iterations + save_intervals = n_subiterations + + dir_tmp = tempfile.TemporaryDirectory() + filename_projection = os.path.join(dir_tmp.name, "projection.hv") + write_interfile(filename_projection, *projection) + params = TEMPLATE_RECON_PARAMS.replace("{PROJECTION}", filename_projection) + + output_prefix = os.path.join(dir_tmp.name, "out") + filename_out = f"{output_prefix}_{save_intervals}.hv" + params = params.replace("{OUTPUT_PREFIX}", output_prefix) + params = params.replace("{N_SUBSETS}", str(n_subsets)) + params = params.replace("{N_SUBITERATIONS}", str(n_subiterations)) + params = params.replace("{SAVE_INTERVALS}", str(save_intervals)) + + if mu_map is not None: + filename_mu_map = os.path.join(dir_tmp.name, "mu_map.hv") + write_interfile(filename_mu_map, *mu_map) + params = params.replace("{ATTENUATION_TYPE}", "Full") + params = params.replace("{ATTENUATION_MAP}", filename_mu_map) + else: + params = params.replace("{ATTENUATION_TYPE}", "No") + params = params.replace("attenuation map", ";attenuation map") + + if mask is not None: + filename_mask = os.path.join(dir_tmp.name, "mask.hv") + write_interfile(filename_mask, *mask) + params = params.replace("{MASK_TYPE}", "Explicit Mask") + params = params.replace("{MASK_FILE}", filename_mask) + else: + params = params.replace("mask file", ";mask file") + params = params.replace( + "{MASK_TYPE}", "Attenuation Map" if mu_map is not None else "No" + ) + + init = uniform_estimate(projection) if init is None else init + filename_init = os.path.join(dir_tmp.name, "init.hv") + write_interfile(filename_init, *init) + params = params.replace("{INIT_FILE}", filename_init) + + filename_params = os.path.join(dir_tmp.name, "OSEM_SPECT.par") + with open(filename_params, mode="w") as f: + f.write(params.strip()) + + recon = stir.OSMAPOSLReconstruction3DFloat(filename_params) + recon.reconstruct() + + print(params) + return load_interfile(filename_out) + + +if __name__ == "__main__": + import argparse + + from mu_map.file import load_as_interfile + from mu_map.recon.project import forward_project + + parser = argparse.ArgumentParser( + description="Reconstruct a projection or another reconstruction", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--projection", type=str, help="a projection in INTERFILE format" + ) + parser.add_argument( + "--recon", + type=str, + help="a reconstruction in DICOM or INTERFILE format - if specified the projection will be overwritten", + ) + parser.add_argument( + "--mu_map", + type=str, + help="a mu map for attenuation correction in DICOM or INTERFILE format", + ) + parser.add_argument( + "--out", type=str, help="the filename to store the reconstruction" + ) + parser.add_argument( + "--n_subsets", + type=int, + default=4, + help="the number of subsets for OSEM reconstruction", + ) + parser.add_argument( + "--n_iterations", + type=int, + default=10, + help="the number of iterations for OSEM reconstruction", + ) + parser.add_argument( + "-v", "--verbosity", type=int, default=0, help="configure the verbosity of STIR" + ) + + args = parser.parse_args() + assert ( + args.projection is not None or args.recon is not None + ), "You have to specify either a projection or a reconstruction" + stir.Verbosity_set(args.verbosity) + + mu_map = load_as_interfile(args.mu_map) if args.mu_map else None + mu_map_slices = None if mu_map is None else mu_map[1].shape[0] + + if args.recon: + recon = load_as_interfile(args.recon) + projection = forward_project(*recon, n_slices=mu_map_slices) + else: + projection = load_as_interfile(args.projection) + + header, image = reconstruct(projection, mu_map=mu_map) + image = image[:, :, ::-1] + + write_interfile(args.out, header, image) -- GitLab