Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion fastMONAI/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.13.0"
__version__ = "0.13.1"
13 changes: 6 additions & 7 deletions fastMONAI/_modidx.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@
'fastMONAI.utils._extract_size_from_transforms': ( 'utils.html#_extract_size_from_transforms',
'fastMONAI/utils.py'),
'fastMONAI.utils._extract_standard_config': ('utils.html#_extract_standard_config', 'fastMONAI/utils.py'),
'fastMONAI.utils._fail_open_callback_run': ('utils.html#_fail_open_callback_run', 'fastMONAI/utils.py'),
'fastMONAI.utils._get_x_column': ('utils.html#_get_x_column', 'fastMONAI/utils.py'),
'fastMONAI.utils._json_round_trip': ('utils.html#_json_round_trip', 'fastMONAI/utils.py'),
'fastMONAI.utils._monai_constructor': ('utils.html#_monai_constructor', 'fastMONAI/utils.py'),
Expand Down Expand Up @@ -458,18 +459,16 @@
'fastMONAI/vision_data.py'),
'fastMONAI.vision_data.show_batch': ('vision_data.html#show_batch', 'fastMONAI/vision_data.py'),
'fastMONAI.vision_data.show_results': ('vision_data.html#show_results', 'fastMONAI/vision_data.py')},
'fastMONAI.vision_inference': { 'fastMONAI.vision_inference._do_resize': ( 'vision_inference.html#_do_resize',
'fastMONAI/vision_inference.py'),
'fastMONAI.vision_inference._prediction_save_path': ( 'vision_inference.html#_prediction_save_path',
'fastMONAI.vision_inference': { 'fastMONAI.vision_inference._prediction_save_path': ( 'vision_inference.html#_prediction_save_path',
'fastMONAI/vision_inference.py'),
'fastMONAI.vision_inference._resize_labels': ( 'vision_inference.html#_resize_labels',
'fastMONAI/vision_inference.py'),
'fastMONAI.vision_inference._resample_labels': ( 'vision_inference.html#_resample_labels',
'fastMONAI/vision_inference.py'),
'fastMONAI.vision_inference._resample_to_grid': ( 'vision_inference.html#_resample_to_grid',
'fastMONAI/vision_inference.py'),
'fastMONAI.vision_inference._restore_prediction': ( 'vision_inference.html#_restore_prediction',
'fastMONAI/vision_inference.py'),
'fastMONAI.vision_inference._signed_depth': ( 'vision_inference.html#_signed_depth',
'fastMONAI/vision_inference.py'),
'fastMONAI.vision_inference._to_original_orientation': ( 'vision_inference.html#_to_original_orientation',
'fastMONAI/vision_inference.py'),
'fastMONAI.vision_inference._update_uid': ( 'vision_inference.html#_update_uid',
'fastMONAI/vision_inference.py'),
'fastMONAI.vision_inference.compute_binary_tumor_volume': ( 'vision_inference.html#compute_binary_tumor_volume',
Expand Down
33 changes: 26 additions & 7 deletions fastMONAI/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,25 @@ def _read_preprocessing_manifest(path, dataset_version=None):


# %% ../nbs/07_utils.ipynb #030876d2
# IDs of runs started by a ModelTrackingCallback and not ended yet. A fit that raises
# or is interrupted before after_fit leaves its run active; the next fit ends it as
# FAILED instead of joining it.
_OPEN_CALLBACK_RUNS = set()


def _fail_open_callback_run():
"""End the active run as FAILED if a ModelTrackingCallback started it and never ended it.

Returns True if a run was ended.
"""
active_run = mlflow.active_run()
if active_run is None or active_run.info.run_id not in _OPEN_CALLBACK_RUNS:
return False
_OPEN_CALLBACK_RUNS.discard(active_run.info.run_id)
mlflow.end_run(status='FAILED')
return True


class ModelTrackingCallback(Callback):
"""
A FastAI callback for comprehensive MLflow experiment tracking.
Expand Down Expand Up @@ -1247,15 +1266,12 @@ def before_fit(self) -> None:
With ``auto_start=False`` the active run is used. If no run is active, the
callback starts its own, so MLflow does not open an implicit run that stays
active after fit. A run started by this callback is ended with status FAILED
if this setup raises, or at the next fit if an earlier fit raised before
after_fit.
if this setup raises. A run that a ModelTrackingCallback started but never
ended, because its fit raised or was interrupted before after_fit, is ended
with status FAILED at the next fit.
"""
active_run = mlflow.active_run()
if (
self._auto_started and active_run is not None
and active_run.info.run_id == self.run_id
):
self.mark_failed()
if _fail_open_callback_run():
active_run = None

if self.auto_start or active_run is None:
Expand All @@ -1265,6 +1281,7 @@ def before_fit(self) -> None:
self.run_name = self.run_name or f"run_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
started_run = mlflow.start_run(run_name=self.run_name)
self.run_id = started_run.info.run_id
_OPEN_CALLBACK_RUNS.add(self.run_id)
self._auto_started = True
self._owns_run = True
self._run_failed = False
Expand Down Expand Up @@ -1324,6 +1341,7 @@ def after_fit(self) -> None:

if self._auto_started:
mlflow.end_run()
_OPEN_CALLBACK_RUNS.discard(self.run_id)
self._auto_started = False

def mark_failed(self) -> None:
Expand All @@ -1338,6 +1356,7 @@ def mark_failed(self) -> None:
mlflow.tracking.MlflowClient().set_terminated(
self.run_id, status='FAILED'
)
_OPEN_CALLBACK_RUNS.discard(self.run_id)
self._run_failed = True

def log_metrics(self, metrics: dict) -> None:
Expand Down
6 changes: 4 additions & 2 deletions fastMONAI/vision_augmentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1261,8 +1261,10 @@ def _apply_anisotropy(self, img, mask):
down_size = list(orig_size)
down_size[axis] = max(1, round(orig_size[axis] / factor))

# Downsample with nearest, upsample with trilinear
down = F.interpolate(sample, size=down_size, mode='nearest')
# Downsample with nearest at the sample centres, so the image moves at
# most half a voxel against the mask (as in TorchIO), then upsample with
# trilinear
down = F.interpolate(sample, size=down_size, mode='nearest-exact')
up = F.interpolate(down, size=orig_size, mode='trilinear',
align_corners=False)
img[i:i+1] = up
Expand Down
82 changes: 38 additions & 44 deletions fastMONAI/vision_inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import numpy as np
from scipy.ndimage import distance_transform_edt, label
from skimage.morphology import remove_small_objects
from torchio import Resize, Image, ScalarImage
from torchio import Pad, Resample, Image, ScalarImage
from .vision_core import *
from .vision_augmentation import do_pad_or_crop, transforms_from_specs
from fastMONAI.utils import (
Expand Down Expand Up @@ -45,28 +45,22 @@ def save_series_pred(series_obj, save_dir, val='1234'):

series_obj.write(save_dir, opts={'keep_uid': True}, formats=['dicom'])

# %% ../nbs/06_vision_inference.ipynb #e4a2771e-25aa-4669-bda2-abcbe29d0504
def _to_original_orientation(input_img, org_orientation):
"""Reorients the image to its original orientation."""

from SimpleITK import DICOMOrient, GetArrayFromImage
orientation_itk = DICOMOrient(input_img, org_orientation)
reoriented_array = GetArrayFromImage(orientation_itk).transpose()

return reoriented_array[None]

# %% ../nbs/06_vision_inference.ipynb #75a1169f-7385-4c48-9a24-51994c80732c
def _do_resize(o, target_shape, image_interpolation='linear',
label_interpolation='nearest'):
"""Resample images so the output shape matches the given target shape."""

resize = Resize(
target_shape,
image_interpolation=image_interpolation,
label_interpolation=label_interpolation
def _resample_to_grid(o, shape, affine, image_interpolation='linear',
label_interpolation='nearest'):
"""Resample an image onto the voxel grid given by shape and affine.

Predictions are restored onto the original image's own grid, so they line up with it
in any orientation. The input's edge voxels are repeated once first: a resampled grid
can end almost one of its own voxels short of the original image's edge voxels, and
those voxels take the edge value instead of 0.
"""
resample = Resample(
(tuple(int(n) for n in shape), np.asarray(affine, dtype=float)),
image_interpolation=image_interpolation,
label_interpolation=label_interpolation,
)

return resize(o)
return resample(Pad(1, padding_mode='edge')(o))

# %% ../nbs/06_vision_inference.ipynb #shape-based-label-resize
def _signed_depth(mask: np.ndarray, spacing, margin: int = 8) -> np.ndarray:
Expand All @@ -91,34 +85,34 @@ def _signed_depth(mask: np.ndarray, spacing, margin: int = 8) -> np.ndarray:
return depth


def _resize_labels(o, target_shape, label_interpolation='shape'):
"""Resize a single-channel label image so its shape matches target_shape.
def _resample_labels(o, shape, affine, label_interpolation='shape'):
"""Resample a single-channel label image onto the voxel grid given by shape and affine.

'shape' uses shape-based interpolation: the signed distance to the edge of the
foreground (all labels together) is resized with cubic B-spline interpolation and
foreground (all labels together) is resampled with cubic B-spline interpolation and
decides which voxels are foreground. With several labels, the same is done for each
label and every foreground voxel takes the label it lies deepest inside, so labels
that touch keep touching. A slice that falls between two source slices gets an
outline between theirs instead of a copy of the nearest one. 'nearest' copies the
nearest source voxel.
"""
if label_interpolation == 'nearest':
return _do_resize(o, target_shape, image_interpolation='nearest')
return _resample_to_grid(o, shape, affine, image_interpolation='nearest')
if label_interpolation != 'shape':
raise ValueError(
f"label_interpolation must be 'shape' or 'nearest', got {label_interpolation!r}")
if tuple(o.spatial_shape) == tuple(target_shape):
if tuple(o.spatial_shape) == tuple(shape) and np.allclose(o.affine, affine):
return o
if o.data.shape[0] != 1:
raise ValueError(f"Expected a single-channel label image, got {o.data.shape[0]} channels")
labels = o.data[0].numpy()
values = [v for v in np.unique(labels) if v != 0]
if not values:
return _do_resize(o, target_shape, image_interpolation='nearest')
return _resample_to_grid(o, shape, affine, image_interpolation='nearest')
masks = [labels != 0] + ([labels == v for v in values] if len(values) > 1 else [])
depth = np.stack([_signed_depth(m, o.spacing) for m in masks])
resized = _do_resize(ScalarImage(tensor=torch.from_numpy(depth), affine=o.affine),
target_shape, image_interpolation='bspline')
resized = _resample_to_grid(ScalarImage(tensor=torch.from_numpy(depth), affine=o.affine),
shape, affine, image_interpolation='bspline')
values = torch.as_tensor(values, dtype=torch.float32)
label = values[resized.data[1:].argmax(0)] if len(values) > 1 else values[0]
restored = torch.where(resized.data[0] > 0, label, 0.)
Expand Down Expand Up @@ -217,17 +211,17 @@ def _prediction_save_path(save_path, fn, org_img):
return Path(save_path) / ('pred_' + Path(source_fn).name)


def _restore_prediction(pred, org_img, input_img, org_size, label_interpolation, save_fn=None):
def _restore_prediction(pred, org_img, input_img, label_interpolation, save_fn=None):
"""Map a predicted mask back to the original image space, then save or return it.

The mask is padded or cropped to the preprocessed shape, resized to the original shape,
reoriented to the original orientation and given the original affine.
The mask is padded or cropped to the preprocessed shape, then resampled onto the
original image's grid, which gives it the original shape, orientation and affine.
"""
pred_mask = do_pad_or_crop(pred.float(), input_img.shape[1:], padding_mode=0, mask_name=None)
input_img.set_data(pred_mask)
input_img = _resize_labels(input_img, org_size, label_interpolation)
reoriented_array = _to_original_orientation(input_img.as_sitk(), ''.join(org_img.orientation))
pred_img = ScalarImage(tensor=reoriented_array, affine=org_img.affine.copy())
restored = _resample_labels(input_img, org_img.spatial_shape, org_img.affine,
label_interpolation)
pred_img = ScalarImage(tensor=restored.data.float(), affine=org_img.affine.copy())
if save_fn is None:
return pred_img
pred_img.save(save_fn)
Expand All @@ -253,8 +247,8 @@ def inference_from_config(model, inference_config, fn: (str, Path) = '',

apply_reorder = inference_config['apply_reorder']
target_spacing = inference_config['target_spacing']
if org_img is None or input_img is None or org_size is None:
org_img, input_img, org_size = med_img_reader(
if org_img is None or input_img is None:
org_img, input_img, _ = med_img_reader(
fn, apply_reorder, target_spacing, only_tensor=False
)
else:
Expand All @@ -271,7 +265,7 @@ def inference_from_config(model, inference_config, fn: (str, Path) = '',
'Decoded segmentation must have shape [D, H, W] or [1, D, H, W]; '
f'got {tuple(pred.shape)}.'
)
return _restore_prediction(pred, org_img, input_img, org_size, label_interpolation, save_fn)
return _restore_prediction(pred, org_img, input_img, label_interpolation, save_fn)

# %% ../nbs/06_vision_inference.ipynb #2ba94a55-2cd1-4fae-b8b4-e82051be0cdc
def inference(learn_inf, apply_reorder, target_spacing, fn: (str, Path) = '',
Expand All @@ -286,13 +280,13 @@ def inference(learn_inf, apply_reorder, target_spacing, fn: (str, Path) = '',
apply_reorder: Whether to reorder the image to RAS+, as in training.
target_spacing: Voxel spacing used in training, or None to keep the original spacing.
fn: Image path, or a list of paths stacked as channels. Not needed when
`org_img`, `input_img` and `org_size` are given.
`org_img` and `input_img` are given.
save_path: Directory to save the mask in as 'pred_<file name>'. If None, the mask
is returned instead.
org_img: Original image, as returned by `med_img_reader(..., only_tensor=False)`.
input_img: Preprocessed image from the same call. It is cast to float32 before
prediction.
org_size: Original image shape from the same call.
org_size: Not used; accepted so existing calls keep working.
label_interpolation: 'shape' (default) restores the mask with shape-based
interpolation; 'nearest' copies the nearest voxel instead.

Expand All @@ -303,15 +297,15 @@ def inference(learn_inf, apply_reorder, target_spacing, fn: (str, Path) = '',

learn_inf = unwrap_compiled_model(learn_inf)

if None in [org_img, input_img, org_size]:
org_img, input_img, org_size = med_img_reader(fn, apply_reorder, target_spacing,
only_tensor=False)
if org_img is None or input_img is None:
org_img, input_img, _ = med_img_reader(fn, apply_reorder, target_spacing,
only_tensor=False)
else:
org_img, input_img = copy(org_img), copy(input_img)
save_fn = _prediction_save_path(save_path, fn, org_img)

pred, *_ = learn_inf.predict(input_img.data.float())
return _restore_prediction(pred, org_img, input_img, org_size, label_interpolation, save_fn)
return _restore_prediction(pred, org_img, input_img, label_interpolation, save_fn)

# %% ../nbs/06_vision_inference.ipynb #1730df1e-e6d7-4fa7-b40e-83f4dda26dc5
def compute_binary_tumor_volume(mask_data: Image):
Expand Down
Loading
Loading