Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add image cropping preprocessing #119

Merged
merged 71 commits into from
Feb 12, 2025
Merged
Show file tree
Hide file tree
Changes from 67 commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
7cd7cf1
refactor: make process code dir, move label, select, and split to it
strixy16 Jan 17, 2025
4cd15e1
style: remove whitespace for ruff check
strixy16 Jan 17, 2025
e772dd0
style: make docstrings imperative, add return type annotations
strixy16 Jan 17, 2025
c45ba60
style: docstring spacing for ruff
strixy16 Jan 17, 2025
21eaee7
refactor: change event column value mapping to use dictionary compreh…
strixy16 Jan 17, 2025
932b713
style: fixes for ruff config, whitespace, imperative format, etc.
strixy16 Jan 17, 2025
6f75b7d
refactor: in selectByColumnValue, update error handling to include lo…
strixy16 Jan 17, 2025
6e27c2e
refactor/style: ruff config fixes
strixy16 Jan 17, 2025
97881c3
feat: add readii logger for errors
strixy16 Jan 17, 2025
264f888
refactor: update dictionary iteration in replaceColumnValues
strixy16 Jan 17, 2025
3e1cb34
refactor: update dictionary iteration for split_col_data in splitData…
strixy16 Jan 17, 2025
86b5481
feat: set up init file for process directory
strixy16 Jan 17, 2025
38c38c8
feat: add process to the ruff config
strixy16 Jan 17, 2025
727cd1a
build: latest pixi lock
strixy16 Jan 17, 2025
ee091ed
feat: add resize/resampling function
strixy16 Jan 17, 2025
0ab0eb7
feat: add dataclasses to use for bounding box stuff in crop
strixy16 Jan 17, 2025
f892474
feat: add findBoundingBox function
strixy16 Jan 17, 2025
4c283d0
feat/docs: add findCentroid function, add comments to findBoundingBox
strixy16 Jan 17, 2025
d61e1f4
feat: add cropToCentroid function and validateNewDimensions function …
strixy16 Jan 17, 2025
75f1adb
refactor: use validateNewDimensions in resizeImage
strixy16 Jan 17, 2025
e72bc45
docs: add docstrings and return types for ruff
strixy16 Jan 20, 2025
7e56d35
refactor: remove new code from this PR
strixy16 Jan 20, 2025
435da76
feat: add cropping methods from FMCIB pipeline
strixy16 Jan 20, 2025
5a6fe99
style: sort imports for ruff
strixy16 Jan 20, 2025
857defe
feat: add init file for process/images
strixy16 Jan 20, 2025
2ce580f
feat: add Bounding Box class for eventual use in crop methods
strixy16 Jan 20, 2025
1f02329
feat: add pyradiomics cropping and crop the mask in all crop methods …
strixy16 Jan 20, 2025
1f5fd98
fix: apply fixes for instance checking, order of coordinates in crop …
strixy16 Jan 20, 2025
4b9b11c
test: start test functions for crop, have test for crop image to mask…
strixy16 Jan 20, 2025
23a1c34
fix: add error handling for invalid crop methods in crop_image_to_mas…
jjjermiah Jan 21, 2025
b1e03b6
test: add some parameterized tests for quick testing of the find - bo…
jjjermiah Jan 21, 2025
596b497
refactor: replace resize_image with resize function from med-imagetools
strixy16 Jan 21, 2025
bcb2fba
fix: fix return types and input bounding box types to be tuples consi…
strixy16 Jan 21, 2025
107ded7
refactor: add None type for mask_label in pyradiomics crop
strixy16 Jan 21, 2025
0e0ac80
feat: make resize_dimensions optional in crop_image_to_mask since pyr…
strixy16 Jan 21, 2025
b934f0f
Merge remote-tracking branch 'origin/main' into katys/feature/add-fmc…
strixy16 Jan 21, 2025
3edf4c4
fix: fixed ordering of bounding box tuple so it is consistent throughout
strixy16 Jan 21, 2025
2f23865
feat: add check for resize_dimensions existence in crop_image_to_mask
strixy16 Jan 21, 2025
4630fac
refactor: replace is not with !=
strixy16 Jan 21, 2025
2b870f9
fix: fix bounding box case name
strixy16 Jan 21, 2025
267ebd6
refactor: change is not to is None
strixy16 Jan 21, 2025
eca1bf3
feat: make variable for current image dimensions
strixy16 Jan 21, 2025
11c8468
refactor: replace tuple of individual coordinate values with Coordina…
strixy16 Jan 22, 2025
2fec2fe
feat: when a Size3D object is added to a Coordinate, it returns anoth…
strixy16 Jan 22, 2025
71f456c
refactor: change centroid from tuple to Centroid object
strixy16 Jan 22, 2025
b8419cf
feat: add subtraction function to Coordinate
strixy16 Jan 22, 2025
b30edc8
fix: update bounding box crop_method spelling
strixy16 Jan 22, 2025
fd0f294
refactor: change donut and square mask colors to match negative contr…
strixy16 Feb 5, 2025
3b367d0
build: latest pixi update
strixy16 Feb 5, 2025
ee6eac3
feat: notebook for developing crop section for FMCIB addition
strixy16 Feb 5, 2025
77284db
refactor: migrate to using med-imagetools crop methods and classes
strixy16 Feb 7, 2025
0b5b262
build: latest pixi update
strixy16 Feb 7, 2025
08d66dc
refactor: clean up main example output
strixy16 Feb 10, 2025
ce5341b
build: update to latest med-imagetools
strixy16 Feb 10, 2025
6e588ac
feat: remove bounding box dataclasses
strixy16 Feb 10, 2025
3c6e285
refactor: update init with existing function, remove old
strixy16 Feb 10, 2025
c8691f9
refactor: ruff fixes
strixy16 Feb 10, 2025
1c99b12
feat: add label existince checking for crop
strixy16 Feb 10, 2025
1d2561b
test: update to crop testing for new function
strixy16 Feb 10, 2025
7a0d5dc
chore: trying to figure out mask resize/resampling handling when load…
strixy16 Feb 10, 2025
3e1f5a9
feat: add step to cast the resized mask image from float to int, use …
strixy16 Feb 11, 2025
50dc234
feat: figuring out crop and resizing image and masks
strixy16 Feb 11, 2025
8529ecc
feat: add axes input and return for displayImageSlice
strixy16 Feb 11, 2025
82c6245
style: remove blank line after docstring
strixy16 Feb 11, 2025
1076261
test: add test conditions for scaling up and down
strixy16 Feb 11, 2025
5bf8a6d
build: latest pixi update
strixy16 Feb 11, 2025
1b0d4d4
fix: remove pyradiomics from CropMethods
strixy16 Feb 11, 2025
2f3bdf0
Merge branch 'main' into katys/feature/add-fmcib-cropping-code
strixy16 Feb 11, 2025
511cffd
build: fix pixi lock
strixy16 Feb 11, 2025
525c5bc
refactor: update ruff config to allow io module
strixy16 Feb 12, 2025
1b62c0e
refactor: change == None to is None
strixy16 Feb 12, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
397 changes: 397 additions & 0 deletions notebooks/crop_testing.ipynb

Large diffs are not rendered by default.

17 changes: 10 additions & 7 deletions notebooks/viz_neg_controls.ipynb

Large diffs are not rendered by default.

5,611 changes: 2,686 additions & 2,925 deletions pixi.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "readii"
version = "1.34.1"
version = "1.34.3"
description = "A package to extract radiomic features!"
authors = [{ name = "Katy Scott", email = "[email protected]" }]

Expand Down
16 changes: 12 additions & 4 deletions src/readii/image_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,9 @@ def displayImageSlice(
sliceDim:Optional[str]="first",
cmap=plt.cm.Greys_r,
dispMin:Optional[int]=None,
dispMax:Optional[int]=None
) -> None:
dispMax:Optional[int]=None,
ax:Optional[plt.Axes]=None
) -> plt.Axes:
"""Function to display a 2D slice from a 3D image
By default, displays slice in greyscale with min and max range set to min and max value in the slice.

Expand All @@ -179,6 +180,8 @@ def displayImageSlice(
Value to use as min for cmap in display
dispMax : int
Value to use as max for cmap in display
ax : plt.Axes
Axis to plot the slice on. If None, will create a new axis.
"""
# If image is a simple ITK image, convert to array for display
if type(image) == sitk.Image:
Expand All @@ -198,10 +201,15 @@ def displayImageSlice(
else:
raise ValueError("sliceDim must be either 'first' or 'last'")

if ax is None:
# Create a new axis
fig, ax = plt.subplots()

# Display the slice of the image
plt.imshow(dispSlice, cmap=cmap, vmin=dispMin, vmax=dispMax)
plt.axis("off")
ax.imshow(dispSlice, cmap=cmap, vmin=dispMin, vmax=dispMax)
ax.axis("off")

return ax

def displayCTSegOverlay(
ctImage,
Expand Down
7 changes: 7 additions & 0 deletions src/readii/process/images/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Module for processing and manipulating images."""

from .crop import crop_and_resize_image_and_mask

__all__ = [
"crop_and_resize_image_and_mask",
]
126 changes: 126 additions & 0 deletions src/readii/process/images/crop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
from typing import Literal

import SimpleITK as sitk
from imgtools.coretypes.box import RegionBox
from imgtools.ops.functional import resize

from readii.utils import logger

CropMethods = Literal["bounding_box", "centroid", "cube"]


def crop_and_resize_image_and_mask(image: sitk.Image,
mask: sitk.Image,
label: int = 1,
crop_method: CropMethods = "cube",
resize_dimension: int | None = None
) -> tuple[sitk.Image, sitk.Image]:
"""Crop an image and mask to an ROI in the mask and resize to a specified crop dimensions.

Parameters
----------
image : sitk.Image
Image to crop.
mask : sitk.Image
Mask to crop the image to. Will also be cropped.
label : int, default 1
Voxel value of the region of interest to crop to in the mask. Set to 1 by default.
crop_method : str, default "cube"
Method to use to crop the image to the mask. Must be one of "bounding_box", "centroid", "cube".
resize_dimensions : int, optional
Dimension to resize the image to. Will apply this value in all dimensions, so result will be a cube.

Returns
-------
cropped_image : sitk.Image
Cropped image.
cropped_mask : sitk.Image
Cropped mask.

Notes
-----
The bounding box is generated as a `RegionBox` object from `med-imagetools`.

For the `centroid` method, `resize_dimension` is used to generate a cube around the centroid of the mask.
If `resize_dimension` is not provided, it defaults to 50 voxels.

For the `cube` method, the bounding box is expanded to a cube with the maximum region of interest dimension.

If `resize_dimension` is provided, the cropped image and mask are resized to the specified dimensions
using `imgtools.ops.functional.resize` with linear interpolation.
"""
# Check that the provided label is present in the mask
stats = sitk.LabelShapeStatisticsImageFilter()
stats.Execute(mask)
if label not in stats.GetLabels():
msg = f"Label {label} not present in mask. Must be one of {stats.GetLabels()}"
logger.exception(msg)
raise ValueError(msg)

# Generate bounding box based on the specified crop method
match crop_method:
case "bounding_box":
# Generate a bounding box around a mask
crop_box = RegionBox.from_mask_bbox(mask, label)

case "centroid":
if resize_dimension == None:
# Set resize_dimension to 50 if not provided -> default expected dimension for FMCIB
resize_dimension = 50

# Generate a cube bounding box with resize_dimensions around the centroid of a mask
crop_box = RegionBox.from_mask_centroid(mask, label).expand_to_cube(resize_dimension)

case "cube":
# Generate a bounding box around the mask, then expand the dimensions to a cube with the maximum bounding box dimension
crop_box = RegionBox.from_mask_bbox(mask, label)
crop_box = crop_box.expand_to_cube(max(crop_box.size))

case _:
msg = f"Invalid crop method: {crop_method}. Must be one of 'bounding_box', 'centroid', or 'cube'."
raise ValueError(msg)

# Crop the image and mask to the bounding box
cropped_image, cropped_mask = crop_box.crop_image_and_mask(image, mask)

if resize_dimension is not None:
# Resize and resample the cropped image with linear interpolation to desired dimensions
cropped_image = resize(cropped_image, size = resize_dimension, interpolation = 'linear')

# Resize and resample the cropped mask with nearest neighbor interpolation to desired dimensions
# This can end up being returned as a float32 image, so need to cast to uint8 to avoid issues with label values
cropped_mask = resize(cropped_mask, size = resize_dimension, interpolation = 'nearest')

# Cast the cropped mask to uint8 to avoid issues with label values
cropped_mask = sitk.Cast(cropped_mask, sitk.sitkUInt8)

return cropped_image, cropped_mask


if __name__ == "__main__":
from imgtools.io import read_dicom_series
from rich import print as rprint

from readii.loaders import loadRTSTRUCTSITK

image = read_dicom_series("tests/4D-Lung/113_HM10395/11-26-1999-NA-p4-13296/1.000000-P4P113S303I10349 Gated 40.0B-29543")

rois = loadRTSTRUCTSITK(rtstructPath = "tests/4D-Lung/113_HM10395/11-26-1999-NA-p4-13296/1.000000-P4P113S303I10349 Gated 40.0B-47.35/1-1.dcm",
baseImageDirPath = "tests/4D-Lung/113_HM10395/11-26-1999-NA-p4-13296/1.000000-P4P113S303I10349 Gated 40.0B-29543",
roiNames = "Tumor_c.*")

rprint("Original image size:", image.GetSize())

mask = rois["Tumor_c40"]

bbox_image, bbox_mask = crop_and_resize_image_and_mask(image, mask, crop_method = "bounding_box")
rprint(f"Bounding box: {bbox_image.GetSize()}")
rprint(f"Bounding box mask: {bbox_mask.GetSize()}")

centroid_image, centroid_mask = crop_and_resize_image_and_mask(image, mask, crop_method = "centroid")
rprint(f"Centroid: {centroid_image.GetSize()}")
rprint(f"Centroid mask: {centroid_mask.GetSize()}")

cube_image, cube_mask = crop_and_resize_image_and_mask(image, mask, crop_method = "cube")
rprint(f"Cube: {cube_image.GetSize()}")
rprint(f"Cube mask: {cube_mask.GetSize()}")
95 changes: 95 additions & 0 deletions tests/process/images/test_crop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import pytest
import SimpleITK as sitk

from readii.image_processing import loadDicomSITK, loadSegmentation
from readii.process.images.crop import (
crop_and_resize_image_and_mask
)

@pytest.fixture
def nsclcCT():
return "tests/NSCLC_Radiogenomics/R01-001/09-06-1990-NA-CT_CHEST_ABD_PELVIS_WITH_CON-98785/3.000000-THORAX_1.0_B45f-95741"


@pytest.fixture
def nsclcSEG():
return "tests/NSCLC_Radiogenomics/R01-001/09-06-1990-NA-CT_CHEST_ABD_PELVIS_WITH_CON-98785/1000.000000-3D_Slicer_segmentation_result-67652/1-1.dcm"


@pytest.fixture
def lung4D_ct_path():
return "tests/4D-Lung/113_HM10395/11-26-1999-NA-p4-13296/1.000000-P4P113S303I10349 Gated 40.0B-29543"


@pytest.fixture
def lung4D_rt_path():
return "tests/4D-Lung/113_HM10395/11-26-1999-NA-p4-13296/1.000000-P4P113S303I10349 Gated 40.0B-47.35/1-1.dcm"


@pytest.fixture
def lung4D_image(lung4D_ct_path):
return loadDicomSITK(lung4D_ct_path)


@pytest.fixture
def lung4D_mask(lung4D_ct_path, lung4D_rt_path):
segDictionary = loadSegmentation(
lung4D_rt_path,
modality="RTSTRUCT",
baseImageDirPath=lung4D_ct_path,
roiNames="Tumor_c.*",
)
return segDictionary["Tumor_c40"]


def test_default_crop_and_resize_image(lung4D_image, lung4D_mask):
expected_size = (92, 92, 92)
cropped_image, cropped_mask = crop_and_resize_image_and_mask(lung4D_image, lung4D_mask)
assert cropped_image.GetSize() == expected_size, \
f"Cropped image size is incorrect, expected {expected_size}, got {cropped_image.GetSize()}"
assert cropped_mask.GetSize() == expected_size, \
f"Cropped mask size is incorrect, expected {expected_size}, got {cropped_mask.GetSize()}"

@pytest.mark.parametrize(
"crop_method, resize_dimension, expected_size",
[
# No resizing
("bounding_box", None, (51, 92, 28)),
("centroid", None, (50, 50, 50)),
("cube", None, (92, 92, 92)),
# Resize down to 50x50x50
("bounding_box", 50, (50, 50, 50)),
("centroid", 50, (50, 50, 50)),
("cube", 50, (50, 50, 50)),
# Resize to odd value
("bounding_box", 49, (49, 49, 49)),
("centroid", 49, (49, 49, 49)),
("cube", 49, (49, 49, 49)),
# Resize up to 98x98x98
("bounding_box", 98, (98, 98, 98)),
("centroid", 98, (98, 98, 98)),
("cube", 98, (98, 98, 98)),
],
)
def test_crop_and_resize_image_and_mask_methods_and_resize_dimension(
lung4D_image,
lung4D_mask,
crop_method,
resize_dimension,
expected_size,
):
"""Test cropping image to mask with different methods"""
cropped_image, cropped_mask = crop_and_resize_image_and_mask(
lung4D_image,
lung4D_mask,
crop_method = crop_method,
resize_dimension = resize_dimension,
)
assert (
cropped_image.GetSize() == expected_size
), f"Cropped image size is incorrect, expected {expected_size}, got {cropped_image.GetSize()}"
assert (
cropped_mask.GetSize() == expected_size
), f"Cropped mask size is incorrect, expected {expected_size}, got {cropped_mask.GetSize()}"