Cookbook
Complete scripts for common tasks. Change the paths, and each script runs as it is. The tutorials explain single topics in more detail.
Find a Recipe
| Task | Recipe |
|---|---|
| Images without mask files, for example lesion exports with -2048 outside the lesion | 1. Images without masks |
| DICOM cases with a DICOM SEG or RTSTRUCT | 2. DICOM cases with a segmentation |
| Many mask files for one image | 3. Many masks for one image |
| Each segment of a SEG, an RTSTRUCT or a label map as its own ROI | 4. Each segment as its own ROI |
| Features of filtered images (IBSI 2) | 5. Filtered radiomics |
| Many discretisations of one image | 6. Many discretisations |
| A data dictionary of the features | 7. A data dictionary |
| The fat or tissue around an ROI | Rings Around an ROI |
| The phases of a cardiac CT | Cardiac CT Phases |
| PET images in SUV | PET in SUV |
| MR images | MR Radiomics with Normalisation |
| Quality images of the masks | Utilities |
| A study from a DICOM archive: find the series, run the cases, join the clinical data | From a DICOM Archive to a Study Table |
| Masks from nnU-Net, TotalSegmentator, 3D Slicer, ITK-SNAP or a treatment planning system | Masks from Other Tools |
Common Mistakes
- No
config_names:RadiomicsPipeline()holds the six standard configurations.run()withoutconfig_namesruns them all, andsave_configs()withoutconfig_namessaves them all. Giveconfig_names, or start withRadiomicsPipeline(load_standard=False). - FBS without a start: an FBS
discretisestep needsmin_val, or aresegmentstep withrange_minbefore it. A filter or anormalisestep cancels that start: after them, givemin_val. roi_onlywith a sentinel value:source_mode="roi_only"does not usesentinel_value. For a known padding value, usesource_mode="auto", sentinel_value=-2048.- A mask
Imageon another grid:run()places a mask path on the grid of the image, but anImagemask must already have that grid. Load a mask withload_image(path, reference_image=image), or give the path torun(). - Label masks: each voxel that is not 0 is in the ROI. For one label, add a
binarize_maskstep. For each label, userun_rois(recipe 4). - Morphology after a filter: the integrated intensity (99N0) and the centre of mass shift (KLMA) read the image, so after a filter they read the response map. Compute the morphology in a configuration without the filter (recipe 5).
- A name that exists:
add_configwith the name of a configuration in the pipeline replaces that configuration. Do not use the names of the standard configurations, such asstandard_fbn_32, for your own. - Workers without a guard: keep
run_batch(..., workers=4)inif __name__ == "__main__":. The workers import your script again. - Your own process pool on Linux: make it with
multiprocessing.get_context("spawn"). A worker made by fork or forkserver (the Linux defaults) stops at its first numba kernel, with "Terminating: fork() called from a process already using GNU OpenMP".run_batchuses spawn itself.
1. Images Without Masks
The task: a folder of NIfTI lesion exports. Each file holds a CT of one lesion, with -2048 outside the lesion, and there is no mask file. The study needs the intensity and shape features, and the texture features of four discretisations.
from pathlib import Path
from pictologics import RadiomicsPipeline, save_results
STEPS = [
{"step": "resample", "params": {"new_spacing": (0.5, 0.5, 0.5), "round_intensities": True}},
{"step": "resegment", "params": {"range_min": -100, "range_max": 3000, "apply_to": "both"}}, # HU: the segmentation
{"step": "keep_largest_component", "params": {}},
]
DISCRETISATIONS = {
"fbn_8": {"method": "FBN", "n_bins": 8},
"fbn_16": {"method": "FBN", "n_bins": 16},
"fbs_8": {"method": "FBS", "bin_width": 8}, # bins from -100 HU, the resegment start
"fbs_16": {"method": "FBS", "bin_width": 16},
}
def build_pipeline():
pipeline = RadiomicsPipeline(load_standard=False)
padding = {"source_mode": "auto", "sentinel_value": -2048} # the value outside the lesion
pipeline.add_config("orig", STEPS + [
{"step": "extract_features", "params": {"families": ["intensity", "morphology"]}},
], **padding)
for name, discretise in DISCRETISATIONS.items():
pipeline.add_config(name, STEPS + [
{"step": "discretise", "params": discretise},
{"step": "extract_features", "params": {"families": ["texture", "histogram", "ivh"]}},
], **padding)
return pipeline
if __name__ == "__main__":
pipeline = build_pipeline()
cases = [
{"subject_id": path.name.removesuffix(".gz").removesuffix(".nii"), "image": str(path)}
for path in sorted(Path("lesion_exports").glob("*.nii*"))
]
table = pipeline.run_batch(cases, "results", workers=2)
print(table.loc[table["status"] != "completed", ["subject_id", "status", "error"]])
save_results(table, "results/features.csv")
- No mask: a case without
maskuses the whole image as the ROI. Theresamplestep takes the voxels of -2048 out of the ROI (source_mode="auto"withsentinel_value), theresegmentstep keeps -100 to 3000 HU in both masks (apply_to: both, because the range is the segmentation here), andkeep_largest_componentkeeps the lesion only. So the shape features describe the lesion. - A compact table: the
origconfiguration gives the intensity and morphology features. The other four give only the features that read the discretisation. So the table has each feature one time, for exampleorig__mean_intensity_Q4LEandfbn_8__joint_entropy_TU9B. - Shared work: the five configurations start with the same three steps, so the steps run one time for each image.
- An unknown padding value: leave out
sentinel_value. The pipeline then finds the value of each image, with a warning. - The result files:
results/cases/<subject_id>.jsonholds the features and the log of each case. A second run skips the cases that are done.
2. DICOM Cases with a Segmentation
The task: one folder for each case, with a DICOM image series and its DICOM SEG (or RTSTRUCT) file, each at any depth:
from pathlib import Path
from pictologics import RadiomicsPipeline, save_results
EXTRACT = {"step": "extract_features", "params": {"families": ["intensity", "morphology", "texture", "histogram", "ivh"]}}
def segmentation_file(folder):
"""The one DICOM file of a segmentation folder, at any depth."""
files = sorted(Path(folder).rglob("*.dcm"))
if len(files) != 1:
raise ValueError(f"{folder} holds {len(files)} DICOM files, not 1")
return str(files[0])
if __name__ == "__main__":
pipeline = RadiomicsPipeline(load_standard=False)
for name, discretise in {
"fbs_25": {"method": "FBS", "bin_width": 25, "min_val": -1000},
"fbn_64": {"method": "FBN", "n_bins": 64},
}.items():
pipeline.add_config(name, [
{"step": "resample", "params": {"new_spacing": (1.0, 1.0, 1.0)}},
{"step": "discretise", "params": discretise},
EXTRACT,
])
cases = [
{
"subject_id": case.name,
"image": str(case / "Image"),
"image_options": {"recursive": True}, # the series at any depth
"mask": segmentation_file(case / "Segmentation"),
}
for case in sorted(Path("cases").iterdir())
if case.is_dir()
]
table = pipeline.run_batch(cases, "results", workers=4)
save_results(table, "results/features.csv")
# The long format: one row for each feature
features = [column for column in table.columns if "__" in column]
long = table.melt(id_vars=["subject_id"], value_vars=features, var_name="column", value_name="value")
long[["config", "feature_key"]] = long["column"].str.split("__", n=1, expand=True)
save_results(long.drop(columns="column"), "results/features_long.csv")
- The mask: the pipeline loads the SEG or the RTSTRUCT onto the grid of the image. All segments (or ROIs) of the file together make the ROI, because each voxel that is not 0 is in the ROI. For each segment on its own, see recipe 4.
- Phases: for a series with more than one phase (for example a cardiac CT), give the phase in
image_options, for example{"recursive": True, "dataset_index": 4}.get_dicom_phases(folder, recursive=True)lists the phases (see Cardiac CT Phases). - Offsets: a mask origin a small part of a voxel off the image grid gives a warning, and the mask snaps to the nearest voxel. Give
mask_subvoxel_warning_thresholdin a case to change the limit (see Sub-Voxel Alignment).
More than one SEG file in a case: merge the files into one mask file first, and give that file as the mask of the case.
from pictologics import load_and_merge_images, load_image, save_image
for case in sorted(Path("cases").iterdir()):
image = load_image(str(case / "Image"), recursive=True)
seg_files = sorted((case / "Segmentation").rglob("*.dcm"))
mask = load_and_merge_images(seg_files, reference_image=image, reposition_to_reference=True, binarize=True)
save_image(mask, Path("merged_masks") / f"{case.name}.nii.gz")
load_and_merge_images takes SEG files and other image files, but not RTSTRUCT files. For an RTSTRUCT, use load_rtstruct (see Data Loading).
3. Many Masks for One Image
The task: one folder holds the images and their masks as NIfTI files: CASE001_IMG.nii.gz, and CASE001_MASK1.nii.gz, CASE001_MASK2.nii.gz and more for each image. The masks of an image together make one ROI. The study needs the features of six discretisations, without other steps, as long-format JSON.
from pathlib import Path
from pictologics import RadiomicsPipeline, format_results, load_and_merge_images, load_image, save_results
EXTRACT = {"step": "extract_features", "params": {"families": ["intensity", "morphology", "texture", "histogram", "ivh"]}}
pipeline = RadiomicsPipeline(load_standard=False)
for n_bins in (8, 16, 32):
pipeline.add_config(f"fbn_{n_bins}", [{"step": "discretise", "params": {"method": "FBN", "n_bins": n_bins}}, EXTRACT])
for bin_width in (8, 16, 32):
pipeline.add_config(f"fbs_{bin_width}", [
{"step": "discretise", "params": {"method": "FBS", "bin_width": bin_width, "min_val": -1000}}, # from air
EXTRACT,
])
folder = Path("nifti_folder")
tables = []
for image_path in sorted(folder.glob("*_IMG.nii*")):
subject_id = image_path.name.split("_IMG")[0]
mask_paths = sorted(folder.glob(f"{subject_id}_MASK*.nii*"))
if not mask_paths:
raise ValueError(f"No masks for {subject_id}")
image = load_image(str(image_path))
mask = load_and_merge_images(mask_paths, reference_image=image, reposition_to_reference=True, binarize=True)
results = pipeline.run(image, mask, subject_id=subject_id, config_names=pipeline.list_configs())
tables.append(format_results(results, fmt="long", meta={"subject_id": subject_id}, output_type="pandas"))
pipeline.clear_log()
save_results(tables, "results/features_long.json")
binarize=Truemakes each mask 0 and 1 before the merge, so the merged mask is one ROI. Withrelabel_masks=Truein place ofbinarize, each file gets its own label (1, 2, 3, ...), forrun_rois.- The six configurations differ only in the discretisation, so the intensity and morphology features are computed one time and copied (see recipe 6).
- The JSON file holds a list of rows:
subject_id,config,feature_keyandvalue, withnullforNaN.
4. Each Segment as Its Own ROI
The task: each case holds a DICOM series and a SEG file with segments, for example the liver, the spleen and the kidneys. The study needs a row for each segment.
from pathlib import Path
from pictologics import RadiomicsPipeline, save_results
from pictologics.loaders import get_segment_info
if __name__ == "__main__":
pipeline = RadiomicsPipeline(load_standard=False)
pipeline.add_config("fbn_32", [
{"step": "resample", "params": {"new_spacing": (1.0, 1.0, 1.0)}},
{"step": "discretise", "params": {"method": "FBN", "n_bins": 32}},
{"step": "extract_features", "params": {"families": ["intensity", "morphology", "texture", "histogram"]}},
])
cases = []
for case in sorted(Path("cases").iterdir()):
seg_file = str(case / "segmentation.dcm")
labels = {s["segment_label"]: s["segment_number"] for s in get_segment_info(seg_file)}
cases.append({
"subject_id": case.name,
"image": str(case / "Image"),
"image_options": {"recursive": True},
"rois": seg_file, # a label map: each segment has its number
"labels": labels, # the ROI names in the table
})
table = pipeline.run_batch(cases, "results", workers=4)
save_results(table, "results/features_by_segment.csv") # one row for each segment
- The label map: the pipeline loads the SEG as one label image, in which each voxel holds the number of its segment.
run_rois(here throughrun_batch) gives each segment the features ofrun()with a mask of that segment alone, and it is much faster than one run for each segment. - The table: one row for each segment, with its name in the column
roi. The names inlabelsmust differ. - An RTSTRUCT: give its path as
rois.get_segment_infogives its ROI names and numbers in the same form. - A label map file: give a NIfTI label map as
rois, withlabels={"liver": 1, "spleen": 2}(or nolabelsfor all labels).
Overlapping segments (for example a tumour inside an organ) do not fit in one label image. Load each segment as its own mask, and run each:
from pictologics import format_results, load_image, load_seg
image = load_image("cases/p001/Image", recursive=True)
masks = load_seg("cases/p001/segmentation.dcm", reference_image=image, combine_segments=False) # {number: mask}
names = {s["segment_number"]: s["segment_label"] for s in get_segment_info("cases/p001/segmentation.dcm")}
rows = [
format_results(pipeline.run(image, mask, config_names=["fbn_32"]), meta={"subject_id": "p001", "roi": names[number]})
for number, mask in masks.items()
]
save_results(rows, "results/p001_segments.csv")
5. Filtered Radiomics
The task: the features of IBSI 2 filter response maps of a CT: LoG at two scales, a Laws energy map, a Gabor filter and a wavelet, with the preprocessing of IBSI 2 Phase 2 (configuration B).
import math
from pictologics import RadiomicsPipeline, format_results, save_results
PREPROCESS = [ # IBSI 2 Phase 2, configuration B
{"step": "resample", "params": {
"new_spacing": (1.0, 1.0, 1.0), "interpolation": "cubic", "mask_interpolation": "linear", "mask_threshold": 0.5,
}},
{"step": "round_intensities", "params": {}},
{"step": "resegment", "params": {"range_min": -1000, "range_max": 400}}, # HU, before the filters
]
FILTERS = {
"log_1.5": {"type": "log", "sigma_mm": 1.5},
"log_3": {"type": "log", "sigma_mm": 3.0},
"laws_e5": {"type": "laws", "kernel": "L5E5E5", "rotation_invariant": True, "pooling": "max",
"compute_energy": True, "energy_distance": 7},
"gabor": {"type": "gabor", "sigma_mm": 5.0, "lambda_mm": 2.0, "gamma": 1.5, "rotation_invariant": True,
"delta_theta": math.pi / 4, "pooling": "average"},
"wavelet_hhh": {"type": "wavelet", "wavelet": "db3", "level": 1, "decomposition": "HHH",
"rotation_invariant": True, "pooling": "average"},
}
pipeline = RadiomicsPipeline(load_standard=False)
pipeline.add_config("orig", PREPROCESS + [
{"step": "extract_features", "params": {"families": ["intensity", "morphology"]}},
])
for name, params in FILTERS.items():
pipeline.add_config(name, PREPROCESS + [
{"step": "filter", "params": params},
{"step": "extract_features", "params": {"families": ["intensity"]}},
])
pipeline.add_config("log_1.5_fbn_32", PREPROCESS + [ # texture of a response map
{"step": "filter", "params": FILTERS["log_1.5"]},
{"step": "discretise", "params": {"method": "FBN", "n_bins": 32}},
{"step": "extract_features", "params": {"families": ["texture", "histogram"]}},
])
results = pipeline.run("ct.nii.gz", "gtv.nii.gz", subject_id="p001", config_names=pipeline.list_configs())
save_results([format_results(results, meta={"subject_id": "p001"})], "filtered_features.csv")
print(results["log_1.5"]["mean_intensity_Q4LE"])
- The resegment step comes before the filters, so it selects the voxels by HU.
- Morphology comes from the
origconfiguration, without a filter (see Common Mistakes, item 6). - Texture of a response map: use FBN, or FBS with
min_val, because the filter cancels the FBS start of theresegmentstep. - Shared work: the configurations start with the same three steps, so the steps run one time. The filters compute only the region around the ROI.
- The log of each filter step records the requested and the effective parameters and boundary. For the Gabor filter, report the
response(default"modulus"). See Image Filtering.
6. Many Discretisations
The task: the features of six discretisations of one image, with the same preprocessing. The pipeline computes the features that do not read the discretisation one time, and copies them.
from pictologics import RadiomicsPipeline
PREPROCESS = [
{"step": "resample", "params": {"new_spacing": (1.0, 1.0, 1.0)}},
{"step": "resegment", "params": {"range_min": -100, "range_max": 3000}},
{"step": "filter_outliers", "params": {"sigma": 3.0}},
{"step": "round_intensities", "params": {}},
{"step": "keep_largest_component", "params": {}},
]
EXTRACT = {"step": "extract_features", "params": {"families": ["intensity", "morphology", "texture", "histogram", "ivh"]}}
pipeline = RadiomicsPipeline(load_standard=False) # shared work is on by default
for n_bins in (8, 16, 32):
pipeline.add_config(f"fbn_{n_bins}", PREPROCESS + [{"step": "discretise", "params": {"method": "FBN", "n_bins": n_bins}}, EXTRACT])
for bin_width in (8, 16, 32):
pipeline.add_config(f"fbs_{bin_width}", PREPROCESS + [{"step": "discretise", "params": {"method": "FBS", "bin_width": bin_width}}, EXTRACT])
results = pipeline.run("ct.nii.gz", "lesion.nii.gz", config_names=pipeline.list_configs())
print(pipeline.deduplication_stats)
# {'reused_families': 10, 'computed_families': 20, 'cache_hit_rate': 0.333...}
- The counts: the morphology and the intensity families are computed one time and copied to the other five configurations (10 copies). The texture, histogram and IVH families read the discretisation, so they are computed for each configuration (18), with the morphology and intensity families of the first (2): 20 in all.
- The results: each configuration has all its features. The copied values are the same as computed values.
- The preprocessing: the five steps before the
discretisestep run one time. - A compact table: to have each intensity and morphology feature one time in the table, put them in their own configuration, as in recipe 1.
- Settings:
RadiomicsPipeline(deduplicate=False)computes each family in each configuration, for example to measure the time of each.RadiomicsPipeline(deduplication_rules="1.1.0")pins the rules.save_configswrites these settings, andload_configsreads them.
7. A Data Dictionary
The task: a table of all features of the configurations, before the run, for a study protocol.
from pictologics import RadiomicsPipeline
pipeline = RadiomicsPipeline()
catalog = pipeline.describe_features()
catalog.to_csv("feature_catalog.csv", index=False)
print(catalog.groupby("config").size()) # the number of features of each configuration
texture_fbn = catalog[(catalog["family_group"] == "Texture") & (catalog["discretisation_method"] == "FBN")]
print(texture_fbn[["config", "feature_name", "ibsi_code"]].head())
print(catalog[["family", "requires_discretisation"]].drop_duplicates().sort_values("family"))
See The Feature Catalog for all columns.